121 lines
5.5 KiB
Go
121 lines
5.5 KiB
Go
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)
|
||
}
|
||
}
|