textmachine/backend/internal/pipeline/quality_test.go

123 lines
5.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 pipeline
import (
"context"
"testing"
"textmachine/backend/internal/checks"
)
// quality_test.go pins the per-run quality-report (D39 слой 5, H5): the claim-1 structural KPI and
// the aggregation of the already-stored deterministic signals. Pure read-only projection.
// TestNarrativeStructure pins the claim-1 KPI: narrative paragraphs (non-dialogue lines) and their
// sentence count. A choppy text (one sentence per line) reads ≈1.0; merged prose reads higher;
// dialogue turns are excluded.
func TestNarrativeStructure(t *testing.T) {
cases := []struct {
name string
text string
wantSent int
wantPara int
}{
// Choppy: 3 one-sentence narrative lines → 3 sentences / 3 paragraphs = 1.0 (claim-1).
{"choppy", "Он вышел.\nНебо серело.\nОн вздохнул.", 3, 3},
// Merged: one paragraph, 3 sentences → 3 / 1 = 3.0 (the reflow fix).
{"merged", "Он вышел за дверь. Небо серело. Он вздохнул, глядя вдаль.", 3, 1},
// Dialogue turns excluded; only the narrative line counts.
{"dialogue-excluded", "Дядя нахмурился.\n— Время летит. Садись, — сказал он.\n— Спасибо.", 1, 1},
// Blank lines skipped; a two-sentence narrative paragraph plus a one-sentence one.
{"mixed", "Первый абзац. Ещё предложение.\n\nВторой абзац.", 3, 2},
}
for _, c := range cases {
sent, para := checks.NarrativeStructure(c.text)
if sent != c.wantSent || para != c.wantPara {
t.Errorf("%s: checks.NarrativeStructure = (%d sent, %d para), want (%d, %d)", c.name, sent, para, c.wantSent, c.wantPara)
}
}
}
// TestQualityReportAggregates runs a real 1-chunk book and asserts the read-only quality projection
// aggregates the structural KPI from the exported final text plus the stored signals.
func TestQualityReportAggregates(t *testing.T) {
rec := &reqRec{}
// The editor (final stage) emits a KNOWN 3-line final: 2 narrative paragraphs (3 sentences total)
// + 1 dialogue turn (excluded) → mean 1.5 sentences/narrative-paragraph.
editorFinal := "Судзуки шёл по коридору. Он думал о вчерашней лекции.\nБиблиотека была тихой.\n— Доброе утро, — сказал он."
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return editorFinal, "stop"
}
return "Судзуки шёл по коридору.", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{})
ctx := context.Background()
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
q, err := r.QualityReport()
if err != nil {
t.Fatalf("QualityReport: %v", err)
}
if q.TotalUnits != 1 || q.TextUnits != 1 || q.ProcessedUnits != 1 {
t.Fatalf("unit counts: total=%d processed=%d text=%d, want 1/1/1", q.TotalUnits, q.ProcessedUnits, q.TextUnits)
}
// Rates are bounded [0,1].
if q.EchoDraftRate < 0 || q.EchoDraftRate > 1 || q.EchoEditRate < 0 || q.EchoEditRate > 1 || q.CosmeticStripRate < 0 || q.CosmeticStripRate > 1 {
t.Errorf("rates must be within [0,1], got echo_draft=%.3f echo_edit=%.3f cosmetic=%.3f", q.EchoDraftRate, q.EchoEditRate, q.CosmeticStripRate)
}
if q.NarrativeSentences != 3 || q.NarrativeParagraphs != 2 {
t.Errorf("structural KPI = %d sent / %d para, want 3/2", q.NarrativeSentences, q.NarrativeParagraphs)
}
if q.MeanSentPerNarrPara != 1.5 {
t.Errorf("mean sentences/narrative-paragraph = %.3f, want 1.5", q.MeanSentPerNarrPara)
}
// A clean run: no glossary misses, no echo (draft or edit), no CJK strip, no trust-gated, no dash issue.
if q.GlossaryMisses != 0 || q.EchoDraftChunks != 0 || q.EchoEditUnits != 0 || q.CosmeticStripUnits != 0 || q.TrustGated != 0 || q.DialogueDashFlags != 0 {
t.Errorf("clean run must have zero defect signals, got %+v", q)
}
if len(q.Chunks) != 1 || q.Chunks[0].NarrativeParagraphs != 2 {
t.Errorf("per-chunk row missing/wrong: %+v", q.Chunks)
}
}
// TestQualityReportExcludesGateWithheld pins F5 (D39.4): a chunk the glossary post-check GATE withheld
// (flagged glossary_miss, empty export) must NOT be counted in TextChunks / the structural KPI — its
// text never ships, so counting it would diverge from `tmctl export`/`translate`.
func TestQualityReportExcludesGateWithheld(t *testing.T) {
srv := newJSONProvider(&reqRec{}, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ.", "stop" // final drops the approved name → confirmed miss
}
return "Некто пошёл в библиотеку.", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
q, err := r.QualityReport()
if err != nil {
t.Fatalf("QualityReport: %v", err)
}
// The single unit is gate-withheld → processed but NOT text-bearing.
if q.ProcessedUnits != 1 {
t.Fatalf("the withheld unit still reached the final stage: processed=%d", q.ProcessedUnits)
}
if q.TextUnits != 0 || q.NarrativeSentences != 0 || q.NarrativeParagraphs != 0 {
t.Errorf("gate-withheld unit must be excluded from the KPI, got text=%d sent=%d para=%d",
q.TextUnits, q.NarrativeSentences, q.NarrativeParagraphs)
}
// It IS visible as a glossary miss (the observability signal is kept).
if q.GlossaryMisses == 0 {
t.Errorf("the confirmed miss must still be reported: %+v", q)
}
}