Closes the compatibility gap documented in docs/architecture/project-identity-canonicalization.md. Beforefb6298a, writes to project_state, memories, and interactions stored the raw project name. Afterfb6298aevery service-layer entry point canonicalizes through the registry, which silently made pre-fix alias-keyed rows unreachable from the new read path. Now there's a migration tool to find and fix them. This commit is the tool and its tests. The tool is NOT run against the live Dalidou DB in this commit — that's a separate supervised manual step after reviewing the dry-run output. scripts/migrate_legacy_aliases.py --------------------------------- Standalone offline migration tool. Dry-run default, --apply explicit. What it inspects: - projects: rows whose name is a registered alias and differs from the canonical project_id (shadow rows) - project_state: rows whose project_id points at a shadow; plan rekeys them to the canonical row's id. (category, key) collisions against the canonical block the apply step until a human resolves - memories: rows whose project column is a registered alias. Plain string rekey. Dedup collisions (after rekey, same (memory_type, content, project, status)) are handled by the existing memory supersession model: newer row stays active, older becomes superseded with updated_at as tiebreaker - interactions: rows whose project column is a registered alias. Plain string rekey, no collision handling What it does NOT do: - Never touches rows that are already canonical - Never auto-resolves project_state collisions (refuses until the human picks a winner via POST /project/state) - Never creates data; only rekeys or supersedes - Never runs outside a single SQLite transaction; any failure rolls back the entire migration Safety rails: - Dry-run is default. --apply is explicit. - Apply on empty plan refuses unless --allow-empty (prevents accidental runs that look meaningful but did nothing) - Apply refuses on any project_state collision - Apply refuses on integrity errors (e.g. two case-variant rows both matching the canonical lookup) - Writes a JSON report to data/migrations/ on every run (dry-run and apply alike) for audit - Idempotent: running twice produces the same final state as running once. The second run finds zero shadow rows and exits clean. CLI flags: --registry PATH override ATOCORE_PROJECT_REGISTRY_PATH --db PATH override the AtoCore SQLite DB path --apply actually mutate (default is dry-run) --allow-empty permit --apply on an empty plan --report-dir PATH where to write the JSON report --json emit the plan as JSON instead of human prose Smoke test against the Phase 9 validation DB produces the expected "Nothing to migrate. The database is clean." output with 4 known canonical projects and 0 shadows. tests/test_migrate_legacy_aliases.py ------------------------------------ 19 new tests, all green: Plan-building: - test_dry_run_on_empty_registry_reports_empty_plan - test_dry_run_on_clean_registered_db_reports_empty_plan - test_dry_run_finds_shadow_project - test_dry_run_plans_state_rekey_without_collisions - test_dry_run_detects_state_collision - test_dry_run_plans_memory_rekey_and_supersession - test_dry_run_plans_interaction_rekey Apply: - test_apply_refuses_on_state_collision - test_apply_migrates_clean_shadow_end_to_end (verifies get_state can see the state via BOTH the alias AND the canonical after migration) - test_apply_drops_shadow_state_duplicate_without_collision (same (category, key, value) on both sides - mark shadow superseded, don't hit the UNIQUE constraint) - test_apply_migrates_memories - test_apply_migrates_interactions - test_apply_is_idempotent - test_apply_refuses_with_integrity_errors (uses case-variant canonical rows to work around projects.name UNIQUE constraint; verifies the case-insensitive duplicate detection works) Reporting: - test_plan_to_json_dict_is_serializable - test_write_report_creates_file - test_render_plan_text_on_empty_plan - test_render_plan_text_on_collision End-to-end gap closure (the most important test): - test_legacy_alias_gap_is_closed_after_migration - Seeds the exact same scenario as test_legacy_alias_keyed_state_is_invisible_until_migrated in test_project_state.py (which documents the pre-migration gap) - Confirms the row is invisible before migration - Runs the migration - Verifies the row is reachable via BOTH the canonical id AND the alias afterward - This test and the pre-migration gap test together lock in "before migration: invisible, after migration: reachable" as the documented invariant Full suite: 194 passing (was 175), 1 warning. The +19 is the new migration test file. Next concrete step after this commit ------------------------------------ - Run the dry-run against the live Dalidou DB to find out the actual blast radius. The script is the inspection SQL, codified. - Review the dry-run output together - If clean (zero shadows), no apply needed; close the doc gap as "verified nothing to migrate on this deployment" - If there are shadows, resolve any collisions via POST /project/state, then run --apply under supervision - After apply, the test_legacy_alias_keyed_state_is_invisible_until_migrated test still passes (it simulates the gap directly, so it's independent of the live DB state) and the gap-closed companion test continues to guard forward
616 lines
20 KiB
Python
616 lines
20 KiB
Python
"""Tests for scripts/migrate_legacy_aliases.py.
|
|
|
|
The migration script closes the compatibility gap documented in
|
|
docs/architecture/project-identity-canonicalization.md. These tests
|
|
cover:
|
|
|
|
- empty/clean database behavior
|
|
- shadow projects detection
|
|
- state rekey without collisions
|
|
- state collision detection + apply refusal
|
|
- memory rekey + supersession of duplicates
|
|
- interaction rekey
|
|
- end-to-end apply on a realistic shadow
|
|
- idempotency (running twice produces the same final state)
|
|
- report artifact is written
|
|
- the pre-fix regression gap is actually closed after migration
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from atocore.context.project_state import (
|
|
get_state,
|
|
init_project_state_schema,
|
|
)
|
|
from atocore.models.database import init_db
|
|
|
|
# Make scripts/ importable
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(_REPO_ROOT / "scripts"))
|
|
|
|
import migrate_legacy_aliases as mig # noqa: E402
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers that seed "legacy" rows the way they would have looked before fb6298a
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _open_db_connection():
|
|
"""Open a direct SQLite connection to the test data dir's DB."""
|
|
import atocore.config as config
|
|
|
|
conn = sqlite3.connect(str(config.settings.db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
return conn
|
|
|
|
|
|
def _seed_shadow_project(
|
|
conn: sqlite3.Connection, shadow_name: str
|
|
) -> str:
|
|
"""Insert a projects row keyed under an alias, like the old set_state would have."""
|
|
project_id = str(uuid.uuid4())
|
|
conn.execute(
|
|
"INSERT INTO projects (id, name, description) VALUES (?, ?, ?)",
|
|
(project_id, shadow_name, f"shadow row for {shadow_name}"),
|
|
)
|
|
conn.commit()
|
|
return project_id
|
|
|
|
|
|
def _seed_state_row(
|
|
conn: sqlite3.Connection,
|
|
project_id: str,
|
|
category: str,
|
|
key: str,
|
|
value: str,
|
|
) -> str:
|
|
row_id = str(uuid.uuid4())
|
|
conn.execute(
|
|
"INSERT INTO project_state "
|
|
"(id, project_id, category, key, value, source, confidence) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(row_id, project_id, category, key, value, "legacy-test", 1.0),
|
|
)
|
|
conn.commit()
|
|
return row_id
|
|
|
|
|
|
def _seed_memory_row(
|
|
conn: sqlite3.Connection,
|
|
memory_type: str,
|
|
content: str,
|
|
project: str,
|
|
status: str = "active",
|
|
) -> str:
|
|
row_id = str(uuid.uuid4())
|
|
conn.execute(
|
|
"INSERT INTO memories "
|
|
"(id, memory_type, content, project, source_chunk_id, confidence, status) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(row_id, memory_type, content, project, None, 1.0, status),
|
|
)
|
|
conn.commit()
|
|
return row_id
|
|
|
|
|
|
def _seed_interaction_row(
|
|
conn: sqlite3.Connection, prompt: str, project: str
|
|
) -> str:
|
|
row_id = str(uuid.uuid4())
|
|
conn.execute(
|
|
"INSERT INTO interactions "
|
|
"(id, prompt, context_pack, response_summary, response, "
|
|
" memories_used, chunks_used, client, session_id, project, created_at) "
|
|
"VALUES (?, ?, '{}', '', '', '[]', '[]', 'legacy-test', '', ?, '2026-04-01 12:00:00')",
|
|
(row_id, prompt, project),
|
|
)
|
|
conn.commit()
|
|
return row_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# plan-building tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(tmp_data_dir):
|
|
init_db()
|
|
init_project_state_schema()
|
|
|
|
|
|
def test_dry_run_on_empty_registry_reports_empty_plan(tmp_data_dir):
|
|
"""Empty registry -> empty alias map -> empty plan."""
|
|
registry_path = tmp_data_dir / "empty-registry.json"
|
|
registry_path.write_text('{"projects": []}', encoding="utf-8")
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert plan.alias_map == {}
|
|
assert plan.is_empty
|
|
assert not plan.has_collisions
|
|
assert plan.counts() == {
|
|
"shadow_projects": 0,
|
|
"state_rekey_rows": 0,
|
|
"state_collisions": 0,
|
|
"memory_rekey_rows": 0,
|
|
"memory_supersede_rows": 0,
|
|
"interaction_rekey_rows": 0,
|
|
}
|
|
|
|
|
|
def test_dry_run_on_clean_registered_db_reports_empty_plan(project_registry):
|
|
"""A registry with projects but no legacy rows -> empty plan."""
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert plan.alias_map != {}
|
|
assert plan.is_empty
|
|
|
|
|
|
def test_dry_run_finds_shadow_project(project_registry):
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
_seed_shadow_project(conn, "p05")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert len(plan.shadow_projects) == 1
|
|
assert plan.shadow_projects[0].shadow_name == "p05"
|
|
assert plan.shadow_projects[0].canonical_project_id == "p05-interferometer"
|
|
|
|
|
|
def test_dry_run_plans_state_rekey_without_collisions(project_registry):
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
_seed_state_row(conn, shadow_id, "status", "next_focus", "Wave 1 ingestion")
|
|
_seed_state_row(conn, shadow_id, "decision", "lateral_support", "GF-PTFE")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert len(plan.state_plans) == 1
|
|
sp = plan.state_plans[0]
|
|
assert len(sp.rows_to_rekey) == 2
|
|
assert sp.collisions == []
|
|
assert not plan.has_collisions
|
|
|
|
|
|
def test_dry_run_detects_state_collision(project_registry):
|
|
"""Shadow and canonical both have state under the same (category, key) with different values."""
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
canonical_id = _seed_shadow_project(conn, "p05-interferometer")
|
|
_seed_state_row(conn, shadow_id, "status", "next_focus", "Wave 1")
|
|
_seed_state_row(
|
|
conn, canonical_id, "status", "next_focus", "Wave 2"
|
|
)
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert plan.has_collisions
|
|
collision = plan.state_plans[0].collisions[0]
|
|
assert collision["shadow"]["value"] == "Wave 1"
|
|
assert collision["canonical"]["value"] == "Wave 2"
|
|
|
|
|
|
def test_dry_run_plans_memory_rekey_and_supersession(project_registry):
|
|
registry_path = project_registry(
|
|
("p04-gigabit", ["p04", "gigabit"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
# A clean memory under the alias that will just be rekeyed
|
|
_seed_memory_row(conn, "project", "clean rekey memory", "p04")
|
|
# A memory that collides with an existing canonical memory
|
|
_seed_memory_row(conn, "project", "duplicate content", "p04")
|
|
_seed_memory_row(conn, "project", "duplicate content", "p04-gigabit")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
# There's exactly one memory plan (one alias matched)
|
|
assert len(plan.memory_plans) == 1
|
|
mp = plan.memory_plans[0]
|
|
# Two rows are candidates for rekey or supersession — one clean,
|
|
# one duplicate. The duplicate is handled via to_supersede; the
|
|
# other via rows_to_rekey.
|
|
total_affected = len(mp.rows_to_rekey) + len(mp.to_supersede)
|
|
assert total_affected == 2
|
|
|
|
|
|
def test_dry_run_plans_interaction_rekey(project_registry):
|
|
registry_path = project_registry(
|
|
("p06-polisher", ["p06", "polisher"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
_seed_interaction_row(conn, "quick capture under alias", "polisher")
|
|
_seed_interaction_row(conn, "another alias-keyed row", "p06")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
total = sum(len(p.rows_to_rekey) for p in plan.interaction_plans)
|
|
assert total == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# apply tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_apply_refuses_on_state_collision(project_registry):
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
canonical_id = _seed_shadow_project(conn, "p05-interferometer")
|
|
_seed_state_row(conn, shadow_id, "status", "next_focus", "Wave 1")
|
|
_seed_state_row(conn, canonical_id, "status", "next_focus", "Wave 2")
|
|
|
|
plan = mig.build_plan(conn, registry_path)
|
|
assert plan.has_collisions
|
|
|
|
with pytest.raises(mig.MigrationRefused):
|
|
mig.apply_plan(conn, plan)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_apply_migrates_clean_shadow_end_to_end(project_registry):
|
|
"""The happy path: one shadow project with clean state rows, rekey into a freshly-created canonical row, verify reachability via get_state."""
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
_seed_state_row(
|
|
conn, shadow_id, "status", "next_focus", "Wave 1 ingestion"
|
|
)
|
|
_seed_state_row(
|
|
conn, shadow_id, "decision", "lateral_support", "GF-PTFE"
|
|
)
|
|
|
|
plan = mig.build_plan(conn, registry_path)
|
|
assert not plan.has_collisions
|
|
summary = mig.apply_plan(conn, plan)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert summary["state_rows_rekeyed"] == 2
|
|
assert summary["shadow_projects_deleted"] == 1
|
|
assert summary["canonical_rows_created"] == 1
|
|
|
|
# The regression gap is now closed: the service layer can see
|
|
# the state under the canonical id via either the alias OR the
|
|
# canonical.
|
|
via_alias = get_state("p05")
|
|
via_canonical = get_state("p05-interferometer")
|
|
assert len(via_alias) == 2
|
|
assert len(via_canonical) == 2
|
|
values = {entry.value for entry in via_canonical}
|
|
assert values == {"Wave 1 ingestion", "GF-PTFE"}
|
|
|
|
|
|
def test_apply_drops_shadow_state_duplicate_without_collision(project_registry):
|
|
"""Shadow and canonical both have the same (category, key, value) — shadow gets marked superseded rather than hitting the UNIQUE constraint."""
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
canonical_id = _seed_shadow_project(conn, "p05-interferometer")
|
|
_seed_state_row(
|
|
conn, shadow_id, "status", "next_focus", "Wave 1 ingestion"
|
|
)
|
|
_seed_state_row(
|
|
conn, canonical_id, "status", "next_focus", "Wave 1 ingestion"
|
|
)
|
|
|
|
plan = mig.build_plan(conn, registry_path)
|
|
assert not plan.has_collisions
|
|
summary = mig.apply_plan(conn, plan)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert summary["state_rows_merged_as_duplicate"] == 1
|
|
|
|
via_canonical = get_state("p05-interferometer")
|
|
# Exactly one active row survives
|
|
assert len(via_canonical) == 1
|
|
assert via_canonical[0].value == "Wave 1 ingestion"
|
|
|
|
|
|
def test_apply_migrates_memories(project_registry):
|
|
registry_path = project_registry(
|
|
("p04-gigabit", ["p04", "gigabit"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
_seed_memory_row(conn, "project", "lateral support uses GF-PTFE", "p04")
|
|
_seed_memory_row(conn, "preference", "I prefer descriptive commits", "gigabit")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
summary = mig.apply_plan(conn, plan)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert summary["memory_rows_rekeyed"] == 2
|
|
|
|
# Both memories should now read as living under the canonical id
|
|
from atocore.memory.service import get_memories
|
|
|
|
rows = get_memories(project="p04-gigabit", limit=50)
|
|
contents = {m.content for m in rows}
|
|
assert "lateral support uses GF-PTFE" in contents
|
|
assert "I prefer descriptive commits" in contents
|
|
|
|
|
|
def test_apply_migrates_interactions(project_registry):
|
|
registry_path = project_registry(
|
|
("p06-polisher", ["p06", "polisher"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
_seed_interaction_row(conn, "alias-keyed 1", "polisher")
|
|
_seed_interaction_row(conn, "alias-keyed 2", "p06")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
summary = mig.apply_plan(conn, plan)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert summary["interaction_rows_rekeyed"] == 2
|
|
|
|
from atocore.interactions.service import list_interactions
|
|
|
|
rows = list_interactions(project="p06-polisher", limit=50)
|
|
prompts = {i.prompt for i in rows}
|
|
assert prompts == {"alias-keyed 1", "alias-keyed 2"}
|
|
|
|
|
|
def test_apply_is_idempotent(project_registry):
|
|
"""Running apply twice produces the same final state as running it once."""
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
_seed_state_row(conn, shadow_id, "status", "next_focus", "Wave 1")
|
|
_seed_memory_row(conn, "project", "m1", "p05")
|
|
_seed_interaction_row(conn, "i1", "p05")
|
|
|
|
# first apply
|
|
plan_a = mig.build_plan(conn, registry_path)
|
|
summary_a = mig.apply_plan(conn, plan_a)
|
|
|
|
# second apply: plan should be empty
|
|
plan_b = mig.build_plan(conn, registry_path)
|
|
assert plan_b.is_empty
|
|
|
|
# forcing a second apply on the empty plan via the function
|
|
# directly should also succeed as a no-op (caller normally
|
|
# has to pass --allow-empty through the CLI, but apply_plan
|
|
# itself doesn't enforce that — the refusal is in run())
|
|
summary_b = mig.apply_plan(conn, plan_b)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert summary_a["state_rows_rekeyed"] == 1
|
|
assert summary_a["memory_rows_rekeyed"] == 1
|
|
assert summary_a["interaction_rows_rekeyed"] == 1
|
|
assert summary_b["state_rows_rekeyed"] == 0
|
|
assert summary_b["memory_rows_rekeyed"] == 0
|
|
assert summary_b["interaction_rows_rekeyed"] == 0
|
|
|
|
|
|
def test_apply_refuses_with_integrity_errors(project_registry):
|
|
"""If the projects table has two case-variant rows for the canonical id, refuse.
|
|
|
|
The projects.name column has a case-sensitive UNIQUE constraint,
|
|
so exact duplicates can't exist. But case-variant rows
|
|
``p05-interferometer`` and ``P05-Interferometer`` can both
|
|
survive the UNIQUE constraint while both matching the
|
|
case-insensitive ``lower(name) = lower(?)`` lookup that the
|
|
migration uses to find the canonical row. That ambiguity
|
|
(which canonical row should dependents rekey into?) is exactly
|
|
the integrity failure the migration is guarding against.
|
|
"""
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
_seed_shadow_project(conn, "p05-interferometer")
|
|
_seed_shadow_project(conn, "P05-Interferometer")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
assert plan.integrity_errors
|
|
with pytest.raises(mig.MigrationRefused):
|
|
mig.apply_plan(conn, plan)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reporting tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_plan_to_json_dict_is_serializable(project_registry):
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
_seed_state_row(conn, shadow_id, "status", "next_focus", "Wave 1")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
payload = mig.plan_to_json_dict(plan)
|
|
# Must be JSON-serializable
|
|
json_str = json.dumps(payload, default=str)
|
|
assert "p05-interferometer" in json_str
|
|
assert payload["counts"]["state_rekey_rows"] == 1
|
|
|
|
|
|
def test_write_report_creates_file(tmp_path, project_registry):
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
report_dir = tmp_path / "reports"
|
|
report_path = mig.write_report(
|
|
plan,
|
|
summary=None,
|
|
db_path=Path("/tmp/fake.db"),
|
|
registry_path=registry_path,
|
|
mode="dry-run",
|
|
report_dir=report_dir,
|
|
)
|
|
assert report_path.exists()
|
|
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
|
assert payload["mode"] == "dry-run"
|
|
assert "plan" in payload
|
|
|
|
|
|
def test_render_plan_text_on_empty_plan(project_registry):
|
|
registry_path = project_registry() # empty
|
|
conn = _open_db_connection()
|
|
try:
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
text = mig.render_plan_text(plan)
|
|
assert "nothing to plan" in text.lower()
|
|
|
|
|
|
def test_render_plan_text_on_collision(project_registry):
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
canonical_id = _seed_shadow_project(conn, "p05-interferometer")
|
|
_seed_state_row(conn, shadow_id, "status", "phase", "A")
|
|
_seed_state_row(conn, canonical_id, "status", "phase", "B")
|
|
plan = mig.build_plan(conn, registry_path)
|
|
finally:
|
|
conn.close()
|
|
|
|
text = mig.render_plan_text(plan)
|
|
assert "COLLISION" in text.upper()
|
|
assert "REFUSE" in text.upper() or "refuse" in text.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# gap-closed companion test — the flip side of
|
|
# test_legacy_alias_keyed_state_is_invisible_until_migrated in
|
|
# test_project_state.py. After running this migration, the legacy row
|
|
# IS reachable via the canonical id.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_legacy_alias_gap_is_closed_after_migration(project_registry):
|
|
"""End-to-end regression test for the canonicalization gap.
|
|
|
|
Simulates the exact scenario from
|
|
test_legacy_alias_keyed_state_is_invisible_until_migrated in
|
|
test_project_state.py — a shadow projects row with a state row
|
|
pointing at it. Runs the migration. Verifies the state is now
|
|
reachable via the canonical id.
|
|
"""
|
|
registry_path = project_registry(
|
|
("p05-interferometer", ["p05", "interferometer"])
|
|
)
|
|
|
|
conn = _open_db_connection()
|
|
try:
|
|
shadow_id = _seed_shadow_project(conn, "p05")
|
|
_seed_state_row(
|
|
conn, shadow_id, "status", "legacy_focus", "Wave 1 ingestion"
|
|
)
|
|
|
|
# Before migration: the legacy row is invisible to get_state
|
|
# (this is the documented gap, covered in test_project_state.py)
|
|
assert all(
|
|
entry.value != "Wave 1 ingestion" for entry in get_state("p05")
|
|
)
|
|
assert all(
|
|
entry.value != "Wave 1 ingestion"
|
|
for entry in get_state("p05-interferometer")
|
|
)
|
|
|
|
# Run the migration
|
|
plan = mig.build_plan(conn, registry_path)
|
|
mig.apply_plan(conn, plan)
|
|
finally:
|
|
conn.close()
|
|
|
|
# After migration: the row is reachable via canonical AND alias
|
|
via_canonical = get_state("p05-interferometer")
|
|
via_alias = get_state("p05")
|
|
assert any(e.value == "Wave 1 ingestion" for e in via_canonical)
|
|
assert any(e.value == "Wave 1 ingestion" for e in via_alias)
|