295 lines
17 KiB
Go
295 lines
17 KiB
Go
package pipeline
|
||
|
||
// bookconsistency_test.go: the aggregation half of backlog row 406 — the part that finally compares one
|
||
// chapter with another. Each fixture spans at least two chapters, because a one-chapter fixture cannot tell
|
||
// this measure apart from the per-chunk post-check it was built to get past.
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
func consistencyBank(rows []store.GlossaryEntry) *membank.Bank {
|
||
return membank.MaterializeBank(membank.BankInput{
|
||
Rows: rows, TargetStemmer: lang.NewTargetStemmer(lang.TargetChecksFor("ru")),
|
||
}, false)
|
||
}
|
||
|
||
func row(src, dst, status string) store.GlossaryEntry {
|
||
return store.GlossaryEntry{BookID: "b", Src: src, Dst: dst, Status: status, Source: "seed"}
|
||
}
|
||
|
||
// TestATermRenderedTwoWaysAcrossChaptersIsNamedWithItsChapters is the measure's reason to exist. The term
|
||
// below reaches the reader as the bank wrote it in chapter 1 and as something else in chapter 3; no per-chunk
|
||
// check can see that, because neither chunk is wrong on its own — chapter 1 is clean and chapter 3 is a
|
||
// miss, and only holding both at once makes it an inconsistency. The chapters must be NAMED: "term X went out
|
||
// two ways" without them is not something an editor can act on.
|
||
func TestATermRenderedTwoWaysAcrossChaptersIsNamedWithItsChapters(t *testing.T) {
|
||
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
c.add(1, "蛊师来了。", "Гу-мастер пришёл.")
|
||
c.add(3, "蛊师走了。", "Заклинатель ушёл.")
|
||
got := c.finish(rows)
|
||
|
||
if got.Split != 1 {
|
||
t.Fatalf("a term shipped the bank's way in ch1 and otherwise in ch3 must count as ONE split; got split=%d absent=%d covered=%d", got.Split, got.Absent, got.Covered)
|
||
}
|
||
if len(got.Terms) != 1 {
|
||
t.Fatalf("want exactly one named term, got %d: %+v", len(got.Terms), got.Terms)
|
||
}
|
||
term := got.Terms[0]
|
||
if term.Verdict != "split" || term.Src != "蛊师" {
|
||
t.Errorf("wrong term reported: %+v", term)
|
||
}
|
||
if len(term.WithForm) != 1 || term.WithForm[0] != 1 {
|
||
t.Errorf("the chapter that GOT the bank's rendering must be named: want [1], got %v", term.WithForm)
|
||
}
|
||
if len(term.WithoutForm) != 1 || term.WithoutForm[0] != 3 {
|
||
t.Errorf("the chapter that did NOT is the actionable half: want [3], got %v", term.WithoutForm)
|
||
}
|
||
}
|
||
|
||
// TestTheSameTermCleanInEveryChapterIsNotSplit is the negative twin. Without it the pin above would pass on
|
||
// an aggregation that called every multi-chapter term a split, which is the easiest way to get a
|
||
// consistency report that is loud and worthless.
|
||
func TestTheSameTermCleanInEveryChapterIsNotSplit(t *testing.T) {
|
||
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
c.add(1, "蛊师来了。", "Гу-мастер пришёл.")
|
||
c.add(3, "蛊师走了。", "Гу-мастер ушёл.")
|
||
got := c.finish(rows)
|
||
if got.Split != 0 || got.Absent != 0 {
|
||
t.Errorf("a term shipped the bank's way in both chapters must be clean: split=%d absent=%d", got.Split, got.Absent)
|
||
}
|
||
if got.Covered != 1 {
|
||
t.Errorf("covered: got %d, want 1 — a clean term still has to appear in the denominator", got.Covered)
|
||
}
|
||
}
|
||
|
||
// TestEveryZeroCarriesThePopulationItCameFrom pins the denominators, and it is the one test here that is
|
||
// about the REPORT rather than the verdict. A consistency report whose only content is "0 problems" is
|
||
// indistinguishable from one that looked at nothing, and the run of 11.09 shipped exactly that shape three
|
||
// times from the polygon's own instrument. The fixture therefore holds one row of every kind that can make a
|
||
// count zero for a DIFFERENT reason, and asserts each lands in its own bucket.
|
||
func TestEveryZeroCarriesThePopulationItCameFrom(t *testing.T) {
|
||
rows := []store.GlossaryEntry{
|
||
row("蛊师", "гу-мастер", "approved"), // fires, shipped
|
||
row("仙人", "небожитель", "approved"), // never occurs in the source at all
|
||
row("妖怪", "", "approved"), // no rendering to look for
|
||
// A rendering that SURFACES without its key ever firing — a common-noun dst recurring away from its
|
||
// term, the recall cost dstFormPresent documents. It is not a judgement about the term either way,
|
||
// and counting it as a term shipped correctly is how a never-fired row quietly joins the denominator
|
||
// of terms that were actually judged.
|
||
row("宝剑", "меч", "approved"),
|
||
}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
c.add(1, "蛊师来了。", "Гу-мастер пришёл. В углу стоял меч.")
|
||
got := c.finish(rows)
|
||
|
||
for _, k := range []struct {
|
||
name string
|
||
got int
|
||
want int
|
||
}{
|
||
{"bank rows", got.BankRows, 4},
|
||
{"judgeable (a dst to look for)", got.Judged, 3},
|
||
{"no dst", got.NoDst, 1},
|
||
{"key never fired in the source", got.NeverFired, 2},
|
||
{"covered", got.Covered, 1},
|
||
{"absent", got.Absent, 0},
|
||
{"split", got.Split, 0},
|
||
{"units judged", got.UnitsJudged, 1},
|
||
} {
|
||
if k.got != k.want {
|
||
t.Errorf("%s: got %d, want %d — the zeroes here must be separable, that is the whole point of the line", k.name, k.got, k.want)
|
||
}
|
||
}
|
||
// The partition must close. A row that falls out of every bucket is how a denominator quietly stops
|
||
// meaning what it says, and subtraction is what NeverFired is derived by.
|
||
if sum := got.Covered + got.Absent + got.Split + got.BlockedOnly + got.SuppressedSilent + got.NeverFired; sum != got.Judged {
|
||
t.Errorf("the judgeable rows do not add up: covered+absent+split+blocked+eaten+never-fired=%d, judgeable=%d", sum, got.Judged)
|
||
}
|
||
}
|
||
|
||
// TestSigningIsAFactAboutTheBookNotAboutATerm pins the axis correction. There is no such product unit as a
|
||
// signed TERM: a bank is signed as a whole, or the book travels with an unsigned one and the engine
|
||
// auto-continues (D39.144). The law on the wire is the same for every row either way (D39.104), so a term's
|
||
// own status must not cut the consistency counts — it would report on a unit the product does not have.
|
||
//
|
||
// The fixture holds one row of each engine status and asserts BOTH halves: the term counts see two absences
|
||
// and know nothing of status, while the book reports its signing state once, as a count of rows.
|
||
func TestSigningIsAFactAboutTheBookNotAboutATerm(t *testing.T) {
|
||
rows := []store.GlossaryEntry{
|
||
row("蛊师", "гу-мастер", "approved"),
|
||
row("仙人", "небожитель", "draft"),
|
||
}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
c.add(1, "蛊师和仙人来了。", "Заклинатель и бессмертный пришли.")
|
||
got := c.finish(rows)
|
||
|
||
if got.Absent != 2 {
|
||
t.Errorf("both renderings are missing from this output and a term's status must not change that: got absent=%d, want 2", got.Absent)
|
||
}
|
||
if got.UnsignedRows != 1 {
|
||
t.Errorf("the BOOK's signing state is a row count, said once: got unsigned=%d of %d judgeable, want 1", got.UnsignedRows, got.Judged)
|
||
}
|
||
}
|
||
|
||
// TestABankWithoutARowLeavesTheMeasureAbsentRatherThanClean pins the nil path. A book with no glossary must
|
||
// report NOTHING here, not a clean sheet: "no bank to judge against" and "judged and found consistent" are
|
||
// the same output otherwise, and the reassuring one is the wrong one.
|
||
func TestABankWithoutARowLeavesTheMeasureAbsentRatherThanClean(t *testing.T) {
|
||
var c *consistencyScan // the shape QualityReport produces for a book with no bank rows
|
||
c.add(1, "蛊师来了。", "Гу-мастер пришёл.")
|
||
if got := c.finish(nil); got != nil {
|
||
t.Errorf("a book with no bank must produce no consistency section at all, got %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestARenderingSurfacingWhereTheTermDidNotOccurIsNotCoverage pins the guard that keeps a chapter out of a
|
||
// term's picture when the term is not in that chapter's SOURCE. The dst here is a common noun, so it recurs
|
||
// in chapter 3 with nothing to do with the term — and if that counted as "chapter 3 got the bank's
|
||
// rendering", the term would read as split (one chapter with, one without) instead of what it is: a term
|
||
// that occurred once and was rendered otherwise.
|
||
//
|
||
// ⚠ A fixture where the stray surfacing lands in the SAME chapter as the firing cannot show this — the
|
||
// chapter is already in the picture — and a fixture where the term never fires at all cannot either, because
|
||
// such a row is discarded later anyway. It has to be a term that fires in one chapter and whose rendering
|
||
// turns up in another.
|
||
func TestARenderingSurfacingWhereTheTermDidNotOccurIsNotCoverage(t *testing.T) {
|
||
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
c.add(1, "蛊师来了。", "Заклинатель пришёл.") // the term fires here and is rendered otherwise
|
||
c.add(3, "他走了。", "Мимо прошёл какой-то гу-мастер.") // the rendering surfaces; the term is not in this source
|
||
got := c.finish(rows)
|
||
|
||
if got.Absent != 1 {
|
||
t.Fatalf("the term fired once and its rendering never came out where it fired: want absent=1, got absent=%d split=%d covered=%d", got.Absent, got.Split, got.Covered)
|
||
}
|
||
if got.Split != 0 {
|
||
t.Errorf("chapter 3 must not count as a chapter that GOT the bank's rendering — the term is not in its source: split=%d", got.Split)
|
||
}
|
||
if len(got.Terms) == 1 && len(got.Terms[0].WithForm) != 0 {
|
||
t.Errorf("no chapter got the bank's rendering for this term, so the «with the bank's form» column must be empty: %v", got.Terms[0].WithForm)
|
||
}
|
||
}
|
||
|
||
// TestATermGoingOutTwoWaysINSIDEOneChapterIsSplit pins the within-chapter branch of the split rule, which
|
||
// every other split fixture here misses: they all span chapters, so the `splitIn` half of the verdict was
|
||
// never exercised and removing it left the battery green. A term rendered the bank's way for some of its
|
||
// occurrences and otherwise for the rest, inside ONE chapter, is a reader meeting two forms on one page —
|
||
// the worse case, not the milder one.
|
||
func TestATermGoingOutTwoWaysINSIDEOneChapterIsSplit(t *testing.T) {
|
||
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
// Three firings in one chapter; the bank's rendering comes out for one of them.
|
||
c.add(1, "蛊师来了。蛊师走了。蛊师站着。", "Гу-мастер вошёл. Заклинатель ушёл. Заклинатель встал.")
|
||
got := c.finish(rows)
|
||
|
||
if got.Split != 1 {
|
||
t.Fatalf("a term rendered two ways inside ONE chapter must read as split: split=%d covered=%d absent=%d", got.Split, got.Covered, got.Absent)
|
||
}
|
||
if len(got.Terms) != 1 {
|
||
t.Fatalf("the finding must be named: %+v", got.Terms)
|
||
}
|
||
if len(got.Terms[0].SplitIn) != 1 || got.Terms[0].SplitIn[0] != 1 {
|
||
t.Errorf("the chapter the split happened INSIDE must be named: want [1], got %v", got.Terms[0].SplitIn)
|
||
}
|
||
// And the cross-chapter columns must be empty here — otherwise this fixture is passing on the other
|
||
// branch and pins nothing new.
|
||
if len(got.Terms[0].WithoutForm) != 0 {
|
||
t.Errorf("no chapter is wholly without the rendering here; «without it» must be empty, got %v", got.Terms[0].WithoutForm)
|
||
}
|
||
}
|
||
|
||
// TestANestedRowWhoseCanonSURVIVEDIsNotCountedAsNeverFired pins the control line of the whole measure
|
||
// against the way it was quietly inflated. A row eaten by a longer key has NO firings of its own — its
|
||
// spans are removed before counting — so before this it fell out of every bucket and subtraction filed it
|
||
// under «key never fired in the source». That is the one line separating «the engine shipped the wrong
|
||
// thing» from «this term is not in this book», and every correctly-rendered nested row was padding it: on
|
||
// any book with aliases, which is any real book.
|
||
func TestANestedRowWhoseCanonSURVIVEDIsNotCountedAsNeverFired(t *testing.T) {
|
||
long := store.GlossaryEntry{BookID: "b", Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed",
|
||
Aliases: []store.GlossaryAlias{{Alias: "古月方源", AliasType: "name"}}}
|
||
nested := row("古月", "Гуюэ", "approved")
|
||
rows := []store.GlossaryEntry{long, nested}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
// The longer key wins the match; the nested row's canon comes out anyway.
|
||
c.add(1, "古月方源来了。", "Гуюэ Фан Юань пришёл.")
|
||
got := c.finish(rows)
|
||
|
||
if got.NeverFired != 0 {
|
||
t.Errorf("古月 IS in this source — it was eaten by a longer key, which is the suppressor working, not the term being absent from the book: never-fired=%d", got.NeverFired)
|
||
}
|
||
if got.Covered != 2 {
|
||
t.Errorf("both rows reached the reader: covered=%d absent=%d split=%d", got.Covered, got.Absent, got.Split)
|
||
}
|
||
if got.SuppressedSilent != 0 {
|
||
t.Errorf("the nested canon «Гуюэ» IS in the output, so nothing was silently lost: eaten=%d", got.SuppressedSilent)
|
||
}
|
||
if sum := got.NeverFired + got.BlockedOnly + got.Covered + got.Absent + got.Split; sum != got.Judged {
|
||
t.Errorf("the partition does not close: %d vs judgeable %d", sum, got.Judged)
|
||
}
|
||
}
|
||
|
||
// TestOneCleanChapterDoesNotEraseAnEatenCaseInAnother pins the eaten class as PER-CHAPTER. It used to be
|
||
// gated on the term having no firings at all, so a term rendered perfectly in chapter 1 wiped out the fact
|
||
// that a longer key ate it — and lost its canon — in chapter 3. That is the surface this pack advertises as
|
||
// the second root of row 407, reporting zero on a book that has one.
|
||
func TestOneCleanChapterDoesNotEraseAnEatenCaseInAnother(t *testing.T) {
|
||
long := store.GlossaryEntry{BookID: "b", Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed",
|
||
Aliases: []store.GlossaryAlias{{Alias: "古月方源", AliasType: "name"}}}
|
||
nested := row("古月", "Гуюэ", "approved")
|
||
rows := []store.GlossaryEntry{long, nested}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
c.add(1, "古月来了。", "Гуюэ пришёл.") // fires in its own right and ships correctly
|
||
c.add(3, "古月方源来了。", "Гу Юэ Фан Юань пришёл.") // eaten by the longer key, canon lost
|
||
|
||
got := c.finish(rows)
|
||
if got.SuppressedSilent != 1 {
|
||
t.Fatalf("chapter 3 lost 古月's canon behind a longer key; one clean chapter must not erase it: eaten=%d", got.SuppressedSilent)
|
||
}
|
||
var eaten *TermConsistency
|
||
for i := range got.Terms {
|
||
if got.Terms[i].Src == "古月" {
|
||
eaten = &got.Terms[i]
|
||
}
|
||
}
|
||
if eaten == nil {
|
||
t.Fatalf("the eaten case must be named, not only counted: %+v", got.Terms)
|
||
}
|
||
if len(eaten.EatenIn) != 1 || eaten.EatenIn[0] != 3 {
|
||
t.Errorf("the chapter where it was eaten must be named: want [3], got %v", eaten.EatenIn)
|
||
}
|
||
}
|
||
|
||
// TestTheVerdictRestsOnTheAnchoredColumn pins WHICH column the verdict is taken on, and it is the guard
|
||
// against this order being changed a third time by argument instead of by measurement. The fixture is the
|
||
// only kind that can pin it: a rendering the one-rune stem tolerance accepts and the safe rule does not
|
||
// («корифей пути демонов» against «корифея пути демонов»). On a fixture where the columns agree, ANY order
|
||
// passes — which is exactly how a planted swap survived here once already.
|
||
//
|
||
// ⛔ WHY THE ANCHORED ONE. The safe rule leans on a term's stored decl forms, and no machine-produced bank
|
||
// in this tree has any: zero decl rows in every mined export measured, against 35–42 of ~50 in every
|
||
// hand-written seed. The labelled corpus that says «the tolerance buys nothing» is built from hand seeds —
|
||
// it cannot show the tolerance's value, because it does not have the illness the tolerance treats. On the
|
||
// product's real path, a book with no hand seed and a mined bank, the stemmer has nothing to stem and this
|
||
// column is the only one that reaches an inflected rendering at all.
|
||
func TestTheVerdictRestsOnTheAnchoredColumn(t *testing.T) {
|
||
rows := []store.GlossaryEntry{row("魔道巨擘", "корифей пути демонов", "approved")}
|
||
c := newConsistencyScan(consistencyBank(rows))
|
||
c.add(1, "魔道巨擘来了。", "С точки зрения корифея пути демонов, это пустяк.")
|
||
got := c.finish(rows)
|
||
|
||
// PREMISE, and the whole point of the fixture: the two columns DISAGREE here. Without it the assertion
|
||
// below passes whichever column is consulted.
|
||
if got.AbsentSafe != 1 {
|
||
t.Fatalf("premise broken: the safe rule must NOT find «корифея» for «корифей», or the columns do not disagree: absent-safe=%d covered-safe=%d", got.AbsentSafe, got.CoveredSafe)
|
||
}
|
||
if got.Covered != 1 || got.Absent != 0 {
|
||
t.Errorf("the verdict must come from the ANCHORED column: «корифея пути демонов» IS this rendering inflected, and on a mined bank — which carries no decl forms — nothing else reaches it. got covered=%d absent=%d split=%d", got.Covered, got.Absent, got.Split)
|
||
}
|
||
}
|