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.CJKLeakRate < 0 || q.CJKLeakRate > 1 { t.Errorf("rates must be within [0,1], got echo=%.3f cjk=%.3f", q.EchoRate, q.CJKLeakRate) } 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.CJKLeakChunks != 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) } }