From 6b7d7e4ecdea9e269e19b9242a243ce6be953d2e Mon Sep 17 00:00:00 2001 From: Bernhard Reiter Date: Fri, 4 Sep 2026 12:31:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(werkstoff):=20L=C3=A4nge=20vor=20Magic,=20R?= =?UTF-8?q?egelbetrieb-Pin,=20Bundle-W=C3=A4chter=20vor=20den=20Tests=20(N?= =?UTF-8?q?achzieh=20craft#48=20+=20cody-cad#71=20S2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - glbKnoten: eine Datei unter 20 Bytes ist »zu kurz für einen Header (N Bytes, mindestens 20)« — auch mit gültigem Magic, und VOR der Magic-Prüfung. Bisher hieß beides »Magic fehlt«; bei einem abgebrochenen Download sah der Handwerker die falsche Ursache. Parität zu cody-cad#71 (Fälle 0/3/12/19 → zu kurz, 20 → leerer JSON-Chunk). - panel.test.tsx: Regelbetrieb-Pin — das ausgelieferte Bundle unverändert durch die Seite: Szene mit URL und Lücke, kein Fehlersatz (R48b-5). Veralteter Kommentar (»Modell nicht in git«) korrigiert. - build-demo-bundle.sh: der Auftrag↔Modell-Wächter steht jetzt VOR pnpm test — eigenes Schloss, nicht das zweite an derselben Tür (R48b-4). Beweis: Red-First — vier »zu kurz«-Fälle rot vor dem Fix (Meldung »Magic«); Saboteur »Magic vor Länge« (Byte-Vergleich) → genau 0-Bytes + 3-Bytes rot; Saboteur »ein Zeichen im ausgelieferten glb_sha256« → Regelbetrieb-Pin rot (+ bundle.test.ts, + Knoten-Zweig-Test, der dasselbe Paar braucht), der Fixture-Test bleibt grün — der Pin hält den Weg des Menschen, nicht das Fixture. Werkstoff 88/88 (vorher 82), Suite 452 + 24 Altbestand, tsc 0, eslint 0. Nicht enthalten: R48b-6 (»Demo-Plan ohne Bohrungen« als getragene Lücke im Auftrag) — Vertragsänderung in drei Repos, Vorschlag im PR. Co-Authored-By: Claude Fable 5.1 --- .../app/[locale]/werkstoff/panel.test.tsx | 16 +++++++- apps/tischler/lib/werkstoff/auftrag.test.ts | 37 +++++++++++++++++++ apps/tischler/lib/werkstoff/auftrag.ts | 7 +++- scripts/build-demo-bundle.sh | 9 +++-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/tischler/app/[locale]/werkstoff/panel.test.tsx b/apps/tischler/app/[locale]/werkstoff/panel.test.tsx index 0cddbaf..02e7e52 100644 --- a/apps/tischler/app/[locale]/werkstoff/panel.test.tsx +++ b/apps/tischler/app/[locale]/werkstoff/panel.test.tsx @@ -132,6 +132,19 @@ describe("Werkstoff-Panel: der Auftrag bestimmt, welche Bretter es gibt", () => }); describe("Werkstoff-Panel: die Szene zeigt nur, was zum Auftrag passt", () => { + it("Regelbetrieb: das ausgelieferte Bundle, unverändert — die Szene erscheint, mit der Lücke, ohne Fehlersatz", async () => { + // Der Weg des Menschen: Seite auf, Bundle so, wie es im Repo liegt. Kein verbogener Auftrag, + // kein eingesetztes Fixture — das ausgelieferte Paar (auftrag.json ↔ modell.glb) muss durch die + // Prüfung der Seite kommen. Gemessen war das (craft#48), gehalten hat es kein Test (R48b-5). + vi.stubGlobal("fetch", stub()); + render(); + const szene = await waitFor(() => screen.getByTestId("szene")); + expect(szene.getAttribute("data-url")).toBe("/werkstoff-bundle/modell.glb"); + expect(szene.getAttribute("data-luecken")).toBe("teil:Rw"); + expect(document.body.textContent).not.toMatch(/⚠|anderes Erzeugnis|Kein 3D-Modell/); + expect(screen.getAllByRole("button", { name: /^(Bo|Se):/ })).toHaveLength(4); + }); + it("nennt der Auftrag kein Modell: Schaltflächen bleiben, und der Satz sagt es", async () => { vi.stubGlobal( "fetch", @@ -156,7 +169,8 @@ describe("Werkstoff-Panel: die Szene zeigt nur, was zum Auftrag passt", () => { "fetch", stub((url, roh) => (url.endsWith("modell.glb") ? roh : roh)), ); - // Das echte Bundle trägt lokal ein Modell (nicht in git); der Test nimmt das Fixture. + // Das ausgelieferte Modell liegt im Repo (Regelbetrieb-Test oben). Hier nimmt der Test bewusst + // das Mini-Fixture und setzt dessen Hash in den Auftrag: der Zweig »Tap in der Szene → Karte«. const buf = new Uint8Array(readFileSync(join(__dirname, "../../../lib/werkstoff/fixtures/demo-mini.glb"))).buffer; const hash = createHash("sha256").update(new Uint8Array(buf)).digest("hex"); const f = vi.mocked(globalThis.fetch); diff --git a/apps/tischler/lib/werkstoff/auftrag.test.ts b/apps/tischler/lib/werkstoff/auftrag.test.ts index da674e2..622da63 100644 --- a/apps/tischler/lib/werkstoff/auftrag.test.ts +++ b/apps/tischler/lib/werkstoff/auftrag.test.ts @@ -232,6 +232,43 @@ describe("glbKnoten — was das 3D-Modell an Namen trägt", () => { expect((fehler as Error).message).toMatch(muster); expect((fehler as Error).name).not.toMatch(/RangeError|SyntaxError/); }); + + // Die Trennlinie liegt bei 20 Bytes (der ganze Header). Darunter ist die Datei »zu kurz« — auch + // mit gültigem Magic; und die Länge wird VOR dem Magic geprüft, damit drei Bytes »xyz« nicht als + // »Magic fehlt« gemeldet werden. Parität zu cody-cad#71 (Länge vor Magic, Fälle 3/12/19/20). + const kopf = (n: number) => { + const b = new Uint8Array(n); + b.set([0x67, 0x6c, 0x54, 0x46].slice(0, Math.min(4, n))); // »glTF« + return b; + }; + it.each([ + ["0 Bytes", new Uint8Array(0), /zu kurz.*0 Bytes, mindestens 20/], + ["3 Bytes ohne Magic (Länge vor Magic)", Uint8Array.from([0x78, 0x79, 0x7a]), /zu kurz.*3 Bytes, mindestens 20/], + ["12 Bytes mit gültigem Magic", kopf(12), /zu kurz.*12 Bytes, mindestens 20/], + ["19 Bytes mit gültigem Magic (eins unter der Linie)", kopf(19), /zu kurz.*19 Bytes, mindestens 20/], + ])("»%s« ist »zu kurz«, nicht »Magic fehlt«", (_name, bytes, muster) => { + let fehler: unknown; + try { + glbKnoten(bytes.buffer); + } catch (e) { + fehler = e; + } + expect(fehler).toBeInstanceOf(Error); + expect((fehler as Error).message).toMatch(muster); + expect((fehler as Error).message).not.toMatch(/Magic/); + expect((fehler as Error).name).not.toMatch(/RangeError/); + }); + + it("20 Bytes — genau der Header mit leerem JSON-Chunk — sind nicht »zu kurz«: der Fehler ist der leere Chunk", () => { + const b = kopf(20); + const dv = new DataView(b.buffer); + dv.setUint32(4, 2, true); + dv.setUint32(8, 20, true); + dv.setUint32(12, 0, true); + dv.setUint32(16, 0x4e4f534a, true); + expect(() => glbKnoten(b.buffer)).toThrow(/unlesbar/); + expect(() => glbKnoten(b.buffer)).not.toThrow(/zu kurz/); + }); }); describe("pruefeSzene — Modell und Auftrag müssen sich decken, in beide Richtungen", () => { diff --git a/apps/tischler/lib/werkstoff/auftrag.ts b/apps/tischler/lib/werkstoff/auftrag.ts index 40375e6..88a722a 100644 --- a/apps/tischler/lib/werkstoff/auftrag.ts +++ b/apps/tischler/lib/werkstoff/auftrag.ts @@ -167,8 +167,13 @@ export function glbKnoten(buf: ArrayBuffer): SzenenKnoten { // Der ganze Header, nicht nur das Magic (Review craft#47 Runde 2): Version, Gesamtlänge, // Chunk-Typ, Chunk-Länge in beide Richtungen — jede Lüge ist ein Klartext-Fehler, nie ein // RangeError oder SyntaxError aus der Tiefe. + // Länge VOR Magic: eine Datei unter 20 Bytes ist »zu kurz«, auch mit gültigem Magic — sonst + // sieht der Handwerker bei einem abgebrochenen Download die falsche Ursache (cody-cad#71, R68-3). + if (buf.byteLength < 20) { + throw new Error(`glTF-Binary zu kurz für einen Header (${buf.byteLength} Bytes, mindestens 20)`); + } const dv = new DataView(buf); - if (buf.byteLength < 20 || dv.getUint32(0, true) !== 0x46546c67) { + if (dv.getUint32(0, true) !== 0x46546c67) { throw new Error("Kein glTF-Binary (Magic »glTF« fehlt)"); } const version = dv.getUint32(4, true); diff --git a/scripts/build-demo-bundle.sh b/scripts/build-demo-bundle.sh index dd07a38..01dd66a 100755 --- a/scripts/build-demo-bundle.sh +++ b/scripts/build-demo-bundle.sh @@ -18,10 +18,7 @@ echo "▸ deps + engine build" pnpm install --prefer-offline pnpm --filter @craft-codex/core build -echo "▸ tests (Abbruch bei rot — kein kaputtes Bundle auf den Stick)" -pnpm test - -echo "▸ Werkstoff-Bundle: das Modell im Auftrag muss die Datei im Bundle sein (sonst zeigt die Seite 404 statt Möbel)" +echo "▸ Werkstoff-Bundle: das Modell im Auftrag muss die Datei im Bundle sein (sonst zeigt die Seite 404 statt Möbel) — VOR den Tests: eigenes Schloss, nicht das zweite an derselben Tür" WB="$ROOT/apps/tischler/public/werkstoff-bundle" if [ -f "$WB/auftrag.json" ] && grep -q '"modell"' "$WB/auftrag.json"; then SOLL=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['modell']['glb_sha256'])" "$WB/auftrag.json") @@ -31,6 +28,10 @@ if [ -f "$WB/auftrag.json" ] && grep -q '"modell"' "$WB/auftrag.json"; then [ "$IST" = "$SOLL" ] || { echo "✗ $DATEI hat Hash $IST, der Auftrag nennt $SOLL — anderes Erzeugnis"; exit 1; } echo " ✓ $DATEI = $SOLL" fi + +echo "▸ tests (Abbruch bei rot — kein kaputtes Bundle auf den Stick)" +pnpm test + echo "▸ production build" (cd apps/tischler && pnpm build)