textmachine/backend/internal/pipeline/quality_test.go

121 lines
5.5 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"
)
// 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 (претензия-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 := narrativeStructure(c.text)
if sent != c.wantSent || para != c.wantPara {
t.Errorf("%s: 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.TotalChunks != 1 || q.TextChunks != 1 || q.ProcessedChunks != 1 {
t.Fatalf("chunk counts: total=%d processed=%d text=%d, want 1/1/1", q.TotalChunks, q.ProcessedChunks, q.TextChunks)
}
// Rates are bounded [0,1] over ProcessedChunks (the disjoint-set bug fix).
if q.EchoRate < 0 || q.EchoRate > 1 || q.CosmeticStripRate < 0 || q.CosmeticStripRate > 1 {
t.Errorf("rates must be within [0,1], got echo=%.3f cosmetic=%.3f", q.EchoRate, 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, no CJK strip, no trust-gated, no dialogue-dash issue.
if q.GlossaryMisses != 0 || q.EchoChunks != 0 || q.CosmeticStripChunks != 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 chunk is gate-withheld → processed but NOT text-bearing.
if q.ProcessedChunks != 1 {
t.Fatalf("the withheld chunk still reached the final stage: processed=%d", q.ProcessedChunks)
}
if q.TextChunks != 0 || q.NarrativeSentences != 0 || q.NarrativeParagraphs != 0 {
t.Errorf("gate-withheld chunk must be excluded from the KPI, got text=%d sent=%d para=%d",
q.TextChunks, 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)
}
}