Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Deterministic builder for `tests/fixtures/omnivoice_data/` — the frozen | |
| regression fixture loaded by PR smoke tests (GATE-01). | |
| Usage (from repo root): | |
| uv run python scripts/seed-test-fixture.py | |
| What it produces (≤ 200 KB total): | |
| tests/fixtures/omnivoice_data/ | |
| ├── omnivoice.db # all 8 tables, 1 voice_profiles row, 0 history rows | |
| ├── voices/test-voice/ | |
| │ ├── profile.json # mirrors the voice_profiles row | |
| │ └── sample.wav # 1-sec 24 kHz mono silence (~48 KB) | |
| └── README.md # explains the fixture + rebuild path | |
| The script is idempotent: an existing fixture directory is wiped and rebuilt. | |
| Inputs are entirely deterministic (fixed timestamp, all-zero PCM samples) so | |
| re-running this script produces a byte-identical fixture for git diffs. | |
| Why this exists: every PR runs `tests/smoke/test_boot_smoke.py` against this | |
| fixture on macOS / Windows / Linux. If the fixture drifts uncontrollably, | |
| the smoke matrix loses its meaning. Keeping it tiny + reproducible is the | |
| whole point of GATE-01. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import shutil | |
| import struct | |
| import sys | |
| import wave | |
| from pathlib import Path | |
| REPO_ROOT = Path(__file__).resolve().parent.parent | |
| FIX = REPO_ROOT / "tests" / "fixtures" / "omnivoice_data" | |
| VOICE_DIR = FIX / "voices" / "test-voice" | |
| # Fixed timestamp so the fixture is byte-deterministic for git diffs. | |
| # Value chosen arbitrarily; any constant works. | |
| FIXED_CREATED_AT = 1700000000.0 | |
| SIZE_BUDGET_BYTES = 200 * 1024 | |
| def write_silence_wav(path: Path, duration_s: float = 1.0, | |
| sample_rate: int = 24000, channels: int = 1) -> None: | |
| """Write a 16-bit PCM silent WAV. Mirrors `tests/test_api.py::make_wav_bytes`.""" | |
| n_samples = int(duration_s * sample_rate) | |
| with wave.open(str(path), "wb") as wf: | |
| wf.setnchannels(channels) | |
| wf.setsampwidth(2) | |
| wf.setframerate(sample_rate) | |
| wf.writeframes(struct.pack(f"<{n_samples}h", *([0] * n_samples))) | |
| def write_profile_json(path: Path) -> dict: | |
| profile = { | |
| "id": "test-voice", | |
| "name": "Test Voice (silence)", | |
| "ref_audio_path": "voices/test-voice/sample.wav", | |
| "ref_text": "silence", | |
| "instruct": "", | |
| "language": "Auto", | |
| "created_at": FIXED_CREATED_AT, | |
| "locked_audio_path": "", | |
| "seed": None, | |
| "is_locked": 0, | |
| } | |
| path.write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n", | |
| encoding="utf-8") | |
| return profile | |
| def build_database(db_path: Path, profile: dict) -> None: | |
| """Build the SQLite DB by importing backend.core.db and calling init_db(). | |
| `init_db()` reads `core.config.DB_PATH` at call time, so we monkey-patch | |
| that module-level constant after import. `OMNIVOICE_DISABLE_FILE_LOG=1` | |
| is set before any backend import so we don't write log files to | |
| nonexistent paths. | |
| """ | |
| os.environ["OMNIVOICE_DISABLE_FILE_LOG"] = "1" | |
| # OMNIVOICE_DATA_DIR is read by backend.core.config.get_app_data_dir() | |
| # but DB_PATH is set at module import time. We override DB_PATH directly | |
| # below for full control. | |
| backend_dir = str(REPO_ROOT / "backend") | |
| if backend_dir not in sys.path: | |
| sys.path.insert(0, backend_dir) | |
| import core.config as cfg | |
| cfg.DB_PATH = str(db_path) | |
| from core.db import init_db, get_db # noqa: E402 | |
| init_db() | |
| # Insert exactly one voice_profiles row (deterministic content). | |
| conn = get_db() | |
| try: | |
| conn.execute( | |
| """ | |
| INSERT INTO voice_profiles ( | |
| id, name, ref_audio_path, ref_text, instruct, language, | |
| locked_audio_path, seed, is_locked, created_at | |
| ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, | |
| ( | |
| profile["id"], | |
| profile["name"], | |
| profile["ref_audio_path"], | |
| profile["ref_text"], | |
| profile["instruct"], | |
| profile["language"], | |
| profile["locked_audio_path"], | |
| profile["seed"], | |
| profile["is_locked"], | |
| profile["created_at"], | |
| ), | |
| ) | |
| conn.commit() | |
| # Checkpoint the WAL into the main DB file, then switch journal mode | |
| # back to DELETE so the fixture is a single-file artifact with no | |
| # `-wal` / `-shm` siblings hanging around to confuse `git status`. | |
| conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") | |
| conn.execute("PRAGMA journal_mode=DELETE") | |
| conn.commit() | |
| finally: | |
| conn.close() | |
| # Defensive cleanup — if any sidecar files survived, remove them. | |
| for sidecar in (db_path.with_name(db_path.name + "-wal"), | |
| db_path.with_name(db_path.name + "-shm"), | |
| db_path.with_name(db_path.name + "-journal")): | |
| if sidecar.exists(): | |
| sidecar.unlink() | |
| def write_readme(path: Path) -> None: | |
| body = ( | |
| "# tests/fixtures/omnivoice_data/\n" | |
| "\n" | |
| "**Frozen regression fixture** loaded by `tests/smoke/test_boot_smoke.py`\n" | |
| "on every PR (macOS / Windows / Linux). See GATE-01 in\n" | |
| "`.planning/REQUIREMENTS.md`.\n" | |
| "\n" | |
| "## Do not edit by hand\n" | |
| "\n" | |
| "Regenerate via:\n" | |
| "\n" | |
| " uv run python scripts/seed-test-fixture.py\n" | |
| "\n" | |
| "The script is deterministic (fixed `created_at` + all-zero PCM)\n" | |
| "so re-running produces a byte-identical fixture for clean git diffs.\n" | |
| "\n" | |
| "## Layout\n" | |
| "\n" | |
| "- `omnivoice.db` — all 8 tables created by `backend/core/db.py::init_db()`,\n" | |
| " with exactly one row in `voice_profiles` and zero rows in history tables.\n" | |
| "- `voices/test-voice/profile.json` — JSON mirror of the voice_profiles row.\n" | |
| "- `voices/test-voice/sample.wav` — 1-sec, 24 kHz, mono, 16-bit PCM silence.\n" | |
| "\n" | |
| "## Why not alembic?\n" | |
| "\n" | |
| "`backend/migrations/versions/` is empty (`.gitkeep` only); the project\n" | |
| "uses `backend.core.db.init_db()` directly. This builder follows the same\n" | |
| "path for parity with production.\n" | |
| "\n" | |
| "## Size budget\n" | |
| "\n" | |
| "Total directory size MUST stay ≤ 200 KB so every contributor's\n" | |
| "`git clone` stays cheap. The seed script exits non-zero if it\n" | |
| "exceeds the budget.\n" | |
| ) | |
| path.write_text(body, encoding="utf-8") | |
| def directory_size_bytes(root: Path) -> int: | |
| total = 0 | |
| for dirpath, _dirnames, filenames in os.walk(root): | |
| for name in filenames: | |
| total += os.path.getsize(os.path.join(dirpath, name)) | |
| return total | |
| def main() -> int: | |
| if FIX.exists(): | |
| shutil.rmtree(FIX) | |
| VOICE_DIR.mkdir(parents=True, exist_ok=True) | |
| write_silence_wav(VOICE_DIR / "sample.wav") | |
| profile = write_profile_json(VOICE_DIR / "profile.json") | |
| build_database(FIX / "omnivoice.db", profile) | |
| write_readme(FIX / "README.md") | |
| total = directory_size_bytes(FIX) | |
| print(f"Fixture written to: {FIX}") | |
| print(f"Total size: {total} bytes ({total / 1024:.1f} KB)") | |
| if total > SIZE_BUDGET_BYTES: | |
| print( | |
| f"ERROR: fixture size {total} bytes exceeds " | |
| f"budget {SIZE_BUDGET_BYTES} bytes (200 KB)", | |
| file=sys.stderr, | |
| ) | |
| return 1 | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |