textmachine/backend/internal/pipeline/waveselection_test.go

157 lines
6.1 KiB
Go

package pipeline
// waveselection_test.go: the record of what each wave was shown (backlog row 417, schema v17).
//
// The fixture is a chapter that splits into TWO draft chunks and groups into ONE edit unit, with a bank term
// whose key occurs ONLY in the second member. That shape is what makes the pin sharp: the leader's draft row
// cannot contain that term — the draft selected over the leader's own chunk, where the key is absent — so an
// editor record that carries it could not have been copied from the draft's.
import (
"context"
"encoding/json"
"strings"
"testing"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// TestTheEditorsSelectionIsRecordedAndIsNotTheDrafts pins the question the paid run of 11.09 could not put
// to the database: what did the EDITOR see. Before the v17 table the answer lived nowhere — the unit merge
// wrote the editor's post-check onto the leader's draft row and left the draft's injection counts in place,
// so `injected_ids` at a leader named the draft's rows and nothing named the editor's.
func TestTheEditorsSelectionIsRecordedAndIsNotTheDrafts(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// Two paragraphs sized as in TestWaveMultiChunkEditUnit: two draft chunks, one edit unit. 鈴木 is put in
// the SECOND paragraph only, so it cannot be in the leader chunk's own draft selection.
first := strings.Repeat("文", 1400) + "。"
second := strings.Repeat("字", 1090) + "鈴木は来た。"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: first + "\n\n" + second, regenerate: 0, glossarySeed: `
terms:
- src: 鈴木
dst: Судзуки
type: name
status: approved
decl: { invariant: true, forms: ["Судзуки"] }
`})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r0 := newRunner(t, bookPath)
manifest, err := r0.bookChunks()
r0.Close()
if err != nil {
t.Fatal(err)
}
units := buildEditUnits(manifest)
// PREMISE: without the 2-chunks-in-1-unit shape the two records would cover the same text and the
// comparison below would pass on a record that simply copied the draft's.
if len(manifest) != 2 || len(units) != 1 || len(units[0].Members) != 2 {
t.Fatalf("fixture must be 2 draft chunks in 1 edit unit, got %d chunks / %d units", len(manifest), len(units))
}
if strings.Contains(manifest[0].Text, "鈴木") || !strings.Contains(manifest[1].Text, "鈴木") {
t.Fatalf("premise broken: 鈴木 must occur in the SECOND member only — that is what makes the editor's record distinguishable")
}
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
waves, err := r1.Store.WaveSelectionsForBook(r1.Book.BookID)
r1.Close()
if err != nil {
t.Fatal(err)
}
byKey := map[string]store.WaveSelection{}
for _, w := range waves {
byKey[w.Wave+":"+itoa(w.ChunkIdx)] = w
}
leaderDraft, ok := byKey["draft:0"]
if !ok {
t.Fatalf("no draft record at the leader chunk; got %d wave rows: %+v", len(waves), waves)
}
edit, ok := byKey["edit:0"]
if !ok {
t.Fatalf("the EDITOR's selection was not recorded at the unit leader — that is the whole gap row 417 names; got %d wave rows: %+v", len(waves), waves)
}
// The editor is addressed at the leader and selects over the WHOLE unit, so its record must name the
// term that lives in the second member. The leader's own draft record must not.
if !hasSrc(t, edit.InjectedSrcs, "鈴木") {
t.Errorf("the editor's record at the leader does not name 鈴木, which occurs in the unit it edited: injected_srcs=%s", edit.InjectedSrcs)
}
if hasSrc(t, leaderDraft.InjectedSrcs, "鈴木") {
t.Errorf("premise broken: the LEADER's draft record names 鈴木, whose key is not in the leader chunk — the two records are not distinguishable: injected_srcs=%s", leaderDraft.InjectedSrcs)
}
if edit.NExactHits == 0 {
t.Errorf("the editor's record shows no exact hit at all, so nothing says the bank reached the editor's prompt: %+v", edit)
}
}
// TestAWaveRecordIsRewrittenNotAccumulatedOnResume pins the property that makes the table safe to recompute:
// a second run over the same book converges on the same rows instead of adding new ones. Without it the
// counts would grow with every resume and the column would measure how often a book was restarted.
func TestAWaveRecordIsRewrittenNotAccumulatedOnResume(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 400), regenerate: 0, glossarySeed: `
terms:
- src: 文
dst: Вэнь
type: name
status: approved
allow_short: true
decl: { invariant: true, forms: ["Вэнь"] }
`})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
first, err := r1.Store.WaveSelectionsForBook(r1.Book.BookID)
r1.Close()
if err != nil {
t.Fatal(err)
}
if len(first) == 0 {
t.Fatalf("premise broken: the first run recorded no wave selection at all, so a second run cannot show convergence")
}
r2 := newRunner(t, bookPath)
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
second, err := r2.Store.WaveSelectionsForBook(r2.Book.BookID)
r2.Close()
if err != nil {
t.Fatal(err)
}
if len(second) != len(first) {
t.Errorf("a resume must REWRITE the wave records, not add rows: %d after the first run, %d after the second", len(first), len(second))
}
for i := range first {
if first[i].NExactHits != second[i].NExactHits || first[i].InjectedSrcs != second[i].InjectedSrcs {
t.Errorf("a resume changed what the record says was shown at ch%d/chunk%d/%s: %+v → %+v",
first[i].Chapter, first[i].ChunkIdx, first[i].Wave, first[i], second[i])
}
}
}
func hasSrc(t *testing.T, jsonArray, want string) bool {
t.Helper()
var got []string
if err := json.Unmarshal([]byte(jsonArray), &got); err != nil {
t.Fatalf("injected_srcs is not a JSON array (%q): %v", jsonArray, err)
}
for _, s := range got {
if s == want {
return true
}
}
return false
}