Audit round 5 (curation feature): 5 blind reviewers, 14 confirmed fixes
The standing post-feature audit over a7f0cfe. Correctness (data): splits
become photo-scoped store records so splitting one edition no longer
force-splits same-named editions, and renaming a split copy migrates its
protection to the corrected title instead of silently re-merging copies.
Correctness (web): edit scoping now counts siblings by NORMALIZED title
(matching how stored edits apply), same-title-same-photos edits are
refused rather than corrupting the sibling entry, split copies serve
their real per-photo cues to the edit form instead of blanks, and a
split whose row vanished underneath returns 409 instead of a false 200.
Silent failures: replay_titles refuses to rebuild from a PARTIAL raw
cache (fresh clone + one --only extract would have truncated the
committed titles.json); the edit endpoint writes in crash-safe order
(cull, record, replay); corrupt curation stores fail loud naming the
file; retried edits don't double-record. Review-decision durability:
drop_rows never drops dedupe_veto rows — a rename retitles them in
place — and writes through a no-reload path so a concurrent rewrite
can't silently discard the cull. Style: catalog action cells get their
own class (.rowactions' flex display broke table alignment), editor
inputs match the design system and stop overriding the global
focus-visible outline, EditBody's clear-semantics docstring scoped to
cue fields, "nothing to change" derived from the record itself.
Tests: 8 new (photo-scoped splits, veto preservation, photo-narrowed
drops, 409s on both curation endpoints under a running job, partial-raw
replay guard, rename-keeps-protection lifecycle, corrupt-store error,
cue-field editing) and the dead edition_hint key in the edit test now
exercises real cue fields. 259 passing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
+49
-4
@@ -177,11 +177,34 @@ def test_dedupe_conflicting_years_stay_separate():
|
||||
def test_dedupe_split_titles_never_merge():
|
||||
deduped = dedupe_entries(
|
||||
[_entry("Wiz-War", "a.jpg"), _entry("Wiz-War", "b.jpg")],
|
||||
split_titles={"wiz war"},
|
||||
splits=[{"norm": "wiz war", "photos": None}],
|
||||
)
|
||||
assert len(deduped) == 2 # the human said: separate physical copies
|
||||
|
||||
|
||||
def test_photo_scoped_split_spares_other_editions():
|
||||
# splitting the copies seen in a/b must not force-split a same-named
|
||||
# different edition (c/d, kept separate by its conflicting cue)
|
||||
entries = [
|
||||
_entry("Carcassonne", "a.jpg", publisher_hint="Rio Grande"),
|
||||
_entry("Carcassonne", "b.jpg", publisher_hint="Rio Grande"),
|
||||
_entry("Carcassonne", "c.jpg", publisher_hint="Z-Man"),
|
||||
_entry("Carcassonne", "d.jpg", publisher_hint="Z-Man"),
|
||||
]
|
||||
splits = [{"norm": "carcassonne", "photos": {"a.jpg", "b.jpg"}}]
|
||||
deduped = dedupe_entries(entries, splits)
|
||||
photo_sets = [e["source_photos"] for e in deduped]
|
||||
assert ["a.jpg"] in photo_sets and ["b.jpg"] in photo_sets # split copies
|
||||
assert ["c.jpg", "d.jpg"] in photo_sets # other edition still dedupes
|
||||
|
||||
|
||||
def test_corrupt_store_fails_loud_with_filename(tmp_path):
|
||||
path = tmp_path / "title_splits.json"
|
||||
path.write_text("<<<<<<< merge conflict")
|
||||
with pytest.raises(ValueError, match="title_splits.json"):
|
||||
load_title_splits(path)
|
||||
|
||||
|
||||
def test_edits_fix_misreads_before_dedupe():
|
||||
# a corrected misspelling merges with the correctly-read sighting
|
||||
edits = [{"match": "Hebarceos", "title_raw": "Herbaceous"}]
|
||||
@@ -219,12 +242,16 @@ def test_stores_roundtrip_and_replay_from_raw(tmp_path):
|
||||
(raw / f"{photo}.json").write_text(
|
||||
json.dumps({"titles": [_entry("Wiz-War", photo)], "unidentified": []})
|
||||
)
|
||||
record_title_split(cfg.title_splits_path, "Wiz-War")
|
||||
record_title_split(cfg.title_splits_path, "wiz war") # dupe, normalized away
|
||||
record_title_split(cfg.title_splits_path, "Wiz-War", ["a.jpg", "b.jpg"])
|
||||
record_title_split(cfg.title_splits_path, "wiz war", ["a.jpg"]) # covered: no dupe
|
||||
record_title_edit(
|
||||
cfg.title_edits_path,
|
||||
{"match": "Wiz-War", "photos": ["a.jpg"], "edition_hint": "7th Edition"},
|
||||
)
|
||||
record_title_edit( # identical retry must not double-record
|
||||
cfg.title_edits_path,
|
||||
{"match": "Wiz-War", "photos": ["a.jpg"], "edition_hint": "7th Edition"},
|
||||
)
|
||||
replay_titles(cfg)
|
||||
titles = json.loads(cfg.titles_path.read_text())
|
||||
assert [e["source_photos"] for e in titles] == [["a.jpg"], ["b.jpg"]]
|
||||
@@ -242,13 +269,31 @@ def test_stores_roundtrip_and_replay_from_raw(tmp_path):
|
||||
assert len(json.loads(cfg.titles_path.read_text())) == 2
|
||||
|
||||
|
||||
def test_replay_ignores_partial_raw_caches(tmp_path):
|
||||
# fresh-clone shape: committed titles.json spans two photos, but only
|
||||
# one raw cache file exists (raw is gitignored) — replay must not
|
||||
# rebuild from the partial raws and truncate the catalog
|
||||
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||
raw = cfg.extract_raw_dir
|
||||
raw.mkdir(parents=True)
|
||||
(raw / "a.jpg.json").write_text(
|
||||
json.dumps({"titles": [_entry("Catan", "a.jpg")], "unidentified": []})
|
||||
)
|
||||
cfg.titles_path.write_text(
|
||||
json.dumps([_entry("Catan", "a.jpg"), _entry("Wingspan", "b.jpg")])
|
||||
)
|
||||
replay_titles(cfg)
|
||||
titles = {e["title_raw"] for e in json.loads(cfg.titles_path.read_text())}
|
||||
assert titles == {"Catan", "Wingspan"}
|
||||
|
||||
|
||||
def test_replay_without_raw_caches_explodes_merged_entries(tmp_path):
|
||||
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||
cfg.data_dir.mkdir(parents=True)
|
||||
merged = _entry("Wiz-War", "a.jpg")
|
||||
merged["source_photos"] = ["a.jpg", "b.jpg", "c.jpg"]
|
||||
cfg.titles_path.write_text(json.dumps([merged, _entry("Catan", "a.jpg")]))
|
||||
record_title_split(cfg.title_splits_path, "Wiz-War")
|
||||
record_title_split(cfg.title_splits_path, "Wiz-War", ["a.jpg", "b.jpg", "c.jpg"])
|
||||
replay_titles(cfg)
|
||||
titles = json.loads(cfg.titles_path.read_text())
|
||||
by_title = {}
|
||||
|
||||
Reference in New Issue
Block a user