Files
UmbraAudio/tests/test_speaker_review_audit.py
2026-10-18 19:01:04 -07:00

182 lines
6.7 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Tests for the speaker-review audit.
The audit is the gate between "the reviewer finished" and "the data goes to
translation". Two of its rules encode decisions that are easy to get wrong and
expensive to get wrong quietly:
- narration with no speaker is NOT a missing speaker; only dialogue counts;
- a speaker stored under two spellings is fine for audio (both resolve via the
alias map) but splits per-speaker exports, so it must still be reported.
The rest guards that the audit is read-only and does not invent findings.
"""
from __future__ import annotations
import json
import sqlite3
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.speaker_review_audit import write_reports # noqa: E402
from umbraaudio.web.routes import SCHEMA_SQL # noqa: E402
SPEAKERS = [
{"primary_name": "Narrator", "aliases": ["narrator", "the narrator"]},
{"primary_name": "Kainda", "aliases": ["Princess Kainda"]},
]
def _project(tmp_path: Path, utterances: list[tuple]) -> Path:
"""Build a throwaway project. ``utterances`` = (idx, type, speaker, text)."""
d = tmp_path / "proj" / ".umbra"
d.mkdir(parents=True)
(d / "speakers.json").write_text(json.dumps({"speakers": SPEAKERS}))
conn = sqlite3.connect(str(d / "project.db"))
conn.executescript(SCHEMA_SQL)
for i, (idx, utype, speaker, text) in enumerate(utterances):
conn.execute(
"INSERT INTO utterances (id, chapter_id, utterance_index, type, "
"speaker_name, text) VALUES (?, 1, ?, ?, ?, ?)",
(i + 1, idx, utype, speaker, text),
)
conn.commit()
conn.close()
return d
def _audit(tmp_path: Path, utterances, monkeypatch):
d = _project(tmp_path, utterances)
from umbraaudio.config import AppConfig
cfg = AppConfig()
cfg.project_dir = d.parent
cfg.data_dir = d
# Patch the name where it is USED. The script does
# "from umbraaudio.config import load_config", which binds at import time,
# so patching the config module would be ignored and these tests would
# quietly audit the real project instead of the fixture.
import scripts.speaker_review_audit as audit_mod
monkeypatch.setattr(audit_mod, "load_config", lambda _s=None: cfg)
return audit_mod.run_audit(None, False), d
def test_narration_without_a_speaker_is_not_a_defect(tmp_path, monkeypatch) -> None:
"""Narration is not spoken by a character; flagging it would bury real bugs."""
audit, _ = _audit(
tmp_path,
[(0, "narration", None, "The ship drifted.")],
monkeypatch,
)
assert [f for f in audit.findings if f.check == "dialogue_missing_speaker"] == []
assert audit.stats["narration_without_speaker"] == 1
def test_dialogue_without_a_speaker_is_reported(tmp_path, monkeypatch) -> None:
audit, _ = _audit(
tmp_path,
[(0, "dialogue", None, "Who goes there?"), (1, "narration", None, "Silence.")],
monkeypatch,
)
missing = [f for f in audit.findings if f.check == "dialogue_missing_speaker"]
assert len(missing) == 1
assert missing[0].chapter == 1
assert missing[0].severity == "action"
@pytest.mark.parametrize("placeholder", ["", " ", "__unassigned__", "unknown", "NONE"])
def test_placeholder_speaker_names_count_as_missing(
tmp_path, monkeypatch, placeholder
) -> None:
audit, _ = _audit(tmp_path, [(0, "dialogue", placeholder, "Hi.")], monkeypatch)
assert [f for f in audit.findings if f.check == "dialogue_missing_speaker"]
def test_two_spellings_of_one_speaker_are_reported(tmp_path, monkeypatch) -> None:
"""Both resolve to one voice, but a per-speaker export would split them."""
audit, _ = _audit(
tmp_path,
[(0, "dialogue", "Narrator", "One."), (1, "dialogue", "narrator", "Two.")],
monkeypatch,
)
variants = [f for f in audit.findings if f.check == "speaker_name_variants"]
assert {v.speaker for v in variants} == {"Narrator", "narrator"}
assert all(v.expected == "Narrator" for v in variants)
def test_a_single_spelling_is_not_reported(tmp_path, monkeypatch) -> None:
audit, _ = _audit(
tmp_path, [(0, "dialogue", "Kainda", "Hi."), (1, "dialogue", "Kainda", "Bye.")],
monkeypatch,
)
assert [f for f in audit.findings if f.check == "speaker_name_variants"] == []
def test_alias_spelling_is_reported_against_its_canonical(tmp_path, monkeypatch) -> None:
audit, _ = _audit(
tmp_path,
[(0, "dialogue", "Kainda", "A."), (1, "dialogue", "Princess Kainda", "B.")],
monkeypatch,
)
variants = [f for f in audit.findings if f.check == "speaker_name_variants"]
assert any(v.speaker == "Princess Kainda" and v.expected == "Kainda" for v in variants)
def test_deleted_speaker_is_reported_with_its_rows(tmp_path, monkeypatch) -> None:
audit, _ = _audit(
tmp_path,
[(0, "dialogue", "Gone", "A."), (1, "dialogue", "Kainda", "B.")],
monkeypatch,
)
orphans = [f for f in audit.findings if f.check == "speaker_not_resolvable"]
assert len(orphans) == 1
assert orphans[0].speaker == "Gone"
assert orphans[0].utterance == 0
def test_audit_does_not_modify_the_database(tmp_path, monkeypatch) -> None:
"""It is a review aid: it must never change the data it is describing."""
d = _project(tmp_path, [(0, "dialogue", None, "Who?"), (1, "dialogue", "Kainda", "Hi.")])
before = d.joinpath("project.db").read_bytes()
speakers_before = d.joinpath("speakers.json").read_bytes()
from umbraaudio.config import AppConfig
cfg = AppConfig()
cfg.project_dir = d.parent
cfg.data_dir = d
import scripts.speaker_review_audit as audit_mod
monkeypatch.setattr(audit_mod, "load_config", lambda _s=None: cfg)
audit_mod.run_audit(None, False)
assert d.joinpath("project.db").read_bytes() == before
assert d.joinpath("speakers.json").read_bytes() == speakers_before
def test_reports_are_written_with_chapter_and_utterance(tmp_path, monkeypatch) -> None:
audit, d = _audit(
tmp_path,
[
(0, "dialogue", "Kainda", '"I have to tell you." said the captain.'),
(1, "dialogue", None, "Who is there?"),
],
monkeypatch,
)
out = tmp_path / "out"
md, csv_path = write_reports(audit, out)
text = md.read_text()
assert "# Speaker review audit" in text
# Every row-level finding must be locatable by a human.
missing = [f for f in audit.findings if f.check == "dialogue_missing_speaker"][0]
assert f"| {missing.chapter} | {missing.utterance} |" in text
assert csv_path.exists()
header = csv_path.read_text().splitlines()[0]
assert "chapter" in header and "utterance" in header