feat(simulator): curators X-ray experience simulator (sub-project 3) #3

Merged
benstull merged 7 commits from experience-simulator into main 2026-06-05 10:52:58 +00:00
15 changed files with 686 additions and 11 deletions
+7
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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",
]
+10
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Web-based curator's X-ray simulator for the experience filter."""
+96
View File
@@ -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()
+7
View File
@@ -0,0 +1,7 @@
services:
simulator:
build:
context: ..
dockerfile: simulator/Dockerfile
ports:
- "8000:8000"
+59
View File
@@ -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
+111
View File
@@ -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);
+64
View File
@@ -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>
+26
View File
@@ -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; }
+40
View File
@@ -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]
+87
View File
@@ -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
+102
View File
@@ -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