textmachine/backend/internal/checks/checkers_generality_test.go

134 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
}
}