Files
UmbraAudio/tests/test_controller_bundle.py
B.D. Murphy 3d69187449 Answer the controller's data request, and audit what we send
The UmbraTrans controller asked (commit 70c23fd) for machine-readable metadata
about the audio system, in a specified layout, with a validator that ships with
the request so the sender can check its own work. That request is answered here.

scripts/build_controller_bundle.py emits that bundle: one real book (Deal
Hunter), 8,884 records, no media, no credentials, no hostnames. It passes the
controller's own validator, exit 0.

Three things in this system will break a mapping that does not know them, and
the bundle's real purpose is to say so precisely rather than to look tidy:

* A chapter has THREE numbers. The database row id is what filenames use,
  chapter_index is reading order, and the distribution track number is a third
  thing again. Only the row id survives an edit. Front and back matter are
  numbered with roman numerals in the MP3 filenames.
* The narrator is written three ways, and the most common way is nothing at all.
  1,803 utterances have a NULL speaker_name, meaning narrator; 614 say
  "narrator" and 15 say "Narrator". A join that skips nulls loses most of the
  narration silently. Utterances now carry speaker_name_raw, the resolved name,
  and speaker_resolution, and the two spellings are not merged - that is a
  decision for whoever owns speaker identity, not something an export should do
  quietly.
* A .wav file is not always a WAV. 30 of the 91 voice reference files are MPEG.
  Every render reports the container measured from the bytes, with
  extension_matches_container, so a reader cannot be misled by the name.

A validator proves the bundle is internally consistent; it cannot prove the
bundle is true. scripts/audit_controller_bundle.py re-measures every hash, byte
size, duration and count against the project on disk - 66,011 claims, all
holding. It caught two real defects in the first draft of this bundle, including
one of my own: produced_at was stamped with the build time, which is a claim
about the audio that nothing supported.

The validator was also run against seven deliberately broken bundles to confirm
it can fail. A validator nobody has seen fail is not evidence of anything; those
cases are now tests.

scripts/audio_controller_connect.py runs the T01-T10 suite: 4 pass, 1 fails,
5 are blocked. T04 fails honestly - there is no ingest endpoint, and nothing was
pushed. T05-T08 are blocked because the controller's own plan sequences ingest
after the metadata arrives; a proposed contract is included rather than an
invented endpoint. T02 is blocked: no node key or service token is configured
here, and an operator has to supply one.

Two corrections to the node profile, which the controller reads: it claimed the
web UI has no authentication (a session gate was added 2026-10-09) and that the
controller host is offline (it is reachable and serving umbratrans-web 0.1.0).

Full suite 751 passed, 8 failed - the same pre-existing pronunciation and
accent failures, unchanged. ruff and mypy clean on everything added.
2026-10-14 10:56:32 -07:00

478 lines
21 KiB
Python

