feat(simulator): curators X-ray experience simulator (sub-project 3) #3
@@ -0,0 +1,7 @@
|
||||
.PHONY: sim sim-local
|
||||
|
||||
sim:
|
||||
docker compose -f simulator/docker-compose.yml up --build
|
||||
|
||||
sim-local:
|
||||
python -m uvicorn simulator.app:app --reload --port 8000
|
||||
@@ -286,3 +286,29 @@ Records point at files via `file_path`. Those files live on the player's drive,
|
||||
**not** in this git repo (the repo holds only the catalog metadata). Keep your
|
||||
`file_path` values consistent with wherever you mount that drive on the machine
|
||||
that will eventually run the room.
|
||||
|
||||
## Playing with the simulator (curator's X-ray)
|
||||
|
||||
The simulator is a web stand-in for the installation's control panel. It runs the
|
||||
real `hef.selection` code against a synthetic catalog so you can feel whether the
|
||||
dials surface fitting pieces before any hardware exists.
|
||||
|
||||
**Run it (Docker):**
|
||||
|
||||
make sim
|
||||
|
||||
then open http://localhost:8000.
|
||||
|
||||
**Run it (no Docker):**
|
||||
|
||||
pip install -e ".[sim]"
|
||||
make sim-local
|
||||
|
||||
**What you see:** the five real dials (mode + Left/Right/Dark/Light), the model
|
||||
knobs (brain/mood weights, pool size, approved-only), and the X-ray — the picked
|
||||
piece, the ranked candidate pool with distances, and brain/mood coordinate maps
|
||||
showing where your knob point and the candidates sit.
|
||||
|
||||
By default it loads a generated fixture catalog. To point it at a real catalog,
|
||||
set `HEF_SIM_CATALOG=catalog/library.jsonl` (used automatically when that file is
|
||||
non-empty).
|
||||
|
||||
+42
-10
@@ -58,6 +58,38 @@ def candidates_for_mode(records, mode: str, pool_size: int) -> list[Record]:
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def ranked_candidates(
|
||||
records,
|
||||
coord: Coordinate,
|
||||
mode: str,
|
||||
*,
|
||||
pool_size: int = 4,
|
||||
weights: Weights = Weights(),
|
||||
approved_only: bool = False,
|
||||
) -> list[tuple[Record, float]]:
|
||||
"""Mode-eligible records sorted nearest-first, each paired with its distance.
|
||||
|
||||
Applies the same approved_only filter and 'av' pool fallback as select(),
|
||||
then returns up to pool_size nearest (record, distance) pairs. This is the
|
||||
single source of truth for "the pool"; select() picks from it.
|
||||
"""
|
||||
if mode not in CONTENT_MODES:
|
||||
raise ValueError(
|
||||
f"ranked_candidates expects a content mode {sorted(CONTENT_MODES)}, "
|
||||
f"got {mode!r}"
|
||||
)
|
||||
pool = records
|
||||
if approved_only:
|
||||
pool = [r for r in pool if r.review_status == "approved"]
|
||||
candidates = candidates_for_mode(pool, mode, pool_size)
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda r: (distance(coord, record_coordinate(r), weights), r.id),
|
||||
)
|
||||
nearest = ranked[:pool_size]
|
||||
return [(r, distance(coord, record_coordinate(r), weights)) for r in nearest]
|
||||
|
||||
|
||||
def select(
|
||||
records,
|
||||
coord: Coordinate,
|
||||
@@ -82,17 +114,17 @@ def select(
|
||||
)
|
||||
if mode == "none":
|
||||
return None
|
||||
pool = records
|
||||
if approved_only:
|
||||
pool = [r for r in pool if r.review_status == "approved"]
|
||||
candidates = candidates_for_mode(pool, mode, pool_size)
|
||||
if not candidates:
|
||||
return None
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda r: (distance(coord, record_coordinate(r), weights), r.id),
|
||||
ranked = ranked_candidates(
|
||||
records,
|
||||
coord,
|
||||
mode,
|
||||
pool_size=pool_size,
|
||||
weights=weights,
|
||||
approved_only=approved_only,
|
||||
)
|
||||
nearest = ranked[:pool_size]
|
||||
if not ranked:
|
||||
return None
|
||||
nearest = [r for r, _ in ranked]
|
||||
if rng is None:
|
||||
return nearest[0]
|
||||
return rng.choice(nearest)
|
||||
|
||||
+8
-1
@@ -12,8 +12,15 @@ requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["hef", "tools"]
|
||||
packages = ["hef", "tools", "simulator"]
|
||||
|
||||
[project.scripts]
|
||||
hef-ingest = "tools.ingest_cli:main"
|
||||
hef-review = "tools.review_cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
sim = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY hef ./hef
|
||||
COPY tools ./tools
|
||||
COPY simulator ./simulator
|
||||
COPY catalog ./catalog
|
||||
RUN pip install --no-cache-dir -e ".[sim]"
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "simulator.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Web-based curator's X-ray simulator for the experience filter."""
|
||||
@@ -0,0 +1,96 @@
|
||||
"""FastAPI service: dials -> real hef.selection -> X-ray (pick + ranked pool)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from hef.catalog import load_catalog, record_to_dict
|
||||
from hef.selection import (
|
||||
Coordinate,
|
||||
Weights,
|
||||
candidates_for_mode,
|
||||
ranked_candidates,
|
||||
select,
|
||||
)
|
||||
from simulator.fixtures import generate_fixture_catalog
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
class SelectRequest(BaseModel):
|
||||
left: int = Field(ge=0, le=4)
|
||||
right: int = Field(ge=0, le=4)
|
||||
dark: int = Field(ge=0, le=4)
|
||||
light: int = Field(ge=0, le=4)
|
||||
mode: Literal["none", "audio", "video", "av"]
|
||||
pool_size: int = Field(default=4, ge=1, le=25)
|
||||
brain_weight: float = Field(default=1.0, ge=0.0)
|
||||
mood_weight: float = Field(default=1.0, ge=0.0)
|
||||
approved_only: bool = False
|
||||
|
||||
|
||||
def load_catalog_or_fixtures() -> list:
|
||||
"""Use the real catalog if a non-empty one is configured/exists, else fixtures."""
|
||||
configured = os.environ.get("HEF_SIM_CATALOG")
|
||||
path = Path(configured) if configured else Path("catalog/library.jsonl")
|
||||
if path.exists() and path.stat().st_size > 0:
|
||||
return load_catalog(path)
|
||||
return generate_fixture_catalog()
|
||||
|
||||
|
||||
def create_app(records: Optional[list] = None) -> FastAPI:
|
||||
app = FastAPI(title="HEF Experience Simulator")
|
||||
app.state.catalog = records if records is not None else load_catalog_or_fixtures()
|
||||
|
||||
@app.post("/api/select")
|
||||
def api_select(req: SelectRequest):
|
||||
catalog = app.state.catalog
|
||||
coord = Coordinate(req.left, req.right, req.dark, req.light)
|
||||
weights = Weights(brain=req.brain_weight, mood=req.mood_weight)
|
||||
if req.mode == "none":
|
||||
return {"pick": None, "pool": [], "coverage": {"candidates_in_mode": 0}}
|
||||
pool = catalog
|
||||
if req.approved_only:
|
||||
pool = [r for r in pool if r.review_status == "approved"]
|
||||
eligible = candidates_for_mode(pool, req.mode, req.pool_size)
|
||||
ranked = ranked_candidates(
|
||||
catalog, coord, req.mode,
|
||||
pool_size=req.pool_size, weights=weights, approved_only=req.approved_only,
|
||||
)
|
||||
pick = select(
|
||||
catalog, coord, req.mode,
|
||||
pool_size=req.pool_size, weights=weights, approved_only=req.approved_only,
|
||||
rng=None,
|
||||
)
|
||||
return {
|
||||
"pick": record_to_dict(pick) if pick else None,
|
||||
"pool": [
|
||||
{"record": record_to_dict(r), "distance": d, "rank": i + 1}
|
||||
for i, (r, d) in enumerate(ranked)
|
||||
],
|
||||
"coverage": {"candidates_in_mode": len(eligible)},
|
||||
}
|
||||
|
||||
@app.get("/api/catalog/meta")
|
||||
def api_meta():
|
||||
catalog = app.state.catalog
|
||||
return {
|
||||
"total": len(catalog),
|
||||
"by_mode": dict(Counter(r.mode for r in catalog)),
|
||||
"by_status": dict(Counter(r.review_status for r in catalog)),
|
||||
}
|
||||
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
simulator:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: simulator/Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Deterministic synthetic catalog so the selection model can be felt everywhere.
|
||||
|
||||
The real catalog (catalog/library.jsonl) is empty; this generates one record per
|
||||
cell of the 5x5 brain x 5x5 mood coordinate space (625 records), with a seeded
|
||||
mix of content modes and review statuses and no real media attached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from hef.catalog import Record
|
||||
|
||||
MODES = ("audio", "video", "av")
|
||||
ARCHIVES = ("internet_archive", "musopen", "librivox", "nasa", "freesound")
|
||||
|
||||
_LEFT_WORDS = ("Treatise", "Lecture", "Field Notes", "Reading", "Documentary")
|
||||
_RIGHT_WORDS = ("Reverie", "Nocturne", "Bloom", "Drift", "Aurora")
|
||||
|
||||
|
||||
def _title(left: int, right: int, dark: int, light: int, mode: str) -> str:
|
||||
a = _LEFT_WORDS[left] if left >= right else _RIGHT_WORDS[right]
|
||||
return f"{a} ({mode}) L{left}R{right}D{dark}Li{light}"
|
||||
|
||||
|
||||
def generate_fixture_catalog(seed: int = 1729) -> list[Record]:
|
||||
"""One valid Record per coordinate cell (625 total), deterministic for a seed."""
|
||||
rng = random.Random(seed)
|
||||
records: list[Record] = []
|
||||
n = 0
|
||||
for left in range(5):
|
||||
for right in range(5):
|
||||
for dark in range(5):
|
||||
for light in range(5):
|
||||
mode = rng.choice(MODES)
|
||||
status = rng.choice(("proposed", "approved"))
|
||||
is_video = mode in ("video", "av")
|
||||
records.append(
|
||||
Record(
|
||||
id=f"fx-{n:04d}",
|
||||
title=_title(left, right, dark, light, mode),
|
||||
source_url=f"https://example.test/fx/{n:04d}",
|
||||
source_archive=rng.choice(ARCHIVES),
|
||||
license="public_domain",
|
||||
mode=mode,
|
||||
left=left,
|
||||
right=right,
|
||||
dark=dark,
|
||||
light=light,
|
||||
duration_s=rng.choice((300, 480, 600, 720, 900)),
|
||||
file_path="",
|
||||
review_status=status,
|
||||
resolution="1920x1080" if is_video else "",
|
||||
rationale=f"fixture at ({left},{right},{dark},{light})",
|
||||
reviewed_at="2026-06-04T00:00:00Z" if status == "approved" else None,
|
||||
)
|
||||
)
|
||||
n += 1
|
||||
return records
|
||||
@@ -0,0 +1,111 @@
|
||||
const DIALS = ["left", "right", "dark", "light"];
|
||||
const MODEL = ["brain_weight", "mood_weight", "pool_size"];
|
||||
|
||||
function buildGrid(el) {
|
||||
el.innerHTML = "";
|
||||
// rows = first axis 0..4 top->bottom, cols = second axis 0..4 left->right
|
||||
for (let a = 0; a < 5; a++) {
|
||||
for (let b = 0; b < 5; b++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "cell";
|
||||
cell.dataset.a = a;
|
||||
cell.dataset.b = b;
|
||||
el.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintGrid(el, axisA, axisB, point, pool) {
|
||||
// clear
|
||||
el.querySelectorAll(".cell").forEach((c) => {
|
||||
c.className = "cell";
|
||||
c.innerHTML = "";
|
||||
});
|
||||
const counts = {};
|
||||
pool.forEach((c) => {
|
||||
const r = c.record;
|
||||
const key = `${r[axisA]},${r[axisB]}`;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
});
|
||||
el.querySelectorAll(".cell").forEach((c) => {
|
||||
const a = +c.dataset.a, b = +c.dataset.b;
|
||||
const key = `${a},${b}`;
|
||||
if (counts[key]) {
|
||||
c.classList.add("cand");
|
||||
const n = document.createElement("span");
|
||||
n.className = "n";
|
||||
n.textContent = counts[key];
|
||||
c.appendChild(n);
|
||||
}
|
||||
if (a === point[axisA] && b === point[axisB]) c.classList.add("point");
|
||||
});
|
||||
}
|
||||
|
||||
function readState() {
|
||||
const s = { mode: document.getElementById("mode").value, approved_only: document.getElementById("approved_only").checked };
|
||||
DIALS.forEach((d) => (s[d] = +document.getElementById(d).value));
|
||||
s.brain_weight = +document.getElementById("brain_weight").value;
|
||||
s.mood_weight = +document.getElementById("mood_weight").value;
|
||||
s.pool_size = +document.getElementById("pool_size").value;
|
||||
return s;
|
||||
}
|
||||
|
||||
function syncOutputs() {
|
||||
[...DIALS, ...MODEL].forEach((id) => {
|
||||
const out = document.getElementById(`${id}-out`);
|
||||
if (out) out.textContent = document.getElementById(id).value;
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
syncOutputs();
|
||||
const state = readState();
|
||||
const resp = await fetch("/api/select", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(state),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
const pickEl = document.getElementById("pick");
|
||||
if (!data.pick) {
|
||||
pickEl.innerHTML = '<div class="void">∅ Void / rest — walls dark, audio silent.</div>';
|
||||
} else {
|
||||
const p = data.pick;
|
||||
pickEl.innerHTML =
|
||||
`<div class="title">${p.title}</div>` +
|
||||
`<div>mode ${p.mode} · coord (${p.left},${p.right},${p.dark},${p.light})</div>` +
|
||||
`<div>${p.rationale || ""}</div>`;
|
||||
}
|
||||
|
||||
const poolEl = document.getElementById("pool");
|
||||
poolEl.innerHTML = "";
|
||||
data.pool.forEach((c, i) => {
|
||||
const li = document.createElement("li");
|
||||
if (i === 0) li.className = "winner";
|
||||
li.innerHTML = `${c.record.title} <span class="dist">d=${c.distance.toFixed(2)}</span>`;
|
||||
poolEl.appendChild(li);
|
||||
});
|
||||
|
||||
const point = { left: state.left, right: state.right, dark: state.dark, light: state.light };
|
||||
paintGrid(document.getElementById("brain-grid"), "left", "right", point, data.pool);
|
||||
paintGrid(document.getElementById("mood-grid"), "dark", "light", point, data.pool);
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const data = await (await fetch("/api/catalog/meta")).json();
|
||||
const byMode = Object.entries(data.by_mode).map(([k, v]) => `${k}:${v}`).join(" ");
|
||||
document.getElementById("meta").textContent = `${data.total} records · ${byMode}`;
|
||||
}
|
||||
|
||||
function init() {
|
||||
buildGrid(document.getElementById("brain-grid"));
|
||||
buildGrid(document.getElementById("mood-grid"));
|
||||
document.querySelectorAll("input, select").forEach((el) =>
|
||||
el.addEventListener("input", refresh)
|
||||
);
|
||||
loadMeta();
|
||||
refresh();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HEF — Curator's X-ray</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Experience Filter — Curator's X-ray</h1>
|
||||
<div id="meta" class="meta"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="controls">
|
||||
<h2>Dials</h2>
|
||||
<label>Mode
|
||||
<select id="mode">
|
||||
<option value="none">None</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="av" selected>A+V</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Left (analytical) <output id="left-out">0</output>
|
||||
<input type="range" id="left" min="0" max="4" step="1" value="0"></label>
|
||||
<label>Right (artistic) <output id="right-out">0</output>
|
||||
<input type="range" id="right" min="0" max="4" step="1" value="0"></label>
|
||||
<label>Dark (somber) <output id="dark-out">0</output>
|
||||
<input type="range" id="dark" min="0" max="4" step="1" value="0"></label>
|
||||
<label>Light (uplifting) <output id="light-out">0</output>
|
||||
<input type="range" id="light" min="0" max="4" step="1" value="0"></label>
|
||||
|
||||
<h2>Model knobs</h2>
|
||||
<label>Brain weight <output id="brain_weight-out">1</output>
|
||||
<input type="range" id="brain_weight" min="0" max="4" step="0.5" value="1"></label>
|
||||
<label>Mood weight <output id="mood_weight-out">1</output>
|
||||
<input type="range" id="mood_weight" min="0" max="4" step="0.5" value="1"></label>
|
||||
<label>Pool size <output id="pool_size-out">4</output>
|
||||
<input type="range" id="pool_size" min="1" max="10" step="1" value="4"></label>
|
||||
<label class="check"><input type="checkbox" id="approved_only"> Approved only</label>
|
||||
</section>
|
||||
|
||||
<section class="xray">
|
||||
<div class="pick">
|
||||
<h2>Picked</h2>
|
||||
<div id="pick"></div>
|
||||
</div>
|
||||
<div class="pool">
|
||||
<h2>Pool (nearest first)</h2>
|
||||
<ol id="pool"></ol>
|
||||
</div>
|
||||
<div class="maps">
|
||||
<h2>Coordinate maps</h2>
|
||||
<div class="map"><div class="label">Brain — Left × Right</div><div id="brain-grid" class="grid5"></div></div>
|
||||
<div class="map"><div class="label">Mood — Dark × Light</div><div id="mood-grid" class="grid5"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: -apple-system, system-ui, sans-serif; background: #0e0e16; color: #e6e6ee; }
|
||||
header { padding: 12px 20px; border-bottom: 1px solid #2a2a3a; display: flex; justify-content: space-between; align-items: baseline; }
|
||||
header h1 { font-size: 18px; margin: 0; }
|
||||
.meta { font-size: 12px; color: #9a9ab0; }
|
||||
main { display: grid; grid-template-columns: 280px 1fr; gap: 20px; padding: 20px; }
|
||||
.controls label { display: block; margin: 8px 0; font-size: 13px; }
|
||||
.controls input[type=range] { width: 100%; }
|
||||
.controls .check { display: flex; gap: 6px; align-items: center; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .5px; color: #9a9ab0; }
|
||||
.xray { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
|
||||
.pick #pick { background: #1a1a2e; border: 1px solid #33334a; border-radius: 8px; padding: 14px; min-height: 80px; }
|
||||
.pick .title { font-size: 16px; font-weight: 600; }
|
||||
.pick .void { color: #7777aa; font-style: italic; }
|
||||
.pool ol { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||
.pool li { margin: 4px 0; }
|
||||
.pool li.winner { color: #7fffd4; font-weight: 600; }
|
||||
.pool .dist { color: #9a9ab0; }
|
||||
.maps { grid-column: 1 / -1; display: flex; gap: 40px; }
|
||||
.grid5 { display: grid; grid-template-columns: repeat(5, 28px); grid-template-rows: repeat(5, 28px); gap: 3px; }
|
||||
.grid5 .cell { background: #1c1c2c; border: 1px solid #2a2a3a; border-radius: 3px; position: relative; }
|
||||
.grid5 .cell.cand { background: #3a3a66; }
|
||||
.grid5 .cell.point { outline: 2px solid #7fffd4; }
|
||||
.grid5 .cell .n { position: absolute; right: 2px; bottom: 1px; font-size: 9px; color: #aab; }
|
||||
.label { font-size: 11px; color: #9a9ab0; margin-bottom: 4px; }
|
||||
@@ -0,0 +1,40 @@
|
||||
from hef.catalog import validate_catalog
|
||||
from hef.selection import CONTENT_MODES
|
||||
from simulator.fixtures import generate_fixture_catalog
|
||||
|
||||
|
||||
def test_fixture_catalog_is_valid():
|
||||
records = generate_fixture_catalog()
|
||||
validate_catalog(records) # raises on any invalid record or duplicate id
|
||||
|
||||
|
||||
def test_fixture_catalog_spans_the_coordinate_space():
|
||||
records = generate_fixture_catalog()
|
||||
coords = {(r.left, r.right, r.dark, r.light) for r in records}
|
||||
# all 625 cells of the 5x5 brain x 5x5 mood space are present
|
||||
assert len(coords) == 625
|
||||
|
||||
|
||||
def test_fixture_catalog_has_every_content_mode():
|
||||
records = generate_fixture_catalog()
|
||||
present = {r.mode for r in records}
|
||||
assert CONTENT_MODES <= present
|
||||
|
||||
|
||||
def test_fixture_catalog_mixes_review_statuses():
|
||||
records = generate_fixture_catalog()
|
||||
statuses = {r.review_status for r in records}
|
||||
assert statuses == {"proposed", "approved"}
|
||||
|
||||
|
||||
def test_fixture_catalog_references_no_real_media():
|
||||
records = generate_fixture_catalog()
|
||||
assert all(r.file_path == "" for r in records)
|
||||
|
||||
|
||||
def test_fixture_catalog_is_deterministic():
|
||||
a = generate_fixture_catalog(seed=42)
|
||||
b = generate_fixture_catalog(seed=42)
|
||||
assert [r.id for r in a] == [r.id for r in b]
|
||||
assert [r.mode for r in a] == [r.mode for r in b]
|
||||
assert [r.review_status for r in a] == [r.review_status for r in b]
|
||||
@@ -0,0 +1,87 @@
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from hef.catalog import Record
|
||||
from hef.selection import Coordinate, Weights, ranked_candidates, select
|
||||
|
||||
|
||||
def make_record(**overrides):
|
||||
base = dict(
|
||||
id="r",
|
||||
title="t",
|
||||
source_url="u",
|
||||
source_archive="internet_archive",
|
||||
license="public_domain",
|
||||
mode="video",
|
||||
left=0,
|
||||
right=0,
|
||||
dark=0,
|
||||
light=0,
|
||||
duration_s=600,
|
||||
file_path="",
|
||||
)
|
||||
base.update(overrides)
|
||||
return Record(**base)
|
||||
|
||||
|
||||
def test_ranked_candidates_sorts_nearest_first_with_distances():
|
||||
near = make_record(id="near", mode="video", left=1, right=1, dark=0, light=0)
|
||||
far = make_record(id="far", mode="video", left=4, right=4, dark=4, light=4)
|
||||
ranked = ranked_candidates([far, near], Coordinate(0, 0, 0, 0), "video")
|
||||
assert [r.id for r, _ in ranked] == ["near", "far"]
|
||||
assert ranked[0][1] < ranked[1][1]
|
||||
|
||||
|
||||
def test_ranked_candidates_caps_at_pool_size():
|
||||
recs = [make_record(id=f"r{i}", mode="video", left=i % 5) for i in range(10)]
|
||||
ranked = ranked_candidates(recs, Coordinate(0, 0, 0, 0), "video", pool_size=3)
|
||||
assert len(ranked) == 3
|
||||
|
||||
|
||||
def test_ranked_candidates_filters_by_mode():
|
||||
a = make_record(id="aud", mode="audio")
|
||||
v = make_record(id="vid", mode="video")
|
||||
ranked = ranked_candidates([a, v], Coordinate(0, 0, 0, 0), "audio")
|
||||
assert [r.id for r, _ in ranked] == ["aud"]
|
||||
|
||||
|
||||
def test_ranked_candidates_av_falls_back_to_audio_and_video():
|
||||
a = make_record(id="aud", mode="audio")
|
||||
v = make_record(id="vid", mode="video")
|
||||
ranked = ranked_candidates([a, v], Coordinate(0, 0, 0, 0), "av", pool_size=4)
|
||||
assert {r.id for r, _ in ranked} == {"aud", "vid"}
|
||||
|
||||
|
||||
def test_ranked_candidates_approved_only():
|
||||
p = make_record(id="prop", mode="video", review_status="proposed")
|
||||
ok = make_record(id="appr", mode="video", review_status="approved")
|
||||
ranked = ranked_candidates([p, ok], Coordinate(0, 0, 0, 0), "video", approved_only=True)
|
||||
assert [r.id for r, _ in ranked] == ["appr"]
|
||||
|
||||
|
||||
def test_ranked_candidates_rejects_none_mode():
|
||||
with pytest.raises(ValueError):
|
||||
ranked_candidates([], Coordinate(0, 0, 0, 0), "none")
|
||||
|
||||
|
||||
def test_select_returns_rank_one_of_ranked_candidates():
|
||||
recs = [
|
||||
make_record(id="near", mode="video", left=1),
|
||||
make_record(id="far", mode="video", left=4),
|
||||
]
|
||||
coord = Coordinate(0, 0, 0, 0)
|
||||
ranked = ranked_candidates(recs, coord, "video")
|
||||
assert select(recs, coord, "video", rng=None).id == ranked[0][0].id
|
||||
|
||||
|
||||
def test_select_none_mode_still_returns_none():
|
||||
recs = [make_record(id="v", mode="video")]
|
||||
assert select(recs, Coordinate(0, 0, 0, 0), "none") is None
|
||||
|
||||
|
||||
def test_select_shuffles_within_pool_when_rng_given():
|
||||
recs = [make_record(id=f"r{i}", mode="video", left=i) for i in range(5)]
|
||||
coord = Coordinate(0, 0, 0, 0)
|
||||
picks = {select(recs, coord, "video", pool_size=5, rng=random.Random(s)).id for s in range(20)}
|
||||
assert len(picks) > 1
|
||||
@@ -0,0 +1,102 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hef.catalog import Record
|
||||
from simulator.app import create_app
|
||||
|
||||
|
||||
def make_record(**overrides):
|
||||
base = dict(
|
||||
id="r",
|
||||
title="t",
|
||||
source_url="u",
|
||||
source_archive="internet_archive",
|
||||
license="public_domain",
|
||||
mode="video",
|
||||
left=0,
|
||||
right=0,
|
||||
dark=0,
|
||||
light=0,
|
||||
duration_s=600,
|
||||
file_path="",
|
||||
)
|
||||
base.update(overrides)
|
||||
return Record(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
records = [
|
||||
make_record(id="v-near", mode="video", left=0, right=0, dark=0, light=0),
|
||||
make_record(id="v-far", mode="video", left=4, right=4, dark=4, light=4),
|
||||
make_record(id="a-one", mode="audio", left=1, right=1, dark=1, light=1),
|
||||
make_record(id="prop", mode="video", left=0, right=0, dark=0, light=1,
|
||||
review_status="proposed"),
|
||||
make_record(id="appr", mode="video", left=0, right=0, dark=0, light=1,
|
||||
review_status="approved"),
|
||||
]
|
||||
return TestClient(create_app(records=records))
|
||||
|
||||
|
||||
def _body(**overrides):
|
||||
base = dict(left=0, right=0, dark=0, light=0, mode="video")
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_select_returns_pick_and_ranked_pool(client):
|
||||
resp = client.post("/api/select", json=_body(mode="video", pool_size=4))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["pick"]["id"] == "v-near"
|
||||
ids = [c["record"]["id"] for c in data["pool"]]
|
||||
assert ids[0] == "v-near"
|
||||
assert all("distance" in c and "rank" in c for c in data["pool"])
|
||||
assert [c["rank"] for c in data["pool"]] == list(range(1, len(data["pool"]) + 1))
|
||||
|
||||
|
||||
def test_none_mode_is_the_void(client):
|
||||
resp = client.post("/api/select", json=_body(mode="none"))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["pick"] is None
|
||||
assert data["pool"] == []
|
||||
|
||||
|
||||
def test_dial_out_of_range_is_rejected(client):
|
||||
resp = client.post("/api/select", json=_body(left=7))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_bad_mode_is_rejected(client):
|
||||
resp = client.post("/api/select", json=_body(mode="banana"))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_approved_only_narrows_pool(client):
|
||||
resp = client.post("/api/select", json=_body(left=0, right=0, dark=0, light=1,
|
||||
mode="video", approved_only=True))
|
||||
data = resp.json()
|
||||
assert all(c["record"]["review_status"] == "approved" for c in data["pool"])
|
||||
|
||||
|
||||
def test_catalog_meta_reports_counts(client):
|
||||
resp = client.get("/api/catalog/meta")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 5
|
||||
assert data["by_mode"]["video"] == 4
|
||||
assert data["by_mode"]["audio"] == 1
|
||||
assert set(data["by_status"]) == {"proposed", "approved"}
|
||||
|
||||
|
||||
from simulator.app import create_app as _create_app_for_static
|
||||
|
||||
|
||||
def test_index_is_served():
|
||||
# The default app mounts the real static dir.
|
||||
client = TestClient(_create_app_for_static())
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
assert "X-ray" in resp.text
|
||||
Reference in New Issue
Block a user