diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx index 15e5a536a..87f2d192d 100644 --- a/web/src/app/pattern-lab/HardwareModal.test.tsx +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -155,6 +155,70 @@ describe("HardwareModal", () => { } }); + // A chat answer copied whole carries the model's words and the ``` fence + // around the code. The box used to pass it as a header, because the check + // found #pragma once on any line, and the words went into the .h whole. + it("keeps only the header when a whole chat answer is pasted", async () => { + const header = [ + "// SPDX-License-Identifier: CC-BY-SA-4.0", + "// Pattern: Wave", + "#pragma once", + "namespace LabWave {", + ' const char* NAME = "Wave";', + " void draw() {}", + "} // namespace LabWave", + ].join("\n"); + const reply = `Here is the converted header:\n\n\`\`\`cpp\n${header}\n\`\`\`\n\nLet me know if you want the colours warmer.`; + const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + try { + render(); + fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), { + target: { value: reply }, + }); + expect(screen.getByText(/Looks like a header ✓/)).toHaveTextContent("The words around it stay out of the .h."); + + fireEvent.click(screen.getByText("Copy .h")); + expect(await screen.findByText("Copied ✓")).toBeTruthy(); + const name = useLabStore.getState().layers[0].name; + expect(writeText).toHaveBeenCalledWith(header.replace('"Wave"', `"${name}"`)); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + + it("finds no header in an answer whose #pragma once is in a sentence", () => { + render(); + fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), { + target: { value: "Sure. The header starts with #pragma once and ends with the namespace." }, + }); + expect(screen.getByText("A Patternflow header starts with #pragma once")).toBeTruthy(); + expect(screen.getByText("Copy .h")).toBeDisabled(); + expect(screen.getByText("Next →")).toBeDisabled(); + }); + + // Copy .h used to be the one button open on an empty box. It put nothing + // on the clipboard and said Copied ✓. + it("keeps Copy .h shut until a header is pasted", () => { + const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + try { + render(); + expect(screen.getByText("Copy .h")).toBeDisabled(); + fireEvent.click(screen.getByText("Copy .h")); + expect(writeText).not.toHaveBeenCalled(); + expect(screen.queryByText("Copied ✓")).toBeNull(); + + fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), { + target: { value: HEADER }, + }); + expect(screen.getByText("Looks like a header ✓")).toBeTruthy(); + expect(screen.getByText("Copy .h")).toBeEnabled(); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + // Text and pixel layers are in the scaffold as pixels, so with no code // layer the header is whole. The window used to count "0 layers to // translate — 0 done" and send the person to a model anyway. diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index 9f0542ece..bb080ff5b 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -4,7 +4,13 @@ import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { PublishModal, SendModuleModal, buildsConfigured, communityConfigured } from "./community"; import { useDialog } from "@/lib/hooks/useDialog"; import { useResettingState } from "@/lib/hooks/useResettingState"; -import { assembleH, buildHExport, cleanPastedUnit, enforcePatternName } from "@/lib/lab/hExport"; +import { + assembleH, + buildHExport, + cleanPastedHeader, + cleanPastedUnit, + enforcePatternName, +} from "@/lib/lab/hExport"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; import { needsFlatten } from "@/lib/lab/flatten"; @@ -85,14 +91,21 @@ export default function HardwareModal({ }); }, [layers, activeLayerId, codeLayers, matrix, knobs, ranges, knobLabels, name]); + // 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]); + const wordsLeftOut = singleHeader !== null && singleHeader !== single.trim(); + // Single path: the model writes NAME itself, so stamp the lab's name back // on (the scaffold path already carries it — buildHExport bakes it in). const assembled = useMemo( () => stacked ? assembleH(exportData.scaffold, pastes) - : enforcePatternName(single.trim(), name), - [stacked, exportData.scaffold, pastes, single, name], + : singleHeader === null + ? "" + : enforcePatternName(singleHeader, name), + [stacked, exportData.scaffold, pastes, singleHeader, name], ); // Text and pixel layers go into the scaffold as the pixels they show, so @@ -287,13 +300,18 @@ export default function HardwareModal({ {headerLooksReal ? "Looks like a header ✓" : "A Patternflow header starts with #pragma once"} + {wordsLeftOut ? " The words around it stay out of the .h." : null}

)} )}
-