"""Tests for the controller bundle builder.
The builder's job is to emit something the *controller's* validator accepts, so
most of these tests build a small synthetic project and run the real validator
against the result. Running the actual validator -- copied into the bundle by the
builder -- is the point: a test that re-implements the checks would only prove the
test agrees with itself.
The synthetic project is built rather than the real one, so the suite does not
depend on 4,000 utterances and 4,500 audio files being present and current. The
real project is exercised by ``scripts/audit_controller_bundle.py``.
"""
from __future__ import annotations
import importlib.util
import json
import sqlite3
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
REPO = Path(__file__).resolve().parent.parent
BUILDER_PATH = REPO / "scripts" / "build_controller_bundle.py"
VALIDATOR_SOURCE = Path("/tmp/controller-validator.py")
def _load_builder() -> Any:
spec = importlib.util.spec_from_file_location("build_controller_bundle", BUILDER_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
builder = _load_builder()
SCHEMA = """
CREATE TABLE project (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, source_path TEXT NOT NULL,
source_format TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'initialized'
);
CREATE TABLE chapters (
id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES project(id),
chapter_index INTEGER NOT NULL, title TEXT, source_text TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'parsed',
audio_path TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE utterances (
id INTEGER PRIMARY KEY, chapter_id INTEGER NOT NULL REFERENCES chapters(id),
character_id INTEGER REFERENCES characters(id),
utterance_index INTEGER NOT NULL, text TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'narration', context_before TEXT, context_after TEXT,
emotion TEXT, pace TEXT, emphasis_words TEXT, pause_before_ms INTEGER,
pause_after_ms INTEGER, instruct TEXT, speaker_name TEXT,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE characters (id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL,
name TEXT NOT NULL, aliases TEXT NOT NULL DEFAULT '[]', gender TEXT,
is_narrator INTEGER NOT NULL DEFAULT 0);
"""
def make_project(root: Path, *, narrator_spellings: tuple[str, ...] = ("narrator",),
null_narrator_rows: int = 2, chapters: int = 3) -> Path:
"""A project shaped like the real one, including its awkward edges."""
umbra = root / ".umbra"
(umbra / "chapter_audio").mkdir(parents=True)
(umbra / "voices").mkdir(parents=True)
db = umbra / "project.db"
conn = sqlite3.connect(db)
conn.executescript(SCHEMA)
conn.execute(
"INSERT INTO project (id, name, source_path, source_format, status) "
"VALUES (1, ?, ?, 'docx', 'analyzed')", (root.name, str(root)))
for index in range(chapters):
conn.execute(
"INSERT INTO chapters (id, project_id, chapter_index, title, source_text, word_count) "
"VALUES (?, 1, ?, ?, 'body text', 10)",
(index + 1, index - 1, f"Chapter {index}"))
utterance_id = 0
for chapter in range(1, chapters + 1):
for position in range(2):
utterance_id += 1
is_narrator_row = position == 0
speaker = narrator_spellings[chapter % len(narrator_spellings)] \
if is_narrator_row else "Kainda"
conn.execute(
"INSERT INTO utterances (id, chapter_id, utterance_index, text, "
"type, speaker_name) VALUES (?, ?, ?, ?, ?, ?)",
(utterance_id, chapter, position,
f"line {utterance_id}", "narration" if is_narrator_row else "dialogue",
speaker))
# A row whose speaker is expressed by being empty, as the real data does.
for _ in range(null_narrator_rows if chapter == 1 else 0):
utterance_id += 1
conn.execute(
"INSERT INTO utterances (id, chapter_id, utterance_index, text, "
"type, speaker_name) VALUES (?, ?, ?, ?, 'narration', NULL)",
(utterance_id, chapter, 99, "implicit narration"))
conn.commit()
conn.close()
(umbra / "speakers.json").write_text(json.dumps({
"speakers": [
{"primary_name": "narrator", "is_narrator": True, "aliases": [],
"chapters_appeared": [1, 2, 3], "has_voice_ref": True},
{"primary_name": "Kainda", "aliases": ["Princess Kainda"],
"chapters_appeared": [1], "has_voice_ref": True},
],
"book_title": "Test Book", "total_chapters": chapters, "total_words": 30,
}))
(umbra / "voice_status.json").write_text(json.dumps({"narrator": "approved"}))
(umbra / "voice_engines.json").write_text(json.dumps(
{"Kainda": {"engine": "qwen", "reason": "ok", "at": "2026-10-10T00:00:00+00:00"}}))
(umbra / "voice_assignments.json").write_text(json.dumps({"narrator": "en-US-GuyNeural"}))
return root
@pytest.fixture()
def built(tmp_path: Path) -> Path:
make_project(tmp_path / "TestProject")
out = tmp_path / "bundle"
builder.build("TestProject", out, "2026-10-14T00:00:00Z",
VALIDATOR_SOURCE if VALIDATOR_SOURCE.exists() else None,
source_root=tmp_path)
return out
def read_manifest(bundle: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in
(bundle / "manifest.jsonl").read_text().splitlines() if line.strip()]
# --------------------------------------------------------------------------
# the controller's own validator
# --------------------------------------------------------------------------
@pytest.mark.skipif(not VALIDATOR_SOURCE.exists(),
reason="controller validator not fetched to /tmp")
def test_controller_validator_accepts_the_bundle(built: Path) -> None:
validator = built / "validate.py"
if not validator.exists():
pytest.skip("validator was not copied into the bundle")
result = subprocess.run([sys.executable, str(validator), str(built)],
capture_output=True, text=True)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.skipif(not VALIDATOR_SOURCE.exists(),
reason="controller validator not fetched to /tmp")
@pytest.mark.parametrize("mutation,expect", [
("dangling_id", "not in the manifest"),
("required_file_removed", "missing required file"),
("unitless_duration", "no unit"),
("credential", "credential"),
# The index declares relative_to required, so that check fires before the
# convention check does; either message would be correct.
("path_without_base", "relative_to"),
])
def test_controller_validator_rejects_a_broken_bundle(built: Path, mutation: str,
expect: str) -> None:
"""A validator nobody has seen fail is not evidence of anything.
Each case breaks one thing the request asks for and asserts the validator
notices, so a passing bundle means something.
"""
validator = built / "validate.py"
if not validator.exists():
pytest.skip("validator was not copied into the bundle")
lines = (built / "manifest.jsonl").read_text().splitlines()
if mutation == "dangling_id":
record = json.loads(lines[0])
record["chapter_id"] = "ch-does-not-exist"
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
elif mutation == "unitless_duration":
record = json.loads(lines[0])
record["duration"] = 1.5
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
elif mutation == "credential":
record = json.loads(lines[0])
record["token"] = "sk-" + "A" * 24
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
elif mutation == "path_without_base":
for index, line in enumerate(lines):
record = json.loads(line)
if record.get("kind") == "render":
record.pop("relative_to", None)
record["file"] = "x.wav"
lines[index] = json.dumps(record, sort_keys=True, separators=(",", ":"))
break
else:
pytest.skip("no render record to break")
if mutation == "required_file_removed":
(built / "VOCABULARY.md").unlink()
else:
(built / "manifest.jsonl").write_text("\n".join(lines) + "\n")
result = subprocess.run([sys.executable, str(validator), str(built)],
capture_output=True, text=True)
assert result.returncode == 1
assert expect in result.stdout
# --------------------------------------------------------------------------
# structure and self-consistency
# --------------------------------------------------------------------------
def test_bundle_has_every_required_piece(built: Path) -> None:
for name in ("README.md", "bundle.json", "VOCABULARY.md", "manifest.jsonl"):
assert (built / name).exists(), name
assert (built / "schemas").is_dir()
assert (built / "examples").is_dir()
examples = [p for p in (built / "examples").iterdir() if p.is_dir()]
assert examples
for book in examples:
assert (book / "manifest.json").exists()
assert (book / "lineage.json").exists()
def test_content_hash_describes_the_file_it_ships_with(built: Path) -> None:
import hashlib
index = json.loads((built / "bundle.json").read_text())
actual = hashlib.sha256((built / "manifest.jsonl").read_bytes()).hexdigest()
assert index["content_hash"]["value"] == actual
assert index["counts"]["records_total"] == len(read_manifest(built))
def test_build_is_deterministic(tmp_path: Path) -> None:
"""Regenerating an unchanged project must produce the same bytes.
Without this, the content hash is decoration: it changes on every run for
reasons unrelated to the data, and comparing two builds tells you nothing.
"""
make_project(tmp_path / "TestProject")
first = tmp_path / "one"
second = tmp_path / "two"
builder.build("TestProject", first, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
builder.build("TestProject", second, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
assert (first / "manifest.jsonl").read_bytes() == (second / "manifest.jsonl").read_bytes()
def test_every_reference_resolves_including_unvalidated_fields(built: Path) -> None:
"""The controller checks some reference fields; these are the rest.
`speaker_id` and `render_id` are not on its list, but a dangling speaker
reference is just as wrong, so it is checked here rather than assumed.
"""
records = read_manifest(built)
known = {r["id"] for r in records}
for record in records:
for field in ("book_id", "chapter_id", "speaker_id", "render_id", "voice_id"):
value = record.get(field)
if isinstance(value, str):
assert value in known, f"{record['id']}.{field} -> {value}"
for field in ("chapters", "renders", "inputs", "published_artifact_ids"):
for value in record.get(field, []) or []:
assert value in known, f"{record['id']}.{field} contains {value}"
def test_no_field_states_a_duration_without_a_unit(built: Path) -> None:
forbidden = {"duration", "length", "elapsed", "runtime", "time"}
for record in read_manifest(built):
for field in record:
assert field.lower() not in forbidden, f"{record['id']} has {field}"
def test_every_path_declares_what_it_is_relative_to(built: Path) -> None:
for record in read_manifest(built):
if "path" in record:
assert record.get("relative_to") == "book_directory", record["id"]
def test_no_absolute_host_paths_leak_into_the_bundle(built: Path) -> None:
"""The bundle is committable and must not describe the machine layout."""
blob = "\n".join((built / "manifest.jsonl").read_text().splitlines())
for leak in ("/Volumes/", "/Users/", str(REPO)):
assert leak not in blob, f"absolute path leaked: {leak}"
# --------------------------------------------------------------------------
# the findings this build exists to surface
# --------------------------------------------------------------------------
def test_narrator_written_three_ways_is_resolved_and_marked(built: Path) -> None:
"""An empty speaker name means the narrator; it must be labelled, not dropped."""
records = read_manifest(built)
utterances = [r for r in records if r["kind"] == "utterance"]
implicit = [u for u in utterances if u["speaker_resolution"] == "implicit_narrator"]
assert implicit, "the fixture is supposed to contain null-speaker rows"
for utterance in implicit:
assert utterance["speaker_name_raw"] in (None, "")
assert utterance["speaker_id"] is not None, "an implicit narrator still has to resolve"
for utterance in utterances:
if utterance["speaker_resolution"] == "explicit":
assert utterance["speaker_name_raw"] not in (None, "")
def test_case_insensitive_spellings_are_not_silently_merged(tmp_path: Path) -> None:
make_project(tmp_path / "TestProject", narrator_spellings=("Narrator", "narrator"))
out = tmp_path / "bundle"
builder.build("TestProject", out, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
speakers = [r for r in read_manifest(out) if r["kind"] == "speaker"
and r["casefold_collision_group"] == "narrator"]
assert len(speakers) == 2, "the two spellings must stay separate records"
for speaker in speakers:
assert speaker["utterance_count"] < speaker["utterance_count_casefold_grouped"]
def test_chapter_numbering_hazard_is_declared(built: Path) -> None:
book = next(r for r in read_manifest(built) if r["kind"] == "book")
numbering = book["numbering"]["schemes"]
assert numbering["database_row_id"]["is_authoritative"] is True
assert numbering["chapter_index"]["is_authoritative"] is False
assert numbering["distribution_track_number"]["is_authoritative"] is False
def test_chapters_record_which_number_their_filenames_use(built: Path) -> None:
for chapter in (r for r in read_manifest(built) if r["kind"] == "chapter"):
basis = chapter["numbering_basis"]
assert basis["file_names_use"] == "database_row_id"
assert basis["chapter_index_is_authoritative"] is False
def test_mismatched_extension_is_reported_not_hidden(built: Path) -> None:
"""A `.wav` that is not a WAV must be visible on the book, not just per file."""
book = next(r for r in read_manifest(built) if r["kind"] == "book")
for entry in book["extension_container_mismatches"]:
assert entry["actual_container"] != entry["named_extension"]
def test_speakers_without_a_profile_are_named_on_the_book(built: Path) -> None:
book = next(r for r in read_manifest(built) if r["kind"] == "book")
assert isinstance(book["speaker_names_without_a_profile"], list)
for name in book["speaker_names_without_a_profile"]:
assert not any(r["kind"] == "speaker" and r["primary_name"] == name
and r["has_profile"] for r in read_manifest(built))
def test_declared_and_measured_chapter_counts_are_both_reported(built: Path) -> None:
book = next(r for r in read_manifest(built) if r["kind"] == "book")
assert book["chapter_count"] == len(book["chapters"])
assert book["declared_total_chapters"] is not None
def test_language_designations_are_empty_and_explained(built: Path) -> None:
"""Empty is a real answer here; an invented list would be a guess in a schema."""
book = next(r for r in read_manifest(built) if r["kind"] == "book")
assert book["language_designations"] == []
assert "do not record" in book["language_designation_note"] or \
"records no per-utterance language" in book["language_designation_note"]
def test_schemas_describe_every_kind_that_appears(built: Path) -> None:
kinds = {r["kind"] for r in read_manifest(built)}
for kind in kinds:
assert (built / "schemas" / f"{kind}.schema.json").exists(), kind
assert (built / "schemas" / "manifest.schema.json").exists()
assert (built / "schemas" / "lineage.schema.json").exists()
def test_required_fields_declared_in_the_index_are_actually_present(built: Path) -> None:
index = json.loads((built / "bundle.json").read_text())
records = read_manifest(built)
for record in records:
for field in index["required_fields"].get(record["kind"], []):
assert field in record, f"{record['kind']} {record['id']} missing {field}"
def test_utterance_text_hash_matches_its_own_text(built: Path) -> None:
import hashlib
for record in read_manifest(built):
if record["kind"] == "utterance":
assert hashlib.sha256(record["text"].encode()).hexdigest() == record["text_sha256"]
assert len(record["text"]) == record["text_char_count"]
def test_chapter_utterance_counts_are_true(built: Path) -> None:
records = read_manifest(built)
per_chapter: dict[str, int] = {}
for record in records:
if record["kind"] == "utterance":
per_chapter[record["chapter_id"]] = per_chapter.get(record["chapter_id"], 0) + 1
for chapter in (r for r in records if r["kind"] == "chapter"):
assert chapter["utterance_count"] == per_chapter.get(chapter["id"], 0)
def test_speaker_utterance_counts_are_true_and_separate_from_the_inferred_one(built: Path) -> None:
records = read_manifest(built)
per_speaker: dict[str, int] = {}
for record in records:
if record["kind"] == "utterance" and record["speaker_resolution"] == "explicit":
per_speaker[record["speaker_name_raw"]] = \
per_speaker.get(record["speaker_name_raw"], 0) + 1
for speaker in (r for r in records if r["kind"] == "speaker"):
assert speaker["utterance_count"] == per_speaker.get(speaker["primary_name"], 0)
assert speaker["utterance_count_including_implicit_narrator"] >= speaker["utterance_count"]
def test_example_is_a_readable_subset_not_a_copy(built: Path) -> None:
"""The example exists to teach the shape; a second copy of the data cannot."""
example = next(p for p in (built / "examples").iterdir() if p.is_dir())
example_records = json.loads((example / "manifest.json").read_text())
assert 0 < len(example_records) < len(read_manifest(built))
lineage = json.loads((example / "lineage.json").read_text())
assert lineage["edges"], "lineage with no edges teaches nothing"
assert all(edge.get("transform") for edge in lineage["edges"])
assert all(edge.get("derivation") in {"direct", "derived", "assembled", "transcoded"}
for edge in lineage["edges"])
def test_generator_never_writes_to_the_project(tmp_path: Path) -> None:
"""The audio system keeps its data and keeps running; the build only reads."""
project = make_project(tmp_path / "TestProject")
before = {p: p.stat().st_mtime_ns for p in sorted(project.rglob("*")) if p.is_file()}
builder.build("TestProject", tmp_path / "bundle", "2026-10-14T00:00:00Z", None,
source_root=tmp_path)
after = {p: p.stat().st_mtime_ns for p in sorted(project.rglob("*")) if p.is_file()}
assert before == after
def test_generated_at_is_the_only_difference_between_two_timestamps(tmp_path: Path) -> None:
make_project(tmp_path / "TestProject")
first = tmp_path / "a"
second = tmp_path / "b"
builder.build("TestProject", first, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
builder.build("TestProject", second, "2026-10-15T00:00:00Z", None, source_root=tmp_path)
def strip(path: Path) -> list[str]:
# Everything that is a build-time stamp, not a fact about the data.
volatile = {"generated_at", "file_modified_at"}
return [
json.dumps({k: v for k, v in json.loads(line).items() if k not in volatile},
sort_keys=True)
for line in (path / "manifest.jsonl").read_text().splitlines()
]
assert strip(first) == strip(second)
def test_production_time_is_not_claimed_as_build_time(tmp_path: Path) -> None:
"""`produced_at: <build time>` would be a claim about the audio, not the export.
A render record must carry when the *file* was written, or nothing at all.
"""
make_project(tmp_path / "TestProject")
out = tmp_path / "bundle"
builder.build("TestProject", out, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
for record in read_manifest(out):
assert "produced_at" not in record, record["id"]
# And the build timestamp never leaks into a file-time field.
for record in read_manifest(out):
assert record.get("file_modified_at") != "2026-10-14T00:00:00Z", record["id"]