diff --git a/scripts/dev_test.py b/scripts/dev_test.py new file mode 100755 index 0000000..7d559e9 --- /dev/null +++ b/scripts/dev_test.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Manual two-user dev test. + +Sets up a fresh multi-user instance with dave + brut, extracts their +activities, then hands off to `bincio dev` so you can browse the site. + +Run from the project root: + + uv run python scripts/dev_test.py + +Options: + --fresh Wipe DATA_DIR before starting (default: reuse if it exists) + --no-dev Stop after extract (skip `bincio dev`) + +Credentials: dave / testpass and brut / testpass +URL: http://localhost:4321 +""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +PROJECT_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = Path("/tmp/bincio_dev_test") +DAVE_INPUT = PROJECT_DIR / "tests" / "data" / "dave" +BRUT_INPUT = PROJECT_DIR / "tests" / "data" / "brut" +PASSWORD = "testpass" + + +def section(msg: str) -> None: + print(f"\n\033[1;36m▸ {msg}\033[0m") + + +def ok(msg: str) -> None: + print(f" \033[32m✓\033[0m {msg}") + + +def warn(msg: str) -> None: + print(f" \033[33m·\033[0m {msg}") + + +# ── 1. Init instance (dave = admin) ────────────────────────────────────────── + +def init_instance() -> None: + section("Initialising instance") + from bincio.serve.db import create_user, get_user, open_db + + DATA_DIR.mkdir(parents=True, exist_ok=True) + + db = open_db(DATA_DIR) + ok("instance.db ready") + + if get_user(db, "dave"): + warn("user 'dave' already exists — skipping") + else: + create_user(db, "dave", "Dave", PASSWORD, is_admin=True) + ok("admin user 'dave' created") + + if get_user(db, "brut"): + warn("user 'brut' already exists — skipping") + else: + create_user(db, "brut", "Brut", PASSWORD, is_admin=False) + ok("user 'brut' created") + + for handle in ("dave", "brut"): + user_dir = DATA_DIR / handle + (user_dir / "activities").mkdir(parents=True, exist_ok=True) + (user_dir / "edits").mkdir(parents=True, exist_ok=True) + + import json + from datetime import datetime, timezone + root_index = DATA_DIR / "index.json" + if not root_index.exists(): + root_index.write_text(json.dumps({ + "bas_version": "1.0", + "instance": {"name": "Dev Test", "private": True}, + "generated_at": datetime.now(timezone.utc).isoformat(), + "shards": [ + {"handle": "dave", "url": "dave/index.json"}, + {"handle": "brut", "url": "brut/index.json"}, + ], + "activities": [], + }, indent=2)) + ok("root index.json written") + else: + warn("root index.json already exists — skipping") + + +# ── 2. Extract activities ───────────────────────────────────────────────────── + +def extract_user(handle: str, input_dir: Path) -> None: + section(f"Extracting activities for {handle}") + if not input_dir.exists(): + print(f" \033[31m✗\033[0m Input dir not found: {input_dir}", file=sys.stderr) + sys.exit(1) + + cfg_path = DATA_DIR / f"_cfg_{handle}.yaml" + cfg_path.write_text( + f"owner:\n handle: {handle}\n" + f"input:\n dirs:\n - {input_dir}\n" + f"output:\n dir: {DATA_DIR}\n" + ) + + from click.testing import CliRunner + from bincio.extract.cli import extract as extract_cmd + result = CliRunner().invoke(extract_cmd, ["--config", str(cfg_path)]) + + if result.exit_code != 0: + print(f" \033[31m✗\033[0m Extract failed:\n{result.output}", file=sys.stderr) + if result.exception: + import traceback + traceback.print_exception(type(result.exception), result.exception, + result.exception.__traceback__, file=sys.stderr) + sys.exit(1) + + acts = list((DATA_DIR / handle / "activities").glob("*.json")) + ok(f"{len(acts)} activities extracted → {DATA_DIR / handle / 'activities'}") + + +# ── 3. Merge + manifest ─────────────────────────────────────────────────────── + +def prepare_serve() -> None: + section("Merging sidecars + writing root manifest") + from bincio.render.merge import merge_all + from bincio.render.cli import _write_root_manifest + import bincio.render.cli as render_cli + from rich.console import Console + render_cli.console = Console() # normal output + + for handle in ("dave", "brut"): + n = merge_all(DATA_DIR / handle) + ok(f"{handle}: {n} sidecar(s) merged") + + _write_root_manifest(DATA_DIR) + ok("root manifest updated") + + +# ── 4. Hand off to bincio dev ───────────────────────────────────────────────── + +def start_dev() -> None: + section("Starting bincio dev") + print() + print(" \033[1mCredentials\033[0m") + print(f" dave / {PASSWORD} (admin)") + print(f" brut / {PASSWORD}") + print() + print(" \033[1mURL\033[0m http://localhost:4321") + print() + print(" Press Ctrl+C to stop.\n") + + try: + subprocess.run( + ["uv", "run", "bincio", "dev", "--data-dir", str(DATA_DIR)], + cwd=PROJECT_DIR, + ) + except KeyboardInterrupt: + pass + + +# ── main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--fresh", action="store_true", help="Wipe DATA_DIR before starting") + parser.add_argument("--no-dev", action="store_true", help="Stop after extract, skip bincio dev") + args = parser.parse_args() + + print(f"\033[1mbincio dev test\033[0m → {DATA_DIR}") + + if args.fresh and DATA_DIR.exists(): + section("Wiping existing data dir") + shutil.rmtree(DATA_DIR) + ok(f"{DATA_DIR} removed") + + init_instance() + extract_user("dave", DAVE_INPUT) + extract_user("brut", BRUT_INPUT) + prepare_serve() + + if not args.no_dev: + start_dev() + else: + print(f"\n\033[32mDone.\033[0m Data ready at {DATA_DIR}") + print(f"Run: uv run bincio dev --data-dir {DATA_DIR}\n") + + +if __name__ == "__main__": + main() diff --git a/tests/data/brut/13957.activity.03f37124-fb63-43e0-9a0e-2747db463a5b.fit b/tests/data/brut/13957.activity.03f37124-fb63-43e0-9a0e-2747db463a5b.fit new file mode 100644 index 0000000..16b7c64 Binary files /dev/null and b/tests/data/brut/13957.activity.03f37124-fb63-43e0-9a0e-2747db463a5b.fit differ diff --git a/tests/data/brut/13957.activity.04af8185-ae4c-45de-b857-544deb330a30.fit b/tests/data/brut/13957.activity.04af8185-ae4c-45de-b857-544deb330a30.fit new file mode 100644 index 0000000..83824e2 Binary files /dev/null and b/tests/data/brut/13957.activity.04af8185-ae4c-45de-b857-544deb330a30.fit differ diff --git a/tests/data/brut/13957.activity.051e81ca-1a01-449f-a2e9-acaccd1867ef.fit b/tests/data/brut/13957.activity.051e81ca-1a01-449f-a2e9-acaccd1867ef.fit new file mode 100644 index 0000000..fcd16e3 Binary files /dev/null and b/tests/data/brut/13957.activity.051e81ca-1a01-449f-a2e9-acaccd1867ef.fit differ diff --git a/tests/data/brut/13957.activity.06660919-36d8-4836-aebb-1e03cbdd5105.fit b/tests/data/brut/13957.activity.06660919-36d8-4836-aebb-1e03cbdd5105.fit new file mode 100644 index 0000000..ec671a9 Binary files /dev/null and b/tests/data/brut/13957.activity.06660919-36d8-4836-aebb-1e03cbdd5105.fit differ diff --git a/tests/data/brut/13957.activity.07a05802-7db6-4188-aa2e-794f8bef5a6e.fit b/tests/data/brut/13957.activity.07a05802-7db6-4188-aa2e-794f8bef5a6e.fit new file mode 100644 index 0000000..4d60d5b Binary files /dev/null and b/tests/data/brut/13957.activity.07a05802-7db6-4188-aa2e-794f8bef5a6e.fit differ diff --git a/tests/data/brut/13957.activity.090cbe04-f840-48b8-959a-3155f2db2edf.fit b/tests/data/brut/13957.activity.090cbe04-f840-48b8-959a-3155f2db2edf.fit new file mode 100644 index 0000000..2220a69 Binary files /dev/null and b/tests/data/brut/13957.activity.090cbe04-f840-48b8-959a-3155f2db2edf.fit differ diff --git a/tests/data/brut/13957.activity.092a3dc2-1598-47a6-9964-25a81fe6366e.fit b/tests/data/brut/13957.activity.092a3dc2-1598-47a6-9964-25a81fe6366e.fit new file mode 100644 index 0000000..d8598af Binary files /dev/null and b/tests/data/brut/13957.activity.092a3dc2-1598-47a6-9964-25a81fe6366e.fit differ diff --git a/tests/data/brut/13957.activity.09345566-ec3d-4e42-9617-ff5172f9d3e0.fit b/tests/data/brut/13957.activity.09345566-ec3d-4e42-9617-ff5172f9d3e0.fit new file mode 100644 index 0000000..9b099f9 Binary files /dev/null and b/tests/data/brut/13957.activity.09345566-ec3d-4e42-9617-ff5172f9d3e0.fit differ diff --git a/tests/data/brut/13957.activity.09734476-7715-4c15-b8cb-499b771a27bf.fit b/tests/data/brut/13957.activity.09734476-7715-4c15-b8cb-499b771a27bf.fit new file mode 100644 index 0000000..9e842b2 Binary files /dev/null and b/tests/data/brut/13957.activity.09734476-7715-4c15-b8cb-499b771a27bf.fit differ diff --git a/tests/data/brut/13957.activity.09edcd49-9bd6-47c0-a92f-9241b16102d0.fit b/tests/data/brut/13957.activity.09edcd49-9bd6-47c0-a92f-9241b16102d0.fit new file mode 100644 index 0000000..0ee22ec Binary files /dev/null and b/tests/data/brut/13957.activity.09edcd49-9bd6-47c0-a92f-9241b16102d0.fit differ diff --git a/tests/data/dave/13957.activity.00bd8c41-6be7-4478-8454-073e3e915772.fit b/tests/data/dave/13957.activity.00bd8c41-6be7-4478-8454-073e3e915772.fit new file mode 100644 index 0000000..ec27cec Binary files /dev/null and b/tests/data/dave/13957.activity.00bd8c41-6be7-4478-8454-073e3e915772.fit differ diff --git a/tests/data/dave/13957.activity.00c29355-fcf9-4b54-b799-cdf3794e0da6.fit b/tests/data/dave/13957.activity.00c29355-fcf9-4b54-b799-cdf3794e0da6.fit new file mode 100644 index 0000000..f21a6aa Binary files /dev/null and b/tests/data/dave/13957.activity.00c29355-fcf9-4b54-b799-cdf3794e0da6.fit differ diff --git a/tests/data/dave/13957.activity.0157fcc4-b39f-43d6-a5a8-817ad7ad9da9.fit b/tests/data/dave/13957.activity.0157fcc4-b39f-43d6-a5a8-817ad7ad9da9.fit new file mode 100644 index 0000000..8c6b036 Binary files /dev/null and b/tests/data/dave/13957.activity.0157fcc4-b39f-43d6-a5a8-817ad7ad9da9.fit differ diff --git a/tests/data/dave/13957.activity.01bbbb1d-a251-4455-8645-3835fff1c4ed.fit b/tests/data/dave/13957.activity.01bbbb1d-a251-4455-8645-3835fff1c4ed.fit new file mode 100644 index 0000000..39173ac Binary files /dev/null and b/tests/data/dave/13957.activity.01bbbb1d-a251-4455-8645-3835fff1c4ed.fit differ diff --git a/tests/data/dave/13957.activity.01c87641-931d-4017-993d-1a38776a0ddf.fit b/tests/data/dave/13957.activity.01c87641-931d-4017-993d-1a38776a0ddf.fit new file mode 100644 index 0000000..07f42ac Binary files /dev/null and b/tests/data/dave/13957.activity.01c87641-931d-4017-993d-1a38776a0ddf.fit differ diff --git a/tests/data/dave/13957.activity.02062165-a71f-4f6c-b741-d2dd629ad623.fit b/tests/data/dave/13957.activity.02062165-a71f-4f6c-b741-d2dd629ad623.fit new file mode 100644 index 0000000..6d0728d Binary files /dev/null and b/tests/data/dave/13957.activity.02062165-a71f-4f6c-b741-d2dd629ad623.fit differ diff --git a/tests/data/dave/13957.activity.0222c10c-ccb8-48d7-aeb8-f36b558af86d.fit b/tests/data/dave/13957.activity.0222c10c-ccb8-48d7-aeb8-f36b558af86d.fit new file mode 100644 index 0000000..ae59cee Binary files /dev/null and b/tests/data/dave/13957.activity.0222c10c-ccb8-48d7-aeb8-f36b558af86d.fit differ diff --git a/tests/data/dave/13957.activity.02766418-8d71-4fb3-9958-1aebc2988c07.fit b/tests/data/dave/13957.activity.02766418-8d71-4fb3-9958-1aebc2988c07.fit new file mode 100644 index 0000000..5db049d Binary files /dev/null and b/tests/data/dave/13957.activity.02766418-8d71-4fb3-9958-1aebc2988c07.fit differ diff --git a/tests/data/dave/13957.activity.02a66be7-70c7-4c12-8230-c4bb12ea3800.fit b/tests/data/dave/13957.activity.02a66be7-70c7-4c12-8230-c4bb12ea3800.fit new file mode 100644 index 0000000..9787685 Binary files /dev/null and b/tests/data/dave/13957.activity.02a66be7-70c7-4c12-8230-c4bb12ea3800.fit differ diff --git a/tests/data/dave/13957.activity.03780ba3-5314-426b-8fe2-d43a1a291694.fit b/tests/data/dave/13957.activity.03780ba3-5314-426b-8fe2-d43a1a291694.fit new file mode 100644 index 0000000..9c63ec3 Binary files /dev/null and b/tests/data/dave/13957.activity.03780ba3-5314-426b-8fe2-d43a1a291694.fit differ diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..3e0e87d --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,123 @@ +"""End-to-end pipeline test: extract → merge → root manifest for two users. + +Uses the 20 real FIT files checked into tests/data/dave/ and tests/data/brut/. +Run with: + + uv run pytest tests/test_pipeline.py -v + +Skip during normal CI runs: + + uv run pytest -m "not integration" +""" + +import json +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from bincio.extract.cli import extract as extract_cmd + +TESTS_DIR = Path(__file__).parent +DATA_DIR = TESTS_DIR / "data" +DAVE_INPUT = DATA_DIR / "dave" +BRUT_INPUT = DATA_DIR / "brut" + + +@pytest.fixture(scope="module") +def data_root(): + """Run extract for dave and brut into a shared temp dir, yield the data root.""" + with tempfile.TemporaryDirectory(prefix="bincio_test_") as tmp: + root = Path(tmp) + runner = CliRunner() + + for handle, input_dir in [("dave", DAVE_INPUT), ("brut", BRUT_INPUT)]: + cfg_path = root / f"cfg_{handle}.yaml" + cfg_path.write_text( + f"owner:\n handle: {handle}\n" + f"input:\n dirs:\n - {input_dir}\n" + f"output:\n dir: {root}\n" + ) + result = runner.invoke(extract_cmd, ["--config", str(cfg_path)]) + assert result.exit_code == 0, ( + f"bincio extract failed for {handle}:\n{result.output}" + ) + + yield root + + +@pytest.mark.integration +@pytest.mark.slow +class TestPipeline: + def test_activities_extracted_dave(self, data_root): + acts = list((data_root / "dave" / "activities").glob("*.json")) + assert len(acts) >= 8, f"Expected ≥8 activities for dave, got {len(acts)}" + + def test_activities_extracted_brut(self, data_root): + acts = list((data_root / "brut" / "activities").glob("*.json")) + assert len(acts) >= 8, f"Expected ≥8 activities for brut, got {len(acts)}" + + def test_index_json_dave(self, data_root): + index = json.loads((data_root / "dave" / "index.json").read_text()) + assert len(index["activities"]) >= 8 + assert index["owner"]["handle"] == "dave" + + def test_index_json_brut(self, data_root): + index = json.loads((data_root / "brut" / "index.json").read_text()) + assert len(index["activities"]) >= 8 + assert index["owner"]["handle"] == "brut" + + def test_merge_produces_merged_dir(self, data_root): + from bincio.render.merge import merge_all + merge_all(data_root / "dave") + merge_all(data_root / "brut") + + assert (data_root / "dave" / "_merged" / "index.json").exists() + assert (data_root / "brut" / "_merged" / "index.json").exists() + + def test_merged_index_has_activities(self, data_root): + # Ensure merge ran (idempotent if already done by earlier test in class) + from bincio.render.merge import merge_all + merge_all(data_root / "dave") + merge_all(data_root / "brut") + + for handle in ("dave", "brut"): + merged = json.loads((data_root / handle / "_merged" / "index.json").read_text()) + assert len(merged["activities"]) >= 8, ( + f"Expected ≥8 merged activities for {handle}" + ) + + def test_root_manifest(self, data_root): + from bincio.render.cli import _user_dirs, _write_root_manifest + from rich.console import Console + + # _write_root_manifest uses the module-level console; patch it to suppress output + import bincio.render.cli as render_cli + render_cli.console = Console(quiet=True) + + _write_root_manifest(data_root) + + manifest = json.loads((data_root / "index.json").read_text()) + handles = {s["handle"] for s in manifest["shards"]} + assert "dave" in handles + assert "brut" in handles + assert manifest["bas_version"] == "1.0" + # Single-user path: no instance.db → private must be False + assert manifest["instance"].get("private") is False + + def test_activity_json_structure(self, data_root): + """Spot-check that extracted JSON has the required BAS fields.""" + acts = sorted((data_root / "dave" / "activities").glob("*.json")) + detail = json.loads(acts[0].read_text()) + for field in ("id", "title", "sport", "started_at", "duration_s"): + assert field in detail, f"Missing field '{field}' in activity JSON" + + def test_geojson_exists_for_gps_activities(self, data_root): + """Each activity with GPS data should have a companion .geojson file.""" + acts_dir = data_root / "dave" / "activities" + json_ids = {p.stem for p in acts_dir.glob("*.json")} + geojson_ids = {p.stem for p in acts_dir.glob("*.geojson")} + # At least some activities should have tracks (Karoo FIT files always have GPS) + assert len(geojson_ids) >= 5, "Expected ≥5 GeoJSON track files for dave" + assert geojson_ids.issubset(json_ids), "GeoJSON without matching detail JSON"