package store import "testing" func gEntry(src, dst, status string, aliases ...string) GlossaryEntry { e := GlossaryEntry{BookID: "book", Src: src, Dst: dst, Status: status, Source: "seed"} for _, a := range aliases { e.Aliases = append(e.Aliases, GlossaryAlias{Alias: a, AliasType: "прозвище"}) } return e } func TestReplaceGlossaryIdempotentAndScoped(t *testing.T) { s, _ := openTemp(t) entries := []GlossaryEntry{ gEntry("阿Q", "А-кью", "approved"), gEntry("四叔", "Четвёртый дядюшка", "approved", "鲁四老爷"), } if err := s.ReplaceGlossary("book", entries); err != nil { t.Fatal(err) } // Another book is untouched. if err := s.ReplaceGlossary("other", []GlossaryEntry{{BookID: "other", Src: "x", Dst: "икс", Status: "approved"}}); err != nil { t.Fatal(err) } got1, err := s.GlossaryForBook("book") if err != nil { t.Fatal(err) } if len(got1) != 2 { t.Fatalf("want 2 entries, got %d", len(got1)) } // Re-replacing the identical set yields the identical content (idempotent). if err := s.ReplaceGlossary("book", entries); err != nil { t.Fatal(err) } got2, err := s.GlossaryForBook("book") if err != nil { t.Fatal(err) } if len(got2) != 2 { t.Fatalf("re-replace changed count: %d", len(got2)) } // The alias survived the replace and attached to the right term. var sishu *GlossaryEntry for i := range got2 { if got2[i].Src == "四叔" { sishu = &got2[i] } } if sishu == nil || len(sishu.Aliases) != 1 || sishu.Aliases[0].Alias != "鲁四老爷" { t.Fatalf("alias not preserved: %+v", sishu) } // other book still has its one row. other, _ := s.GlossaryForBook("other") if len(other) != 1 { t.Fatalf("scope leak: other book has %d rows", len(other)) } } func TestReplaceGlossaryRemovesDroppedRows(t *testing.T) { s, _ := openTemp(t) if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("阿Q", "А-кью", "approved"), gEntry("四叔", "дядюшка", "approved")}); err != nil { t.Fatal(err) } // Drop one term: full replace must make it disappear (no phantom). if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("阿Q", "А-кью", "approved")}); err != nil { t.Fatal(err) } got, _ := s.GlossaryForBook("book") if len(got) != 1 || got[0].Src != "阿Q" { t.Fatalf("dropped term still present: %+v", got) } } func TestGlossaryRevisionLogged(t *testing.T) { s, _ := openTemp(t) if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("王胡", "Бородатый Ван", "approved")}); err != nil { t.Fatal(err) } // Change the approved rendering: a B1 editorial revision must be journaled. if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("王胡", "Ван Ху", "approved")}); err != nil { t.Fatal(err) } revs, err := s.GlossaryRevisionsForBook("book") if err != nil { t.Fatal(err) } if len(revs) != 1 || revs[0].OldDst != "Бородатый Ван" || revs[0].NewDst != "Ван Ху" { t.Fatalf("revision not logged correctly: %+v", revs) } // Re-replacing identical does NOT add a second revision. if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("王胡", "Ван Ху", "approved")}); err != nil { t.Fatal(err) } revs, _ = s.GlossaryRevisionsForBook("book") if len(revs) != 1 { t.Fatalf("idempotent replace added a spurious revision: %d", len(revs)) } } func TestUniqueSrcSenseWindow(t *testing.T) { s, _ := openTemp(t) // Same src+sense+window twice → UNIQUE violation (B2). dup := []GlossaryEntry{gEntry("道", "путь", "approved"), gEntry("道", "дао", "approved")} if err := s.ReplaceGlossary("book", dup); err == nil { t.Fatal("expected UNIQUE(src,sense,window) violation for duplicate src+sense+window") } // Same src, different SENSE → allowed (polysemy, A3). ok := []GlossaryEntry{ {BookID: "book", Src: "道", Dst: "путь", Sense: "way", Status: "approved"}, {BookID: "book", Src: "道", Dst: "дао", Sense: "dao", Status: "approved"}, } if err := s.ReplaceGlossary("book", ok); err != nil { t.Fatalf("distinct senses should be allowed: %v", err) } got, _ := s.GlossaryForBook("book") if len(got) != 2 { t.Fatalf("want 2 polysemy rows, got %d", len(got)) } } func TestRetrievalStateUpsert(t *testing.T) { s, _ := openTemp(t) rs := RetrievalState{BookID: "book", Chapter: 1, ChunkIdx: 0, SnapshotID: "snap", NExactHits: 3, NAmbiguousFlagged: 1, NSpoilerBlocked: 1, NPostcheckMiss: 1, PostcheckDetail: `[{"src":"阿Q","dst":"А-кью"}]`, InjectedIDs: `["ah_q"]`} if err := s.UpsertRetrievalState(rs); err != nil { t.Fatal(err) } got, err := s.GetRetrievalState("book", 1, 0) if err != nil || got == nil { t.Fatalf("get: %v %v", got, err) } if got.NExactHits != 3 || got.NSpoilerBlocked != 1 || got.NPostcheckMiss != 1 { t.Fatalf("roundtrip mismatch: %+v", got) } // Overwrite (resolve semantics on resume): new counts win. rs.NExactHits = 5 rs.NPostcheckMiss = 0 if err := s.UpsertRetrievalState(rs); err != nil { t.Fatal(err) } got, _ = s.GetRetrievalState("book", 1, 0) if got.NExactHits != 5 || got.NPostcheckMiss != 0 { t.Fatalf("overwrite failed: %+v", got) } all, _ := s.RetrievalStatesForBook("book") if len(all) != 1 { t.Fatalf("want 1 retrieval-state row, got %d", len(all)) } }