Homelabtoolkit v1

This commit is contained in:
2026-06-08 00:01:55 +12:00
parent c8838a485d
commit 040fbacc70
56 changed files with 12477 additions and 151 deletions
+396
View File
@@ -0,0 +1,396 @@
"""Tests for the User Favourites feature (Emby Collections / BoxSets).
Uses a FakeEmbyClient (no network, no app.py import) and plain ``asyncio.run`` so
no pytest-asyncio plugin is required. Run from the repo root: ``python -m pytest``.
"""
import asyncio
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from services import emby_collections, emby_users, emby_watch_history, favorites
from services import recommendations as rec
def run(coro):
return asyncio.run(coro)
# ── Item builders ────────────────────────────────────────────────────────────
def movie(item_id, name, *, genres=None, year=2014, studio="Acme",
director="Jane Doe", actor="Bob Roe", ticks=72_000_000_000, rating=7.0):
return {
"Id": item_id, "Name": name, "Type": "Movie", "MediaType": "Video",
"ProductionYear": year, "RunTimeTicks": ticks, "CommunityRating": rating,
"Genres": genres or ["Action"],
"Studios": [{"Name": studio}] if studio else [],
"People": ([{"Name": director, "Type": "Director"}] if director else [])
+ ([{"Name": actor, "Type": "Actor"}] if actor else []),
}
# ── Fake Emby client ─────────────────────────────────────────────────────────
class FakeEmbyClient:
def __init__(self, users=None, collections=None, collection_items=None,
watched_by_user=None, watched_history=None, candidates=None):
self.users = users or []
self.collections = collections or []
self.collection_items = collection_items or {} # cid -> [raw items]
self.watched_by_user = watched_by_user or {} # uid -> set(item_id)
self.watched_history = watched_history or {} # uid -> [raw items]
self.candidates = candidates or {} # uid -> [raw items]
self.calls = [] # recorded writes
async def get(self, path, params=None):
params = params or {}
if path == "/Users":
return list(self.users)
if path.startswith("/Users/") and path.endswith("/Items"):
uid = path.split("/")[2]
if "ParentId" in params: # collection children
cid = params["ParentId"]
watched = self.watched_by_user.get(uid, set())
items = []
for raw in self.collection_items.get(cid, []):
copy = dict(raw)
copy["UserData"] = {"Played": raw["Id"] in watched}
items.append(copy)
return {"Items": items}
if "Ids" in params: # is_item_watched
wanted = params["Ids"].split(",")
watched = self.watched_by_user.get(uid, set())
return {"Items": [{"Id": i, "UserData": {"Played": i in watched}} for i in wanted]}
if params.get("Filters") == "IsUnplayed": # candidate pool
return {"Items": list(self.candidates.get(uid, []))}
raise AssertionError(f"unexpected get {path} {params}")
async def get_all(self, path, params=None):
params = params or {}
if path == "/Items" and params.get("IncludeItemTypes") == "BoxSet":
return list(self.collections)
if path.startswith("/Users/") and path.endswith("/Items"):
uid = path.split("/")[2]
if params.get("Filters") == "IsPlayed":
return list(self.watched_history.get(uid, []))
raise AssertionError(f"unexpected get_all {path} {params}")
async def post(self, path, params=None, **kwargs):
self.calls.append(("post", path, params or {}))
cid = path.split("/")[2]
for item_id in (params or {}).get("Ids", "").split(","):
if item_id:
self.collection_items.setdefault(cid, []).append(
{"Id": item_id, "Name": item_id, "Type": "Movie"})
return {"ok": True}
async def delete(self, path, params=None, **kwargs):
self.calls.append(("delete", path, params or {}))
cid = path.split("/")[2]
remove = set((params or {}).get("Ids", "").split(","))
self.collection_items[cid] = [
i for i in self.collection_items.get(cid, []) if i["Id"] not in remove
]
return {"ok": True}
@property
def writes(self):
return [c for c in self.calls if c[0] in ("post", "delete")]
def base_client():
"""Two users, each with a favourites collection; Matt and Dave have watched
different items to prove user-specific behaviour. Plus a themed collection
that is not a favourites collection."""
return FakeEmbyClient(
users=[{"Id": "u-matt", "Name": "Matt"}, {"Id": "u-dave", "Name": "Dave"}],
collections=[
{"Id": "c-matt", "Name": "Matt Favorites", "ChildCount": 3},
{"Id": "c-dave", "Name": "Dave Favorites", "ChildCount": 1},
{"Id": "c-misc", "Name": "Marvel Universe", "ChildCount": 5},
],
collection_items={
"c-matt": [movie("m1", "Alpha"), movie("m2", "Bravo"), movie("m3", "Charlie")],
},
# Matt watched m1; Dave watched m2 (same items, different user)
watched_by_user={"u-matt": {"m1"}, "u-dave": {"m2"}},
)
# ── 1. Detecting favourites collections by name ──────────────────────────────
@pytest.mark.parametrize("name,owner", [
("Matt Favorites", "Matt"),
("Dave Favorites", "Dave"),
("Anna Maria Favorites", "Anna Maria"),
("favorites", None),
("Favorites", None),
("Marvel Universe", None),
("Favorites of Matt", None),
("", None),
(None, None),
])
def test_parse_favorites_owner(name, owner):
assert emby_collections.parse_favorites_owner(name) == owner
def test_list_favorites_users_only_matches_real_users():
users = run(favorites.list_favorites_users(base_client()))
assert [u["user_name"] for u in users] == ["Dave", "Matt"] # "Marvel Universe" excluded
def test_find_all_collections_includes_non_favorites():
cols = run(emby_collections.find_all_collections(base_client()))
by_name = {c["collection_name"]: c for c in cols}
assert set(by_name) == {"Matt Favorites", "Dave Favorites", "Marvel Universe"}
assert by_name["Matt Favorites"]["is_favorites"] is True
assert by_name["Marvel Universe"]["is_favorites"] is False
assert by_name["Marvel Universe"]["owner_name"] is None
def test_list_collections_overview_maps_owner_user_id():
overview = run(favorites.list_collections_overview(base_client()))
by_name = {c["collection_name"]: c for c in overview["collections"]}
assert by_name["Matt Favorites"]["owner_user_id"] == "u-matt"
assert by_name["Marvel Universe"]["owner_user_id"] is None
assert {u["name"] for u in overview["users"]} == {"Matt", "Dave"}
# ── 2. Listing collection items, user-specific watched status ────────────────
def test_list_collection_items_for_user():
items = run(emby_collections.list_collection_items(base_client(), "c-matt", "u-matt"))
assert [i["title"] for i in items] == ["Alpha", "Bravo", "Charlie"]
assert items[0]["runtime_minutes"] == 120
def test_watched_status_is_user_specific():
client = base_client()
matt = run(emby_collections.list_collection_items(client, "c-matt", "u-matt"))
dave = run(emby_collections.list_collection_items(client, "c-matt", "u-dave"))
assert [i["watched"] for i in matt] == [True, False, False] # Matt watched m1
assert [i["watched"] for i in dave] == [False, True, False] # Dave watched m2
def test_is_item_watched_user_specific():
client = base_client()
assert run(emby_watch_history.is_item_watched(client, "u-matt", "m1")) is True
assert run(emby_watch_history.is_item_watched(client, "u-dave", "m1")) is False
# ── 3. Browse any collection; actions gated to favourites owner ──────────────
def test_collection_view_actions_enabled_for_any_collection():
view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-matt"))
assert view["actions_enabled"] is True
assert [i["watched"] for i in view["items"]] == [True, False, False]
def test_collection_view_watched_follows_selected_user():
view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-dave"))
assert view["actions_enabled"] is True # available regardless of owner
assert [i["watched"] for i in view["items"]] == [False, True, False]
def test_collection_view_actions_enabled_for_non_favorites_collection():
client = base_client()
client.collection_items["c-misc"] = [movie("x1", "Iron Man")]
view = run(favorites.get_collection_items_view(client, "c-misc", "u-matt"))
assert view["is_favorites"] is False
assert view["actions_enabled"] is True
def test_collection_view_missing_collection_raises_404():
with pytest.raises(favorites.FavoritesError) as exc:
run(favorites.get_collection_items_view(base_client(), "c-nope", "u-matt"))
assert exc.value.status == 404
# ── 4. Cleanup: dry-run vs apply ─────────────────────────────────────────────
def test_cleanup_dry_run_does_not_modify_emby():
client = base_client()
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=True))
assert res["dry_run"] is True and res["applied"] is False
assert res["watched_found"] == 1
assert [r["item_id"] for r in res["removed"]] == ["m1"]
assert client.writes == []
assert len(client.collection_items["c-matt"]) == 3
def test_apply_cleanup_removes_only_watched_items():
client = base_client()
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
assert res["applied"] is True
assert res["summary"]["removed_count"] == 1
assert res["summary"]["final_count"] == 2
deletes = [c for c in client.writes if c[0] == "delete"]
assert len(deletes) == 1
assert deletes[0][1] == "/Collections/c-matt/Items"
assert deletes[0][2]["Ids"] == "m1"
assert {i["Id"] for i in client.collection_items["c-matt"]} == {"m2", "m3"}
assert res["log"][0]["item_id"] == "m1"
assert res["log"][0]["reason"] == "watched-by-user"
assert res["log"][0]["user"] == "Matt"
assert res["log"][0]["collection"] == "Matt Favorites"
def test_cleanup_does_not_cross_users():
"""Dave watched m2; cleaning Matt's collection must not remove m2."""
client = base_client()
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
assert "m2" not in {r["item_id"] for r in res["removed"]}
assert "m2" in {i["Id"] for i in client.collection_items["c-matt"]}
def test_cleanup_works_on_non_favorites_collection():
"""Cleanup is available for any collection, using the selected user's
watched status."""
client = base_client()
client.collection_items["c-misc"] = [movie("x1", "Iron Man"), movie("x2", "Iron Man 2")]
client.watched_by_user["u-matt"] = {"x1"}
res = run(favorites.cleanup_watched(client, "c-misc", "u-matt", dry_run=False))
assert res["applied"] is True
assert [r["item_id"] for r in res["removed"]] == ["x1"]
assert {i["Id"] for i in client.collection_items["c-misc"]} == {"x2"}
# ── 5. Recommendation scoring (pure) ─────────────────────────────────────────
def test_build_profile_and_score_candidate():
watched = [emby_collections.normalize_item(movie(
"w1", "Seed", genres=["Action", "Sci-Fi"], year=2014,
studio="Acme", director="Jane Doe", actor="Bob Roe"))]
profile = rec.build_profile(watched)
assert profile.genres == {"Action", "Sci-Fi"}
assert profile.decades == {2010}
candidate = emby_collections.normalize_item(movie(
"c1", "Match", genres=["Action", "Sci-Fi", "Drama"], year=2013,
studio="Acme", director="Jane Doe", actor="Bob Roe"))
# 2 genres*5 + director3 + actor2 + studio2 + decade1 + mediatype1 = 19
assert rec.score_candidate(candidate, profile) == 19
def test_score_series_franchise_bonus():
profile = rec.TasteProfile(series={"The Saga"}, media_types={"Video"})
cand = {"series_name": "The Saga", "media_type": "Video", "genres": [], "year": None}
assert rec.score_candidate(cand, profile) == rec.SCORE_SERIES + rec.SCORE_MEDIA_TYPE
def test_rank_orders_by_score_then_rating_then_title():
profile = rec.TasteProfile(genres={"Action"}, media_types={"Video"},
studios={"Acme"}, directors={"Jane Doe"})
low = emby_collections.normalize_item(movie("low", "Zeta", genres=["Action"],
studio=None, director=None, actor=None, rating=9.0))
high = emby_collections.normalize_item(movie("high", "Alpha", genres=["Action"],
studio="Acme", director="Jane Doe", actor=None, rating=1.0))
ranked = rec.rank_candidates([low, high], profile)
assert [r["id"] for r in ranked] == ["high", "low"]
def test_rank_drops_zero_score_candidates():
profile = rec.TasteProfile(genres={"Action"})
unrelated = {"id": "x", "genres": ["Cooking"], "media_type": "Audio", "year": 1980, "title": "X"}
assert rec.rank_candidates([unrelated], profile) == []
# ── 6. Regenerate: exclusions ────────────────────────────────────────────────
def regen_client():
client = base_client()
client.watched_history["u-matt"] = [
movie("w1", "Seed One", genres=["Action", "Sci-Fi"], year=2014),
movie("w2", "Seed Two", genres=["Action"], year=2016),
]
client.candidates["u-matt"] = [
movie("c1", "Good One", genres=["Action", "Sci-Fi"], year=2015),
movie("c2", "Good Two", genres=["Action"], year=2012),
movie("m2", "Already In Collection", genres=["Action"], year=2015), # in c-matt
movie("w1", "Already Watched", genres=["Action"], year=2014), # watched
]
return client
def test_regenerate_excludes_watched_and_existing_collection_items():
res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=True, target_size=25))
ids = {r["item_id"] for r in res["recommended"]}
assert "m2" not in ids # already in collection
assert "w1" not in ids # already watched
assert {"c1", "c2"} <= ids
def test_regenerate_dry_run_makes_no_writes():
client = regen_client()
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
assert res["applied"] is False
assert client.writes == []
def test_regenerate_apply_adds_up_to_target():
client = regen_client()
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=False, target_size=4))
# collection had 3, target 4 -> add exactly 1
assert res["summary"]["added_count"] == 1
assert res["summary"]["final_count"] == 4
posts = [c for c in client.writes if c[0] == "post"]
assert len(posts) == 1
assert posts[0][1] == "/Collections/c-matt/Items"
assert res["recommended"][0]["item_id"] == "c1" # highest score first
def test_regenerate_never_readds_watched():
res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=False, target_size=25))
added_ids = {r["item_id"] for r in res["recommended"]}
assert "w1" not in added_ids and "w2" not in added_ids
# ── 7. Empty history ─────────────────────────────────────────────────────────
def test_regenerate_with_empty_history_recommends_nothing():
client = base_client()
client.watched_history["u-matt"] = []
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
assert res["recommended"] == []
assert "No watch history" in res["message"]
assert client.writes == []
def test_empty_profile_build_candidates_returns_empty():
profile = rec.build_profile([])
assert profile.is_empty
assert run(rec.build_candidates(base_client(), "u-matt", profile, set())) == []
# ── 8. Missing users / collections / API failures ────────────────────────────
def test_missing_user_raises_404():
with pytest.raises(favorites.FavoritesError) as exc:
run(favorites.cleanup_watched(base_client(), "c-matt", "u-ghost"))
assert exc.value.status == 404
def test_missing_collection_raises_404():
with pytest.raises(favorites.FavoritesError) as exc:
run(favorites.cleanup_watched(base_client(), "c-nope", "u-matt"))
assert exc.value.status == 404
def test_emby_api_failure_propagates():
class BoomClient(FakeEmbyClient):
async def get_all(self, path, params=None):
raise RuntimeError("Emby exploded")
client = BoomClient(users=[{"Id": "u-matt", "Name": "Matt"}])
with pytest.raises(RuntimeError):
run(favorites.list_collections_overview(client))
def test_regenerate_rejects_negative_target():
with pytest.raises(favorites.FavoritesError):
run(favorites.regenerate(base_client(), "c-matt", "u-matt", dry_run=True, target_size=-1))
+229
View File
@@ -0,0 +1,229 @@
"""Tests for the Music Collection Completeness feature.
Covers normalisation, fuzzy matching, scan upserts, deleted-file handling,
completeness statuses, and that manual ignore decisions survive a recompute.
Scanning uses a fake tag reader so we don't need real audio files with embedded
metadata — the scanner's database behaviour is what's under test.
"""
from pathlib import Path
import pytest
from services import db
from services import music_library as ml
from services import text_normalize as tn
@pytest.fixture
def temp_db(tmp_path, monkeypatch):
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
db.init_db()
yield
def _fake_tags(path: Path) -> dict:
return {
"artist": path.parent.parent.name,
"album": path.parent.name,
"title": path.stem,
"track_number": 1,
"disc_number": 1,
"year": 1997,
"artist_mbid": None,
"album_mbid": None,
"track_mbid": None,
}
def _make_track(root: Path, artist: str, album: str, name: str) -> Path:
folder = root / artist / album
folder.mkdir(parents=True, exist_ok=True)
f = folder / name
f.write_bytes(b"\x00" * 64)
return f
# ── normalisation ─────────────────────────────────────────────────────────────
def test_normalize_strips_editions_but_keeps_original():
assert tn.normalize_title("OK Computer (Deluxe Edition)") == tn.normalize_title("OK Computer")
assert tn.normalize_title("The Bends - 2009 Remaster") == "the bends"
assert tn.normalize_title("In Rainbows [Remastered]") == "in rainbows"
def test_normalize_artist_drops_leading_the():
assert tn.normalize_artist("The Beatles") == "beatles"
assert tn.normalize_artist("AC/DC") == "ac dc"
def test_is_various_artists():
assert tn.is_various_artists("Various Artists")
assert tn.is_various_artists("VA")
assert not tn.is_various_artists("Radiohead")
# ── fuzzy matching / classification ───────────────────────────────────────────
def _local(title, year=1997, mbid=None, _id=1):
return {"id": _id, "title": title, "title_normalized": tn.normalize_title(title), "year": year, "mbid": mbid}
def test_classify_mbid_match():
locals_ = [_local("Whatever", mbid="mbid-123")]
status, conf, reason, lid = tn.classify_release("Different Title", 2000, "mbid-123", locals_)
assert status == tn.OWNED and conf == 1.0 and lid == 1
def test_classify_title_and_year():
locals_ = [_local("OK Computer", 1997)]
status, *_ = tn.classify_release("OK Computer", 1997, None, locals_)
assert status == tn.OWNED
# within a year still counts as owned
status2, *_ = tn.classify_release("OK Computer", 1998, None, locals_)
assert status2 == tn.OWNED
def test_classify_fuzzy_and_missing():
locals_ = [_local("OK Computer", 1997)]
status, conf, *_ = tn.classify_release("OK Komputer", None, None, locals_)
assert status == tn.PROBABLY_OWNED
status2, *_ = tn.classify_release("Kid A", 2000, None, locals_)
assert status2 == tn.MISSING
# ── scan upserts + deleted handling ───────────────────────────────────────────
def test_scan_upserts_and_skips_unchanged(temp_db, tmp_path, monkeypatch):
monkeypatch.setattr(ml, "_read_tags", _fake_tags)
root = tmp_path / "music"
_make_track(root, "Radiohead", "OK Computer", "01 - Airbag.mp3")
_make_track(root, "Radiohead", "OK Computer", "02 - Paranoid Android.mp3")
_make_track(root, "Portishead", "Dummy", "01 - Mysterons.flac")
result = ml.run_scan(root)
assert result["status"] == "completed"
with db.connect() as conn:
artists = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"]
albums = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"]
tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks WHERE is_active=1").fetchone()["c"]
assert (artists, albums, tracks) == (2, 2, 3)
# Re-scan unchanged: counts stable, no duplicate rows.
ml.run_scan(root)
with db.connect() as conn:
tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks").fetchone()["c"]
assert tracks == 3
def test_deleted_file_marked_inactive(temp_db, tmp_path, monkeypatch):
monkeypatch.setattr(ml, "_read_tags", _fake_tags)
root = tmp_path / "music"
f1 = _make_track(root, "Radiohead", "OK Computer", "01 - Airbag.mp3")
_make_track(root, "Radiohead", "OK Computer", "02 - Paranoid Android.mp3")
ml.run_scan(root)
f1.unlink()
ml.run_scan(root)
with db.connect() as conn:
gone = conn.execute("SELECT is_active FROM library_tracks WHERE file_path=?", (str(f1),)).fetchone()
active_tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks WHERE is_active=1").fetchone()["c"]
# album still active because one track remains
album_active = conn.execute("SELECT is_active FROM library_albums LIMIT 1").fetchone()["is_active"]
assert gone["is_active"] == 0
assert active_tracks == 1
assert album_active == 1
def test_deleted_album_deactivates_album_and_artist(temp_db, tmp_path, monkeypatch):
monkeypatch.setattr(ml, "_read_tags", _fake_tags)
root = tmp_path / "music"
f = _make_track(root, "Solo", "Only Album", "01 - Track.mp3")
ml.run_scan(root)
f.unlink()
ml.run_scan(root)
with db.connect() as conn:
album_active = conn.execute("SELECT is_active FROM library_albums LIMIT 1").fetchone()["is_active"]
artist_active = conn.execute("SELECT is_active FROM library_artists LIMIT 1").fetchone()["is_active"]
assert album_active == 0
assert artist_active == 0
# ── completeness statuses + manual decisions ──────────────────────────────────
def _seed_artist_with_release(year_local=1997):
"""Insert one artist, one local album (OK Computer), and three external
release groups (owned, missing, live-excluded). Returns artist id."""
with db.connect() as conn:
cur = conn.execute(
"INSERT INTO library_artists(name, name_normalized, is_active, created_at, updated_at) VALUES('Radiohead','radiohead',1,'','')"
)
artist_id = cur.lastrowid
conn.execute(
"INSERT INTO library_albums(artist_id, title, title_normalized, year, is_active, created_at, updated_at) VALUES(?,?,?,?,1,'','')",
(artist_id, "OK Computer", tn.normalize_title("OK Computer"), year_local),
)
for mbid, title, yr, prim, sec in [
("rg-ok", "OK Computer", 1997, "Album", "[]"),
("rg-kida", "Kid A", 2000, "Album", "[]"),
("rg-live", "I Might Be Wrong: Live Recordings", 2001, "Album", '["Live"]'),
]:
conn.execute(
"INSERT INTO external_releases(artist_id, mb_release_group_mbid, title, title_normalized, first_release_year, primary_type, secondary_types, fetched_at) "
"VALUES(?,?,?,?,?,?,?,'')",
(artist_id, mbid, title, tn.normalize_title(title), yr, prim, sec),
)
ml.recompute_completeness(conn, artist_id)
return artist_id
def test_completeness_statuses(temp_db):
artist_id = _seed_artist_with_release()
with db.connect() as conn:
rows = {
r["title"]: r
for r in conn.execute(
"SELECT title, status FROM collection_completeness WHERE artist_id=?", (artist_id,)
).fetchall()
}
assert rows["OK Computer"]["status"] == tn.OWNED
assert rows["Kid A"]["status"] == tn.MISSING
# The live album is filtered out — no completeness row created.
assert "I Might Be Wrong: Live Recordings" not in rows
def test_manual_ignore_survives_recompute(temp_db):
artist_id = _seed_artist_with_release()
with db.connect() as conn:
kid = conn.execute(
"SELECT id FROM collection_completeness WHERE artist_id=? AND title='Kid A'", (artist_id,)
).fetchone()
ml.set_album_decision(kid["id"], "ignore")
# A metadata refresh recomputes — the manual decision must persist.
with db.connect() as conn:
ml.recompute_completeness(conn, artist_id)
row = conn.execute("SELECT status, manual_override FROM collection_completeness WHERE id=?", (kid["id"],)).fetchone()
assert row["status"] == "ignored"
assert row["manual_override"] == 1
def test_reset_decision_recomputes(temp_db):
artist_id = _seed_artist_with_release()
with db.connect() as conn:
kid = conn.execute(
"SELECT id FROM collection_completeness WHERE artist_id=? AND title='Kid A'", (artist_id,)
).fetchone()
ml.set_album_decision(kid["id"], "owned")
ml.set_album_decision(kid["id"], "reset")
with db.connect() as conn:
row = conn.execute("SELECT status, manual_override FROM collection_completeness WHERE id=?", (kid["id"],)).fetchone()
assert row["manual_override"] == 0
assert row["status"] == tn.MISSING # Kid A is not in the local library