package checks // checkers_generality_test.go: mutation tests for the string-79 generality fixes (D39.39). They prove the // engine no longer hardcodes the SOURCE side of the magnitude checker to a CJK source — the same behaviour // is gated by the DECLARED source script (data), so a pair whose source is NOT in the repo behaves correctly // without a Go change. $0, no corpus needed. import ( "testing" "textmachine/backend/internal/lang" ) func TestNumberMagnitudeSourceScriptGate(t *testing.T) { pack, err := lang.Load("../../configs/langpacks", "zh", "ru") if err != nil { t.Fatalf("load pack: %v", err) } c := CompileCheckersFor(pack, lang.TargetChecksFor("ru")) // 三万 (3·10^4) rendered as «три миллиона» (3·10^6) is the documented 100× magnitude error. source, final := "杀了三万人", "убил три миллиона людей" // Declared Han source → the checker is active and fires. c.SetSourceScripts(lang.LangScripts("zh")) if n, _ := c.lintNumberMagnitude(source, final); n == 0 { t.Fatalf("zh (Han) source: the magnitude error must fire, got 0 — test inputs no longer trigger") } // Declared Latin source (a pair NOT in the repo) → inert BY DATA, no Go change: a stray Han magnitude // quoted inside a non-CJK source is not a magnitude the checker should judge. c.SetSourceScripts(lang.LangScripts("en")) if n, det := c.lintNumberMagnitude(source, final); n != 0 { t.Errorf("en (Latin) source: magnitude checker must be inert by declared script, got %d %v", n, det) } // ja (Han+kana) source → dense → active again (万 is shared with Japanese). c.SetSourceScripts(lang.LangScripts("ja")) if n, _ := c.lintNumberMagnitude(source, final); n == 0 { t.Errorf("ja source: dense script must keep the checker active") } } // TestHomoglyphDetectorAcceptance is the deferred приёмка of the homoglyph detector (D39.64 §5.5): the // package-6 «гy» (Cyrillic г + Latin y, the #7 case the all-Latin residue checker structurally cannot see // because it is a MIXED-script token) is caught by the existing tk.mixed detector in detectBrokenWords. This // closes #7 as a NON-latin-threshold concern: lowering minLatinResidueLen would not have caught it; the // homoglyph belongs to a different, already-built detector. func TestHomoglyphDetectorAcceptance(t *testing.T) { pack, err := lang.Load("../../configs/langpacks", "zh", "ru") if err != nil { t.Fatalf("load pack: %v", err) } c := CompileCheckersFor(pack, lang.TargetChecksFor("ru")) if n, det := c.detectBrokenWords("отличительная гy клана"); n == 0 { t.Errorf("homoglyph «гy» must fire the tk.mixed detector, got 0") } else { t.Logf("homoglyph detector fired: %v", det) } // A clean Cyrillic-only word must NOT fire (precision). if n, _ := c.detectBrokenWords("отличительная гу клана"); n != 0 { t.Errorf("clean Cyrillic «гу» must not fire the homoglyph detector, got %d", n) } } // TestInverseMarkerDashError is the mutation test for the k4_inverse build (D39.39): a dash-led line with an // inner-speech marker is a thought typeset as spoken dialogue and fires; a plain spoken dash line does not. func TestInverseMarkerDashError(t *testing.T) { pack, err := lang.Load("../../configs/langpacks", "zh", "ru") if err != nil { t.Fatalf("load pack: %v", err) } c := CompileCheckersFor(pack, lang.TargetChecksFor("ru")) cfg := CheapGateConfig{Checkers: c} // marker in the ATTRIBUTION → fires (thought typeset as spoken). if r := RunCheapGates("s", "d", "— Жаль этого гу, — вздохнул про себя Фан Юань.", cfg); r.DialogueDash == 0 { t.Errorf("dash line with «про себя» in the attribution must fire the inverse-marker error") } // multi-clause: marker in the FIRST attribution survives a continued-speech tail. if r := RunCheapGates("s", "d", "— Жаль, — вздохнул про себя Фан Юань. — В своё время я старался.", cfg); r.DialogueDash == 0 { t.Errorf("attribution marker before a continued-speech clause must still fire") } // ADVERSARIAL FP (review find): marker inside the SPOKEN part, real attribution «сказал» → must NOT fire. if r := RunCheapGates("s", "d", "— Повтори это про себя три раза, — сказал учитель.", cfg); r.DialogueDash != 0 { t.Errorf("a spoken line that merely SAYS «про себя» must not fire, got %d", r.DialogueDash) } // ordinary spoken dash line → does not fire. if r := RunCheapGates("s", "d", "— Здравствуй, — сказал старейшина громко.", cfg); r.DialogueDash != 0 { t.Errorf("a plain spoken dash line must not fire, got %d", r.DialogueDash) } // D39.78 counterexamples: the segment-cut confines the marker test to the IMMEDIATE attribution clause. // A marker in a following CONTINUED-SPEECH clause (after «. —») must not fire. if r := RunCheapGates("s", "d", "— Нет, — сказал он. — Я прочту это про себя.", cfg); r.DialogueDash != 0 { t.Errorf("«про себя» in a continued-speech clause after «. —» must not fire, got %d", r.DialogueDash) } // A marker in a trailing NARRATION sentence (no second dash at all) must not fire — the cut is at the mark, // not at the next join, so a bare «. Про себя…» tail is excluded. if r := RunCheapGates("s", "d", "— Хорошо, — кивнул он. Про себя же он выругался.", cfg); r.DialogueDash != 0 { t.Errorf("«про себя» in a trailing narration sentence must not fire, got %d", r.DialogueDash) } // The homograph «про себя»=«о себе» in a clause AFTER the immediate attribution must not fire. if r := RunCheapGates("s", "d", "— Хорошо, — сказал он, говоря про себя, а не про тебя.", cfg); r.DialogueDash != 0 { t.Errorf("the homograph «про себя» past the immediate attribution clause must not fire, got %d", r.DialogueDash) } // Word boundary: «в уме» must match as a whole phrase but NOT inside «в умении». if c.lineHasInnerMarker("сомневаясь в умении ждать") { t.Errorf("«в уме» must not match as a substring of «в умении» (word boundary)") } if !c.lineHasInnerMarker("он посчитал в уме и кивнул") { t.Errorf("«в уме» as a whole phrase must still match") } // data-driven generality: a target without inner_marker data leaves it inert. bare := CompileCheckers(nil, lang.TargetChecks{}) if bare.lineHasInnerMarker("— что-то про себя") { t.Errorf("no inner_marker data → the inverse-marker check must be inert") } } // TestChevronCitationNotFlagged is the mutation test for the #3 refinement (adversarial-review find): the // optional-comma attribution join must NOT flag a flat «X» — citation copula, even when a speech verb sits // elsewhere on the line; an exclamatory/comma-joined reply still fires. func TestChevronCitationNotFlagged(t *testing.T) { // flat citation, speech verb elsewhere on the line → NOT a spoken chevron. if chevronSpeechShape([]rune("«Бессмертный» — так его называли, с уважением произнёс старик")) { t.Errorf("flat «X» — citation must not match chevronSpeechShape") } // exclamatory reply → matches. if !chevronSpeechShape([]rune("«Гуюэ Фан Чжэн!» — крикнул старейшина")) { t.Errorf("exclamatory «X!» — reply must match") } // comma-joined reply → matches. if !chevronSpeechShape([]rune("«Реплика», — сказал он")) { t.Errorf("comma-joined «X», — reply must match") } }