diff --git a/fusionlab/api.py b/fusionlab/api.py index 2a208ca..ed6e0de 100644 --- a/fusionlab/api.py +++ b/fusionlab/api.py @@ -15,6 +15,7 @@ from fusionlab.api_agent import router as agent_router from fusionlab.api_replay import router as replay_router from fusionlab.api_replay import start_warm_up +from fusionlab.api_studies import router as studies_router from fusionlab.api_virtual import router as virtual_router from fusionlab.physics import DEVICES, Controls, sigmav_dt, simulate @@ -219,6 +220,7 @@ def index(): app.include_router(replay_router) app.include_router(virtual_router) app.include_router(agent_router) +app.include_router(studies_router) @app.get("/static/agent.js", include_in_schema=False) diff --git a/fusionlab/api_studies.py b/fusionlab/api_studies.py new file mode 100644 index 0000000..e4cf2de --- /dev/null +++ b/fusionlab/api_studies.py @@ -0,0 +1,280 @@ +"""Named session studies (backlog #44): a saved view — workspace, shot(s), slice, what-if/explore +parameters — plus a markdown note, stored server-side so a short `#/study/{id}` link restores the +same view in any browser. The router is list/get/create/update beside the other three routers in +`fusionlab/api.py`. + +Storage follows the protocol-store pattern (`fusionlab/protocol.py`): one JSON document per id +under `data/studies/`, written only through `atomicio.atomic_write` (an interrupted write never +leaves a half-file), ids minted server-side matching a strict pattern (`s_[0-9a-f]{12}`) so a +tampered request can never name a path — a malformed id is rejected as 422 before it can become +one, and a well-formed unknown id is a clean 404, checked before any write. Runtime data (ignored +with the rest of `data/*`), single-user demo scale. + +Honesty: a study is a comparison record. The server stamps the house words into every study's +provenance — stored copy and rendered copy say "compared with MAST data, never predictive". The +stored `route` must be one of the router's own hash routes (no `#/study/…` nesting — a study +points at a concrete view, never at another study link). +""" + +from __future__ import annotations + +import json +import logging +import re +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from fusionlab.atomicio import atomic_write + +logger = logging.getLogger(__name__) + +router = APIRouter() + +STUDY_ID_PATTERN = re.compile(r"s_[0-9a-f]{12}") +# A study's route is a view the workspace router (web/workspaces.js) owns: replay/compare/explore. +# `#/study/…` is deliberately absent — a study points at a view, never at another study link. The +# path parameter is only ever shot ids and compare's comma list (`[0-9,]*`), so traversal-shaped +# strings ("//../..") cannot ride in as a stored route. +ROUTE_RE = re.compile(r"^#/(replay|compare|explore)(?:/[0-9,]*)?(?:\?.*)?$") + +STUDIES_DIR = Path(__file__).resolve().parent.parent / "data" / "studies" +MAX_NOTE_CHARS = 20_000 # a page-length note; a bound on the field, checked by the schema +MAX_NAME_CHARS = 200 +MAX_ROUTE_CHARS = 500 + +HOUSE_WORDS = "compared with MAST data, never predictive" + + +class StudyIdError(ValueError): + """A malformed study id — rejected before it can name a path (the route answers 422).""" + + +class InvalidStudy(ValueError): + """A study body that does not fit the house shape (e.g. a non-hash route) — clean 422.""" + + +class StudyProvenance(BaseModel): + """Server-built provenance: where the study was made from, and the honesty rule it rides with. + The client cannot supply `house_words` — the server always stamps the house words.""" + + created_from: str = "palette" + house_words: str = HOUSE_WORDS + + +class StudyIn(BaseModel): + """The client's fields of a new study. Ids, timestamps, workspace and provenance are + server-built: a request body cannot mint an id, forge a stamp, or point a study at another + study link. Non-finite numbers are rejected one layer down, at the route door.""" + + name: str = Field(min_length=1, max_length=MAX_NAME_CHARS) + route: str = Field(min_length=1, max_length=MAX_ROUTE_CHARS) + shot_ids: list[int] = Field(default_factory=list, max_length=16) + ref_shot: int | None = None + what_if: dict[str, float] | None = Field(default=None, max_length=16) + explore: dict[str, float | str] | None = Field(default=None, max_length=32) + notes_md: str = Field(default="", max_length=MAX_NOTE_CHARS) + created_from: str = Field(default="palette", max_length=40) + + +class StudyUpdate(BaseModel): + """The fields an update may change; omitted fields are left as stored. `created_from` and the + provenance block are deliberately absent — where a study came from never changes.""" + + name: str | None = Field(default=None, min_length=1, max_length=MAX_NAME_CHARS) + route: str | None = Field(default=None, min_length=1, max_length=MAX_ROUTE_CHARS) + shot_ids: list[int] | None = Field(default=None, max_length=16) + ref_shot: int | None = None + what_if: dict[str, float] | None = Field(default=None, max_length=16) + explore: dict[str, float | str] | None = Field(default=None, max_length=32) + notes_md: str | None = Field(default=None, max_length=MAX_NOTE_CHARS) + + +class Study(BaseModel): + """One stored study — the full document at `data/studies/{id}.json`.""" + + id: str + name: str + created: str + updated: str + workspace: str + route: str + shot_ids: list[int] + ref_shot: int | None + what_if: dict[str, float] | None + explore: dict[str, float | str] | None + notes_md: str + provenance: StudyProvenance + + +# ---------------------------------------------------------------- the store +def _now() -> str: + return datetime.now(UTC).isoformat(timespec="seconds") + + +def new_study_id() -> str: + return "s_" + uuid.uuid4().hex[:12] + + +def _assert_study_id(study_id: str) -> None: + """Drafts and studies are named from the id — a malformed id is unknown before it can become a + path (protocol.py's `_assert_protocol_id` discipline).""" + if not STUDY_ID_PATTERN.fullmatch(study_id): + raise StudyIdError(f"unknown study id '{study_id}'") + + +def _validated_route(route: str) -> str: + if not ROUTE_RE.fullmatch(route): + raise InvalidStudy( + "route must be a FusionLab hash view — '#/replay/…', '#/compare/…' or '#/explore'" + ) + return route + + +def study_path(study_id: str) -> Path: + _assert_study_id(study_id) + return STUDIES_DIR / f"{study_id}.json" + + +def _dump(study: Study) -> str: + return json.dumps(study.model_dump(mode="json"), indent=2, allow_nan=False) + + +def save_study(study: Study) -> None: + """Atomic write (atomicio): an interrupted save never leaves a half-file.""" + STUDIES_DIR.mkdir(parents=True, exist_ok=True) + atomic_write(study_path(study.id), lambda tmp: tmp.write_text(_dump(study), encoding="utf-8")) + + +def load_study(study_id: str) -> Study: + """The stored study, re-validated on read — a hand-edited study file fails here, not in the UI.""" + path = study_path(study_id) + if not path.is_file(): + raise LookupError(f"no study '{study_id}' — the link may be broken or it was deleted") + try: + return Study.model_validate(json.loads(path.read_text(encoding="utf-8"))) + except (ValueError, json.JSONDecodeError) as e: + logger.info("study %s failed validation: %s", study_id, e) + raise ValueError(f"study '{study_id}' is not a valid study — it may have been edited " + "outside the app") from e + + +def list_studies() -> list[Study]: + """Every stored study, newest-updated first (ties broken by id, so the order is stable across + calls). One unreadable file is logged and skipped — it must not hide every other study.""" + studies: list[Study] = [] + if not STUDIES_DIR.is_dir(): + return studies + for path in STUDIES_DIR.glob("*.json"): + if not STUDY_ID_PATTERN.fullmatch(path.stem): + continue + try: + studies.append(Study.model_validate(json.loads(path.read_text(encoding="utf-8")))) + except (ValueError, json.JSONDecodeError) as e: + logger.warning("skipping unreadable study file %s: %s", path.name, e) + studies.sort(key=lambda s: (s.updated, s.id), reverse=True) + return studies + + +def create_study(payload: StudyIn) -> Study: + """A new study: the id, timestamps, workspace (derived from the route) and provenance are + server-built; only the study's content comes from the client.""" + route = _validated_route(payload.route) + study = Study( + id=new_study_id(), name=payload.name, created=_now(), updated=_now(), + workspace=route.split("/")[1], route=route, shot_ids=payload.shot_ids, + ref_shot=payload.ref_shot, what_if=payload.what_if, explore=payload.explore, + notes_md=payload.notes_md, + provenance=StudyProvenance(created_from=payload.created_from or "palette"), + ) + save_study(study) + return study + + +def update_study(study_id: str, payload: StudyUpdate) -> Study: + """Patch the stored study (404-before-write: an unknown id never touches the filesystem) and + stamp `updated`. Fields the request omits stay as stored; an explicit null clears.""" + study = load_study(study_id) # no study, nothing to update — 404 before any write + data: dict[str, Any] = payload.model_dump(exclude_unset=True) + route = data.pop("route", None) + if route is not None: + validated = _validated_route(str(route)) + study.route = validated + study.workspace = validated.split("/")[1] + for key in ("name", "shot_ids", "ref_shot", "what_if", "explore", "notes_md"): + if key in data: + setattr(study, key, data[key]) + study.updated = _now() + save_study(study) + return study + + +# ---------------------------------------------------------------- routes +def _assert_standard_json(body: bytes) -> None: + """Python's json.loads silently accepts Infinity/NaN literals, but this store's dump refuses + them (allow_nan=False) — and a validation-error reply cannot even echo an unserializable value + without crashing. So non-standard bodies are rejected at the door, before pydantic sees them: + a clean 422 that never echoes the value. Malformed JSON cannot reach here — pydantic 422s it + during body binding.""" + + def reject(lit: str) -> Any: + raise ValueError(lit) + + try: + json.loads(body, parse_constant=reject) + except ValueError as e: + raise HTTPException(422, "study parameters must be finite numbers") from e + + +@router.get("/studies") +def studies_list() -> dict[str, Any]: + """Every stored study, newest-updated first — the palette's Studies source (and its recent + pointers when the fetch fails).""" + return {"studies": [s.model_dump(mode="json") for s in list_studies()]} + + +@router.get("/studies/{study_id}") +def studies_get(study_id: str) -> dict[str, Any]: + """One study, whole. A malformed id is a 422 (rejected before it can name a path); a + well-formed unknown id is a clean 404.""" + try: + study = load_study(study_id) + except StudyIdError as e: + raise HTTPException(422, str(e)) from e + except LookupError as e: + raise HTTPException(404, str(e)) from e + return study.model_dump(mode="json") + + +@router.post("/studies", status_code=201) +async def studies_create(request: Request, payload: StudyIn) -> dict[str, Any]: + """Save the current view as a named study. Caps are schema bounds (a note over the cap is a + 422, nothing written); the route must be one of the app's own hash views; non-finite numbers + are rejected at the door.""" + _assert_standard_json(await request.body()) + try: + study = create_study(payload) + except InvalidStudy as e: + raise HTTPException(422, str(e)) from e + return study.model_dump(mode="json") + + +@router.patch("/studies/{study_id}") +async def studies_update(study_id: str, request: Request, payload: StudyUpdate) -> dict[str, Any]: + """Update a study's fields (the note after another look, a renamed study). Unknown ids are 404 + before any write; malformed ids are 422; an oversized note is a 422 that leaves the stored + study untouched.""" + _assert_standard_json(await request.body()) + try: + study = update_study(study_id, payload) + except StudyIdError as e: + raise HTTPException(422, str(e)) from e + except LookupError as e: + raise HTTPException(404, str(e)) from e + except InvalidStudy as e: + raise HTTPException(422, str(e)) from e + return study.model_dump(mode="json") diff --git a/tests/test_studies.py b/tests/test_studies.py new file mode 100644 index 0000000..af436d6 --- /dev/null +++ b/tests/test_studies.py @@ -0,0 +1,215 @@ +"""Named session studies (backlog #44): the store's CRUD round trip, its tamper-resistance (a +malformed id never names a path; an unknown id is a clean 404 before any write), the caps, atomic +writes, stable listing, and the honesty rule — provenance is server-stamped with the house words, +and a stored route is always one of the app's own hash views. Tripwires for the served JavaScript +live in test_web_checks.py, per the house split; the browser-side restore e2e is the recorded +dogfood in the fix PR (no JS harness, on purpose). +""" + +import json + +import pytest +from fastapi.testclient import TestClient + +from fusionlab import api_studies +from fusionlab.api import app +from fusionlab.atomicio import atomic_write + +client = TestClient(app) + + +@pytest.fixture() +def studies_dir(tmp_path, monkeypatch): + """The store pointed at an empty temporary directory — the repo's runtime data/ stays untouched + and every test starts from an empty store.""" + monkeypatch.setattr(api_studies, "STUDIES_DIR", tmp_path) + return tmp_path + + +def _body(**over): + body = { + "name": "NBI shift sweep on shot 30192", + "route": "#/replay/30192?t=0.412&edit=1", + "shot_ids": [30192], + "ref_shot": 30192, + "what_if": {"P_nbi": 1.3}, + "notes_md": "At −30 ms the shifted NBI pulse still lands inside the flattop.", + } + body.update(over) + return body + + +# ---------------------------------------------------------------- the round trip +def test_create_get_update_round_trip(studies_dir): + """Save → read → edit, through the HTTP surface: the stored document is the one served, and an + update stamps `updated` without touching `created`.""" + r = client.post("/studies", json=_body()) + assert r.status_code == 201, r.text + s = r.json() + assert api_studies.STUDY_ID_PATTERN.fullmatch(s["id"]) + assert s["workspace"] == "replay" + assert s["created"] == s["updated"] + + got = client.get(f"/studies/{s['id']}") + assert got.status_code == 200 + assert got.json() == s # the served study is exactly the stored one, ids to provenance + + p = client.patch(f"/studies/{s['id']}", json={"notes_md": "Re-read next review."}) + assert p.status_code == 200 + patched = p.json() + assert patched["notes_md"] == "Re-read next review." + assert patched["created"] == s["created"] and patched["updated"] >= s["updated"] + assert client.get(f"/studies/{s['id']}").json() == patched + + # and on disk: one JSON document, same content (the store is the file, not a parallel truth) + on_disk = json.loads((studies_dir / f"{s['id']}.json").read_text(encoding="utf-8")) + assert on_disk == patched + + +def test_omitted_fields_survive_patch_and_null_clears(studies_dir): + """PATCH semantics: omitted fields stay as stored; an explicit null clears (ref_shot).""" + s = client.post("/studies", json=_body()).json() + p = client.patch(f"/studies/{s['id']}", json={"name": "Renamed", "ref_shot": None}).json() + assert p["name"] == "Renamed" + assert p["ref_shot"] is None + assert p["what_if"] == {"P_nbi": 1.3} # omitted → untouched + assert p["notes_md"] == _body()["notes_md"] + + +def test_workspace_follows_route_on_update(studies_dir): + s = client.post("/studies", json=_body(route="#/explore")).json() + assert s["workspace"] == "explore" + p = client.patch(f"/studies/{s['id']}", json={"route": "#/compare/30166,30420?ref=30166"}).json() + assert p["workspace"] == "compare" + + +# ---------------------------------------------------------------- tamper-resistance +@pytest.mark.parametrize("bad", ["s_hack", "s_TOOSHORT", "s_0123456789zz", "x_s_01234567890", "30192"]) +def test_malformed_id_is_a_clean_422(studies_dir, bad): + """A malformed id is rejected as 422 before it can become a path — GET and PATCH alike, with + the store still empty (no read of, no write to, any filesystem name).""" + assert client.get(f"/studies/{bad}").status_code == 422 + assert client.patch(f"/studies/{bad}", json={"name": "x"}).status_code == 422 + assert list(studies_dir.glob("*")) == [] + + +def test_unknown_id_is_404_before_any_write(studies_dir): + """A well-formed unknown id is a clean 404 — and PATCH checks before writing (404-before-write, + the protocol-store discipline): no file, no directory, nothing.""" + for method, kwargs in (("get", {}), ("patch", {"json": {"notes_md": "x"}})): + r = getattr(client, method)("/studies/s_000000000000", **kwargs) + assert r.status_code == 404, r.text + assert "no study" in r.json()["detail"] + assert list(studies_dir.glob("*")) == [] + + +def test_client_cannot_forge_ids_or_stamps(studies_dir): + """Ids, timestamps and provenance are server-built: a request body carrying them is ignored, and + `house_words` always holds the house honesty rule.""" + forged = _body(id="s_ffffffffffff", created="1999-01-01T00:00:00+00:00", + updated="1999-01-01T00:00:00+00:00", workspace="exploit", + provenance={"house_words": "validated, predictive"}) + s = client.post("/studies", json=forged).json() + assert s["id"] != "s_ffffffffffff" and api_studies.STUDY_ID_PATTERN.fullmatch(s["id"]) + assert s["workspace"] == "replay" + assert s["provenance"]["house_words"] == "compared with MAST data, never predictive" + assert s["created"].startswith("202") # server clock, not the forged 1999 + + +def test_provenance_is_immutable_after_create(studies_dir): + s = client.post("/studies", json=_body()).json() + p = client.patch(f"/studies/{s['id']}", json={"name": "Renamed"}).json() + assert p["provenance"] == s["provenance"] # where a study came from never changes + + +# ---------------------------------------------------------------- the caps and the route rule +def test_oversized_note_is_rejected_with_nothing_written(studies_dir): + r = client.post("/studies", json=_body(notes_md="x" * 20_001)) + assert r.status_code == 422 + assert list(studies_dir.glob("*")) == [] # rejected at the schema — no file, no directory + + +def test_note_cap_leaves_the_stored_study_untouched(studies_dir): + """A rejected update must not half-apply: the stored note is exactly what it was.""" + s = client.post("/studies", json=_body()).json() + r = client.patch(f"/studies/{s['id']}", json={"notes_md": "y" * 20_001}) + assert r.status_code == 422 + assert client.get(f"/studies/{s['id']}").json()["notes_md"] == _body()["notes_md"] + + +@pytest.mark.parametrize("route", ["javascript:alert(1)", "http://example.com", "#/study/s_012345678901", "#/replay//../../etc", ""]) +def test_route_must_be_a_house_hash_view(studies_dir, route): + """A study points at a view the router owns — never at another study link, never at a URL the + server should fetch. All of those are clean 422s.""" + assert client.post("/studies", json=_body(route=route)).status_code == 422 + assert list(studies_dir.glob("*")) == [] + + +def test_nonfinite_parameter_is_rejected(studies_dir): + """json cannot carry inf — but Python's json.loads parses an `Infinity` literal, so a hand-built + request can carry one. Finite values only, rejected at the route door (a plain httpx client + refuses to even send inf, so the body is hand-rolled).""" + raw = json.dumps(_body(what_if={"P_nbi": 0.0})).replace('"P_nbi": 0.0', '"P_nbi": Infinity') + r = client.post("/studies", content=raw, headers={"Content-Type": "application/json"}) + assert r.status_code == 422 + assert list(studies_dir.glob("*")) == [] + + +# ---------------------------------------------------------------- atomicity and listing +def test_interrupted_write_leaves_no_half_file(tmp_path, monkeypatch): + """The store's one write is atomicio's: an interrupted save can never publish half a document — + the target never appears and the store still reads clean.""" + monkeypatch.setattr(api_studies, "STUDIES_DIR", tmp_path) + target = tmp_path / "s_a000000000.json" + + def interrupted(tmp): + tmp.write_text('{"id": "s_a000000000", "name": "half', encoding="utf-8") + raise OSError("interrupted — as if the process died mid-write") + + with pytest.raises(OSError): + atomic_write(target, interrupted) + assert not target.exists() # no half-study, ever + assert list(tmp_path.glob("*.json")) == [] # the temp is dot-prefixed .tmp, invisible to the glob + assert api_studies.list_studies() == [] # the store still reads clean + + +def test_list_is_newest_first_and_stable(studies_dir, monkeypatch): + stamps = iter(["2026-10-12T10:00:00+00:00", "2026-10-12T10:00:00+00:00", + "2026-10-12T11:00:00+00:00", "2026-10-12T11:00:00+00:00", + "2026-10-12T12:00:00+00:00", "2026-10-12T12:00:00+00:00"]) + monkeypatch.setattr(api_studies, "_now", lambda: next(stamps)) + a = client.post("/studies", json=_body(name="A")).json() + b = client.post("/studies", json=_body(name="B")).json() + c = client.post("/studies", json=_body(name="C")).json() + ids = [s["id"] for s in client.get("/studies").json()["studies"]] + assert ids == [c["id"], b["id"], a["id"]] # newest-updated first + assert [s["id"] for s in client.get("/studies").json()["studies"]] == ids # stable across calls + + monkeypatch.setattr(api_studies, "_now", lambda: "2026-10-12T13:00:00+00:00") + client.patch(f"/studies/{a['id']}", json={"notes_md": "re-read"}) + ids_after = [s["id"] for s in client.get("/studies").json()["studies"]] + assert ids_after == [a["id"], c["id"], b["id"]] # the edited study rises, the rest keep order + + +def test_list_skips_unreadable_files(studies_dir): + """A hand-edited study file must not hide every other study — it is logged and skipped; reading + THAT id directly still fails loudly (ValueError, the route maps it to a 500).""" + client.post("/studies", json=_body(name="good")) + (studies_dir / "s_b000000000.json").write_text("{not json", encoding="utf-8") + listed = client.get("/studies").json()["studies"] + assert [s["name"] for s in listed] == ["good"] + with pytest.raises(ValueError): + api_studies.load_study("s_b000000000") + + +def test_degraded_studies_are_stored_as_they_are(studies_dir): + """A compare study and a bare-explore study store the same shape — the store never reinterprets + the view, it keeps what the capture sent.""" + cmp_s = client.post("/studies", json=_body(name="cmp", route="#/compare/30166,30420?ref=30166", + shot_ids=[30166, 30420], ref_shot=30166)).json() + assert cmp_s["workspace"] == "compare" + ex_s = client.post("/studies", json=_body(name="ex", route="#/explore", shot_ids=[], + ref_shot=None, what_if=None, + explore={"device": "mast", "Ip": 0.8})).json() + assert ex_s["workspace"] == "explore" + assert client.get(f"/studies/{ex_s['id']}").json()["explore"] == {"device": "mast", "Ip": 0.8} diff --git a/tests/test_web_checks.py b/tests/test_web_checks.py index 4b00ceb..579ce46 100644 --- a/tests/test_web_checks.py +++ b/tests/test_web_checks.py @@ -217,3 +217,48 @@ def test_abort_overlay_is_conditional_on_the_catalog_flag(): assert "a distance, never a forecast" in replay # the house wording, on the panel itself ins_fn = replay.split("function insight(s)")[1].split("const ohmic")[0] assert "flags this shot aborted" in ins_fn and "catalog bookkeeping" in ins_fn + + +def test_study_restore_fetch_has_the_error_discipline(): + """#44: the study mount fetches the stored study with the house discipline — the id is + encodeURIComponent'd, a non-OK reply throws (the failure names itself in the study landing, + never silent), the stored route is re-checked against the app's own views before it is + navigated to, and the replay half of the restore rides the shared load machine so the chip + stays truthful. The house words ride the restore status.""" + ws = _served("workspaces.js") + mount = ws.split("study(param) {")[1].split("function rememberStudy")[0] + assert "fetch('/studies/' + encodeURIComponent(param))" in mount + assert "if (!r.ok) throw new Error" in mount + assert "Couldn't open the study" in mount + assert "/^#\\/(replay|compare|explore)\\b/.test(s.route)" in mount # the route is re-checked, not trusted + assert "(replay|compare|explore|study)" in ws # the grammar grew a fourth entry + apply_fn = ws.split("function applyStudy")[1].split("function restoreExplore")[0] + assert "FusionLabShotLoad" in apply_fn and "L.onChange(check)" in apply_fn + assert "the rest have no slider on this machine" in ws # a partial what-if apply is said out loud + assert "compared with MAST data, never predictive" in ws + + +def test_study_save_flow_has_the_error_discipline(): + """#44: the palette's save posts the capture with the fetch discipline — a non-OK reply is + unwrapped to the server's own detail, a failure says nothing was written, and the studies list + degrades to recent pointers when the server list is unavailable (named, never silent).""" + pal = _served("palette.js") + save = pal.split("saveForm.addEventListener('submit'")[1] + assert "fetch('/studies', { method: 'POST'" in save + assert "if (!r.ok) return r.json().catch(() => null).then" in save + assert "Couldn't save" in save and "nothing was written" in save + assert "store.del(NOTE_KEY)" in save # the draft is consumed once the server holds the note + refresh = pal.split("function refreshDynamic")[1].split("let opener = null")[0] + assert "recent studies still work" in refresh + + +def test_study_note_draft_is_consumed_on_restore(): + """#44: the drawer's note field drafts to localStorage but a restore overwrites it from the + server's copy (setNote) and consumes the draft — pointers and drafts only, the server store is + the source of truth.""" + replay = _served("replay.js") + assert 'id="rp-wi-note"' in replay # the field lives in the drawer + assert "localStorage.getItem('fusionlab-study-note')" in replay # the draft loads into an empty field + assert "localStorage.setItem('fusionlab-study-note'" in replay # keyed on the input event + assert "localStorage.removeItem('fusionlab-study-note')" in replay # restore consumes the draft + diff --git a/web/app.js b/web/app.js index 83d09ce..d336ed0 100644 --- a/web/app.js +++ b/web/app.js @@ -162,6 +162,9 @@ device(k, reset) { if (!(k in devices) || ($('device').value === k && !reset)) return; $('device').value = k; loadDevice(); }, set(k, v) { if (!SLIDERS.includes(k)) return; $(k).value = v; $(k).dispatchEvent(new Event('input')); }, get: (k) => (k === 'device' ? $('device').value : val(k)), + // The whole control surface, for a study's explore snapshot (workspaces.js saves it, then + // re-applies it through device()/set()): plain numbers, the same ones the readouts show. + values: () => ({ device: $('device').value, ...Object.fromEntries(SLIDERS.map((k) => [k, val(k)])) }), // lock(['Ip']) leaves one knob live; lock(null) frees them all lock(keys) { LOCKABLE.forEach((k) => { $(k).disabled = !!keys && !keys.includes(k); }); }, ready: () => ready, // resolves once /devices has filled the controls diff --git a/web/index.html b/web/index.html index ddabebc..81ce696 100644 --- a/web/index.html +++ b/web/index.html @@ -40,6 +40,18 @@

FUSIONLAB · open-data tokamak twin

+ + +