diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx index 3d835eb60..9bc38be00 100644 --- a/web/src/app/pattern-lab/HardwareModal.test.tsx +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -7,14 +7,15 @@ // hears the Esc meant for them, and it closes the one on top. // // The modal is mounted the way the lab mounts it, behind an open flag that -// onClose clears, so a stray close really unmounts it and the pasted header -// is really gone. +// onClose clears, so a stray close really unmounts it. The pasted header is +// kept for the tab, and rendering Lab again is opening the window again. import { useState } from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import HardwareModal from "./HardwareModal"; import { useLabStore } from "@/lib/lab/store"; +import { PROJECT_STORAGE, serializeProject } from "@/lib/lab/serialize"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) })); vi.mock("@/lib/community/auth-client", () => ({ @@ -26,6 +27,10 @@ vi.mock("@/lib/community/auth-client", () => ({ const HEADER = "#pragma once\nnamespace Lab { void draw() {} }\n"; const CODE = "export function draw(display) { display.setPixel(0, 0, 255, 0, 0); }"; +/** What a model sends back for one layer of a stack. */ +const unitBlock = (index: number) => + `namespace L${index} {\n void setup() {}\n void update(float dt, const InputFrame& input) {}\n void draw() {}\n}`; + function Lab() { const [open, setOpen] = useState(true); return open ? ( @@ -39,11 +44,17 @@ function hardware() { return screen.getByRole("dialog", { name: "Hardware" }); } +function headerBox() { + return screen.getByPlaceholderText(/Paste the \.h the model gives you/); +} + +function pasteHeader() { + fireEvent.change(headerBox(), { target: { value: HEADER } }); +} + /** Paste a header, press Next, and land on the "On to hardware" stage. */ function pasteHeaderAndContinue() { - fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), { - target: { value: HEADER }, - }); + pasteHeader(); fireEvent.click(screen.getByText("Next →")); expect(screen.getByText("On to hardware")).toBeTruthy(); } @@ -57,6 +68,8 @@ function expectHeaderKept() { beforeEach(() => { useLabStore.getState().discardProject(); + // The modal keeps what was pasted for the tab, and jsdom's tab is the whole file. + sessionStorage.clear(); vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); vi.stubEnv("NEXT_PUBLIC_COMMUNITY_URL", "https://patternflow.work"); // Nothing here should reach the network; a request that does stays unanswered. @@ -283,8 +296,6 @@ describe("HardwareModal", () => { it("a stack of two code layers waits for one translation, then names the stub left", () => { useLabStore.getState().addCodeLayer(); const bottom = useLabStore.getState().layers[1]; - const unit = (index: number) => - `namespace L${index} {\n void setup() {}\n void update(float dt, const InputFrame& input) {}\n void draw() {}\n}`; render(); expect(screen.getByText(/2 layers to translate — 0 done/)).toHaveTextContent( @@ -294,7 +305,7 @@ describe("HardwareModal", () => { fireEvent.click(screen.getByText("Next →")); expect(screen.queryByText("On to hardware")).toBeNull(); - fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unit(1) } }); + fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } }); expect(screen.getByText(/1 done/)).not.toHaveTextContent("Next opens"); fireEvent.click(screen.getByText("Next →")); expect(screen.getByText("On to hardware")).toBeTruthy(); @@ -305,7 +316,7 @@ describe("HardwareModal", () => { ); fireEvent.click(screen.getByText("← Back to the header")); - fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L0 \{/), { target: { value: unit(0) } }); + fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L0 \{/), { target: { value: unitBlock(0) } }); fireEvent.click(screen.getByText("Next →")); expect(screen.getByText(/Header ready \([\d.]+ KB\)\./)).toBeTruthy(); expect(screen.queryByText(/Not translated/)).toBeNull(); @@ -382,4 +393,138 @@ describe("HardwareModal", () => { await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull()); expectHeaderKept(); }); + + // The header a model translated lived in this modal's state alone. Closing + // the window any way at all, or reloading the page, emptied the box, and + // the way back was to ask the model again. + it.each([ + ["Esc", () => fireEvent.keyDown(document.activeElement!, { key: "Escape" })], + ["a press outside", () => fireEvent.click(hardware())], + ["×", () => fireEvent.click(screen.getByRole("button", { name: "Close" }))], + ])("opens again with the header it held before %s", (_, close) => { + render(); + pasteHeader(); + close(); + expect(screen.getByText("closed")).toBeTruthy(); + cleanup(); + + render(); + expect(headerBox()).toHaveValue(HEADER); + expect(screen.getByText("Looks like a header ✓")).toBeTruthy(); + expect(screen.getByText("Next →")).toBeEnabled(); + }); + + it("opens again with the header after a reload", () => { + const lab = useLabStore.getState(); + const starter = lab.layers[0].id; + lab.addCodeLayerFromCode(CODE, "Dot"); + useLabStore.getState().removeLayer(starter); + render(); + pasteHeader(); + cleanup(); + + // What the lab's autosave writes, read back the way an open reads it. + const state = useLabStore.getState(); + const saved = serializeProject({ + name: state.name, + matrix: state.matrix, + layers: state.layers, + activeLayerId: state.activeLayerId, + knobs: state.knobs, + ranges: state.ranges, + knobLabels: state.knobLabels, + forkOf: state.forkOf, + editOf: state.editOf, + gen: state.gen, + director: state.director, + }); + state.discardProject(); + localStorage.setItem(PROJECT_STORAGE, saved!); + useLabStore.setState({ hydrated: false }); + useLabStore.getState().hydrate(); + expect(useLabStore.getState().layers.map((layer) => layer.name)).toEqual(["Dot"]); + + render(); + expect(headerBox()).toHaveValue(HEADER); + }); + + it("opens a stack again with the layers already translated", () => { + useLabStore.getState().addCodeLayer(); + render(); + fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } }); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + cleanup(); + + render(); + expect(screen.getByText(/2 layers to translate — 1 done/)).toBeTruthy(); + expect(screen.getByPlaceholderText(/Paste the namespace L1 \{/)).toHaveValue(unitBlock(1)); + expect(screen.getByPlaceholderText(/Paste the namespace L0 \{/)).toHaveValue(""); + expect(screen.getByText("Next →")).toBeEnabled(); + }); + + // A knob position only says where the board starts that knob. Asking the + // model again for it would cost more than it fixes. + const turnKnob = () => { + const [min, max] = useLabStore.getState().ranges[0]; + const knob = useLabStore.getState().knobs[0]; + useLabStore.getState().setKnob(0, knob === max ? min : max); + expect(useLabStore.getState().knobs[0]).not.toBe(knob); + }; + + it("keeps the header when a knob turns", () => { + render(); + pasteHeader(); + cleanup(); + + turnKnob(); + render(); + expect(headerBox()).toHaveValue(HEADER); + }); + + // Each layer's prompt in a stack lists the knobs with where they sit now. + it("keeps a stack's translations when a knob turns", () => { + useLabStore.getState().addCodeLayer(); + render(); + fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } }); + cleanup(); + + turnKnob(); + render(); + expect(screen.getByPlaceholderText(/Paste the namespace L1 \{/)).toHaveValue(unitBlock(1)); + }); + + // The header answers one prompt. Once the lab asks another, it would put + // the old pattern, frame or colours on the board while saying ready. + it.each([ + ["the code", (id: string) => { + const layer = useLabStore.getState().layers.find((each) => each.id === id); + if (layer?.type !== "code") throw new Error("not code"); + useLabStore.getState().updateLayerCode(id, `${layer.code}\n// tuned`); + }], + ["the ramp mode", (id: string) => { + const layer = useLabStore.getState().layers.find((each) => each.id === id); + if (layer?.type !== "code") throw new Error("not code"); + useLabStore.getState().setLayerRampMode(id, layer.ramp.mode === "step" ? "linear" : "step"); + }], + ["the matrix", () => useLabStore.getState().setMatrix({ width: 64, height: 64 })], + ])("opens empty once %s changes, and keeps nothing for it", (_, change) => { + render(); + pasteHeader(); + cleanup(); + + change(useLabStore.getState().layers[0].id); + render(); + expect(headerBox()).toHaveValue(""); + expect(screen.getByText("Next →")).toBeDisabled(); + }); + + it("forgets a header the box was emptied of", () => { + render(); + pasteHeader(); + fireEvent.change(headerBox(), { target: { value: "" } }); + cleanup(); + + render(); + expect(headerBox()).toHaveValue(""); + }); }); diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index c9cd18e82..ebbd53dc5 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -12,6 +12,7 @@ import { enforcePatternName, } from "@/lib/lab/hExport"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; +import { promptKey, readPastes, writePastes } from "@/lib/lab/hardwarePastes"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; import { loneCodeLayer } from "@/lib/lab/flatten"; import { labPatternName, useLabStore } from "@/lib/lab/store"; @@ -53,8 +54,6 @@ export default function HardwareModal({ // Seeded from the lab's pattern name so the .h NAME, the .pfm slug and the // Director's opening pattern cue all agree without retyping. const [name, setName] = useState(() => labPatternName(useLabStore.getState())); - const [pastes, setPastes] = useState>({}); - const [single, setSingle] = useState(""); const [copied, flashCopied] = useResettingState<{ key: string; ok: boolean } | null>(null, 1200); const [stage, setStage] = useState("convert"); @@ -91,6 +90,58 @@ export default function HardwareModal({ }); }, [lone, matrix, knobs, ranges, knobLabels, name]); + // What the model answered is kept for the tab under the prompt it answers + // (hardwarePastes.ts), so Esc, a press outside or a reload no longer sends + // the person back to ask again. The single key is the prompt without the + // name, which assembly stamps on again, and without the knob positions, + // which the key leaves out anyway. + const singleKey = useMemo( + () => + lone + ? promptKey( + buildCppPrompt({ + code: lone.code, + matrix, + knobs: [], + ranges, + knobLabels, + ramp: lone.ramp, + recolor: lone.recolor, + }), + ) + : "", + [lone, matrix, ranges, knobLabels], + ); + const unitKeys = useMemo( + () => exportData.units.map((unit) => [unit.index, promptKey(unit.prompt)] as const), + [exportData.units], + ); + + const [single, setSingle] = useState(() => (lone ? (readPastes()[singleKey] ?? "") : "")); + const [pastes, setPastes] = useState>(() => { + const kept = readPastes(); + const restored: Record = {}; + for (const [index, key] of unitKeys) { + if (kept[key]) restored[index] = kept[key]; + } + return restored; + }); + + // Keeps what the boxes hold now and nothing else, so a paste whose prompt + // the lab no longer asks goes with the next edit. + const keep = (nextSingle: string, nextPastes: Record) => { + const kept: Record = {}; + if (lone) { + if (nextSingle.trim()) kept[singleKey] = nextSingle; + } else { + for (const [index, key] of unitKeys) { + const text = nextPastes[index] ?? ""; + if (text.trim()) kept[key] = text; + } + } + writePastes(kept); + }; + // A chat answer pasted whole used to go into the .h with the model's words // and the ``` fence, and the header test below passed it. const singleHeader = useMemo(() => cleanPastedHeader(single), [single]); @@ -160,8 +211,8 @@ export default function HardwareModal({ const label = lone?.name ?? name; // Only a press on this backdrop itself closes. Send and Publish open inside - // it, so a press on their backdrop bubbles up here too, and the header - // pasted above lives in this component's state and would go with it. + // it, so a press on their backdrop bubbles up here too, and closing one of + // them would close this window under it. const closeOnBackdrop = (event: MouseEvent) => { if (event.target === event.currentTarget) onClose(); }; @@ -270,9 +321,11 @@ export default function HardwareModal({