From a86816b816acc9852a6491fe8f114e616d39a2df Mon Sep 17 00:00:00 2001 From: Jan Jaap Date: Fri, 7 Aug 2026 11:56:49 +0200 Subject: [PATCH] feat(profiles): emit printer printable_area in /profiles/bundled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bambuddy has no way to learn a printer's bed size (bambuddy#67 blocker table): `/profiles/bundled` returned printers as `{name, base_id}`, and every other candidate source — the Printer DB model, PRINTER_MODEL_MAP, the preset-name parser — carries no geometry either. The only bed dimensions Bambuddy ever reads today come out of a 3MF, which is why an STL and a bedless 3MF both render on a hardcoded 256x256 plate. Emit each bundled machine preset's `printable_area`, resolved through the same `resolveProfile` inherits walk PR #2/#3 built for filament metadata. No second walker: printers get the depth cap, the cycle termination and the declared-name index (for parents whose file basename sanitizes a slash) for free. The walk is what makes it work. Measured against the two bundled trees the sidecar images carry, the leaf states `printable_area` for only 4/44 presets in SoftFever/OrcaSlicer@v2.3.2 and 7/56 in bambulab/BambuStudio@v02.07.01.57; through the walk it resolves for 44/44 and 56/56. The key is spelled identically in both trees and carries the same corner-point shape in both — measured, not assumed, since the two slicers genuinely disagree on ~330 setting keys. Emitted raw as the declared point list, not reduced to width/height. A reduction would silently flatten the outlines that are not origin-anchored rectangles, and those exist: across OrcaSlicer's full vendor tree 582 profiles declare 4 points, but 8 declare 72 (round delta beds), 3 declare 6 and 3 declare 239. Only the container shape is normalised — a polygon written as one comma-joined string is split (Creality Ender-5 Max) and stray whitespace inside a point is trimmed (`"0x256 "` on Bambu Lab X2D) — never the geometry. Degrade rules follow PR #3: `null` rather than a fabricated value, and the degrade path is logged. `null` means "absent" and is never "a bed of size zero"; fewer than three usable points degrades to `null` rather than reaching the consumer as a zero-area bed. The per-cache-fill count line now reports `printable_area N`, which is what distinguishes "deployment still on an old sidecar" from "the resolver broke" — the same diagnosability gap that cost bambuddy#47 and #51 a ticket each. Also verified and deliberately NOT implemented: `bed_exclude_area` exists under that spelling in both trees and resolves for the same presets. It is non-empty for 20 presets in each (the 18x28 nozzle-wipe corner on P1P/P1S/X1/X1 Carbon/X1E) and empty for every H2D variant. Refs bambuddy#68, bambuddy#67 Co-Authored-By: Claude Opus 5 --- src/routes/profiles/route.ts | 178 +++++++++++-- tests/e2e/bundled-printer-bed.spec.ts | 354 ++++++++++++++++++++++++++ 2 files changed, 517 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/bundled-printer-bed.spec.ts diff --git a/src/routes/profiles/route.ts b/src/routes/profiles/route.ts index 066cef7..9dba4d9 100644 --- a/src/routes/profiles/route.ts +++ b/src/routes/profiles/route.ts @@ -19,6 +19,7 @@ import { AppError } from "../../middleware/error"; import { getDefaultBundledProfilesPath, resolveProfile, + type ProfileCategory, } from "../slicing/profile-resolver"; const router = Router(); @@ -56,9 +57,34 @@ type BundledFilament = { // lets Bambuddy group/filter the Standard tier by brand. filament_vendor: string | null; }; +type BundledBase = { name: string; base_id: string | null }; +type BundledPrinter = BundledBase & { + // The printer's bed outline, as the polygon the profile tree declares: + // a list of `"x"` corner points in bed millimetres, e.g. + // `["0x0","256x0","256x256","0x256"]`. **Not** a width/height pair. + // + // Like `filament_type`, it is almost never on the leaf. A concrete BBL + // machine preset is a per-nozzle delta (`Bambu Lab H2D 0.4 nozzle`), and + // the bed is declared once on a shared base such as + // `fdm_bbl_3dp_002_common`. Measured against both bundled trees, the leaf + // states it for 4/44 presets in OrcaSlicer v2.3.2 and 7/56 in BambuStudio + // v02.07.01.57 — everything else sits one or two levels up the `inherits:` + // chain. Reading only the leaf would report `null` for ~90% of the tier. + // + // **Emitted raw, deliberately.** Reducing it here to `{width, height}` + // would silently flatten the shapes that are not axis-aligned rectangles + // anchored at the origin, and those exist: across OrcaSlicer's full vendor + // tree, 582 profiles declare a 4-point outline but 8 declare 72 points + // (round delta beds), 3 declare 6 and 3 declare 239. The consumer knows + // what it needs — a bounding box, a render outline — and can decide. + // + // `null` means the whole chain stated nothing usable. It is NOT "a bed of + // size zero", and consumers must keep the two apart. + printable_area: string[] | null; +}; type BundledIndex = { - printer: { name: string; base_id: string | null }[]; - process: { name: string; base_id: string | null }[]; + printer: BundledPrinter[]; + process: BundledBase[]; filament: BundledFilament[]; }; let bundledIndexCache: BundledIndex | null = null; @@ -83,10 +109,19 @@ router.get("/bundled", async (_req, res) => { } const result: BundledIndex = { - printer: await readBundledDir(path.join(bundledPath, "machine"), null), - process: await readBundledDir(path.join(bundledPath, "process"), null), + printer: (await readBundledDir( + path.join(bundledPath, "machine"), + "machine", + bundledPath, + )) as BundledPrinter[], + process: await readBundledDir( + path.join(bundledPath, "process"), + "process", + null, + ), filament: (await readBundledDir( path.join(bundledPath, "filament"), + "filament", bundledPath, )) as BundledFilament[], }; @@ -97,9 +132,10 @@ router.get("/bundled", async (_req, res) => { // the tier. This line is what makes that regression self-diagnosing. const resolved = (key: keyof BundledFilament) => result.filament.filter((f) => f[key] !== null).length; + const beds = result.printer.filter((p) => p.printable_area !== null).length; console.info( `[profiles/bundled] listing built: ${result.printer.length} printer, ${result.process.length} process, ${result.filament.length} filament ` + - `(filament_type ${resolved("filament_type")}, filament_vendor ${resolved("filament_vendor")}, filament_colour ${resolved("filament_colour")} resolved)`, + `(printable_area ${beds}, filament_type ${resolved("filament_type")}, filament_vendor ${resolved("filament_vendor")}, filament_colour ${resolved("filament_colour")} resolved)`, ); bundledIndexCache = result; @@ -110,14 +146,17 @@ router.get("/bundled", async (_req, res) => { /** * List one bundled category. * - * `bundledProfilesPath` is non-null only for the filament directory, where it - * is the root the `inherits:` walk resolves parents against. Printer and - * process listings need no metadata, so they never pay for the walk. + * `bundledProfilesPath` is non-null for the categories that carry resolved + * metadata — filament (`filament_type` / `filament_colour` / `filament_vendor`) + * and machine (`printable_area`) — and is the root the `inherits:` walk + * resolves parents against. The process listing needs no metadata, so it never + * pays for the walk. */ async function readBundledDir( dir: string, + category: ProfileCategory, bundledProfilesPath: string | null, -): Promise<({ name: string; base_id: string | null } | BundledFilament)[]> { +): Promise<(BundledBase | BundledPrinter | BundledFilament)[]> { if (!fs.existsSync(dir)) return []; let entries: string[]; try { @@ -125,27 +164,33 @@ async function readBundledDir( } catch { return []; } - const out: ({ name: string; base_id: string | null } | BundledFilament)[] = []; + const out: (BundledBase | BundledPrinter | BundledFilament)[] = []; for (const entry of entries) { if (!entry.endsWith(".json")) continue; const filePath = path.join(dir, entry); try { const raw = await fs.promises.readFile(filePath, "utf8"); - const json = JSON.parse(raw) as RawFilamentFields & { - instantiation?: string; - }; + const json = JSON.parse(raw) as RawFilamentFields & + RawPrinterFields & { + instantiation?: string; + }; // Bundled profiles ship a mix of concrete presets and abstract bases // (e.g. `fdm_filament_pla`). Skip the latter so the slicer modal only // offers things a user can actually pick. `instantiation:"true"` is the // BBL convention for "this is a leaf preset". if (json.instantiation && json.instantiation !== "true") continue; if (!json.name) continue; - const base = { name: json.name, base_id: json.inherits ?? null }; - if (bundledProfilesPath) { + const base: BundledBase = { name: json.name, base_id: json.inherits ?? null }; + if (bundledProfilesPath && category === "filament") { out.push({ ...base, ...(await filamentMetadata(json, bundledProfilesPath)), }); + } else if (bundledProfilesPath && category === "machine") { + out.push({ + ...base, + ...(await printerMetadata(json, bundledProfilesPath)), + }); } else { out.push(base); } @@ -259,6 +304,109 @@ function firstScalar(value: string | string[] | undefined): string | null { return null; } +type RawPrinterFields = { + name?: string; + inherits?: string; + printable_area?: unknown; +}; + +/** + * `printable_area` for one bundled machine preset, following `inherits:` when + * the leaf does not state it. + * + * Same walk (`resolveProfile`) and the same degrade contract as + * `filamentMetadata` — the machinery is shared on purpose; a second, subtly + * different walker is how the leaf-only bug got shipped twice. + * + * Verified against both bundled trees at the versions the sidecar images + * carry, `SoftFever/OrcaSlicer@v2.3.2` and `bambulab/BambuStudio@v02.07.01.57`: + * the key is spelled `printable_area` in both, carries the same + * `["x", ...]` corner-point shape in both, and resolves for **every** + * instantiable `type: "machine"` preset in both (44/44 and 56/56) — but only + * through the walk. The two trees genuinely disagree on ~330 setting keys, so + * that agreement was measured rather than assumed. + * + * The listing also contains `type: "machine_model"` catalogue entries + * ("Bambu Lab H2D" with no nozzle suffix). Those describe a printer FAMILY, + * not a slicing preset, and declare no bed at any point in their chain. They + * report `null`, which is the honest answer: there is no bed to report, and + * inventing the 0.4-nozzle variant's would be a fabricated value. + */ +async function printerMetadata( + leaf: RawPrinterFields, + bundledProfilesPath: string, +): Promise<{ printable_area: string[] | null }> { + let fields: RawPrinterFields = leaf; + const needsWalk = + typeof leaf.inherits === "string" && + leaf.inherits.length > 0 && + pointListOf(leaf.printable_area) === null; + if (needsWalk) { + try { + fields = (await resolveProfile({ ...leaf }, "machine", { + bundledProfilesPath, + })) as RawPrinterFields; + } catch (err) { + // Same trade as the filament walk: one printer loses its bed rather + // than the caller losing the entire Standard tier — but say so. A + // systematic resolution failure would otherwise present as every + // printer quietly reporting `null`, which is exactly the shape of a + // deployment still running an old sidecar, and the two would be + // indistinguishable from the outside. + console.warn( + `[profiles/bundled] inherits walk failed for printer preset "${leaf.name ?? ""}" (inherits="${leaf.inherits}"); falling back to leaf-only metadata: ${err instanceof Error ? err.message : String(err)}`, + ); + fields = leaf; + } + } + return { printable_area: pointListOf(fields.printable_area) }; +} + +/** + * Normalise a declared bed outline to a list of corner-point strings. + * + * **Container shape only — never geometry.** Every point the profile declares + * survives, in order. What is normalised is the packaging, which the bundled + * trees are not consistent about: + * + * - Almost every profile writes a JSON array of `"x"` strings. + * - At least one writes the whole polygon as a single comma-joined string + * (`"0x0,400x0,400x400,0x400"` — Creality Ender-5 Max, OrcaSlicer's + * Creality vendor tree). Left alone, that reaches the consumer as an + * un-splittable scalar. + * - At least one has a stray trailing space inside a point + * (`"0x256 "` — `Bambu Lab X2D 0.4 nozzle`, BambuStudio). Left alone, a + * consumer parsing `parseFloat` per axis mostly survives it and a + * consumer doing strict parsing does not. + * + * Fewer than three usable points is not a polygon, so it degrades to `null` + * (with a warning) rather than reaching the consumer as a bed whose bounding + * box happens to be zero-sized. `null` must keep meaning "absent", never + * "0 × 0". + */ +function pointListOf(value: unknown): string[] | null { + const raw = Array.isArray(value) + ? value + : typeof value === "string" + ? value.split(",") + : null; + if (raw === null) return null; + const points: string[] = []; + for (const p of raw) { + if (typeof p !== "string") continue; + const trimmed = p.trim(); + if (trimmed.length > 0) points.push(trimmed); + } + if (points.length === 0) return null; + if (points.length < 3) { + console.warn( + `[profiles/bundled] ignoring printable_area with ${points.length} usable point(s) — a bed outline needs at least 3: ${JSON.stringify(value)}`, + ); + return null; + } + return points; +} + // Bundle routes are defined before /:category so the literal "bundle" / // "bundles" path segments don't get matched as a category by the more // generic handlers below (validateCategory would reject them). diff --git a/tests/e2e/bundled-printer-bed.spec.ts b/tests/e2e/bundled-printer-bed.spec.ts new file mode 100644 index 0000000..8abb143 --- /dev/null +++ b/tests/e2e/bundled-printer-bed.spec.ts @@ -0,0 +1,354 @@ +/** + * `GET /profiles/bundled` must report each printer preset's **bed outline**. + * + * A concrete BBL machine profile is a thin per-nozzle delta — `Bambu Lab H2D + * 0.4 nozzle` is barely more than `{name, inherits, nozzle_diameter}` — and + * the bed is declared once, further up the `inherits:` chain, on a shared base + * such as `fdm_bbl_3dp_002_common`. Measured against the real bundled trees, + * the leaf states `printable_area` for 4/44 presets in OrcaSlicer v2.3.2 and + * 7/56 in BambuStudio v02.07.01.57; through the walk it resolves for 44/44 and + * 56/56. Reading only the leaf would report `null` for ~90% of the tier, which + * is exactly the failure mode `filament_type` had (bambuddy#47) and which cost + * a ticket to localize. + * + * The fixture mirrors the real machine tree rather than a flat directory: an + * abstract root, a family base that declares the bed, an intermediate, and the + * instantiable leaf that declares nothing. + */ + +import { + describe, + it, + expect, + beforeAll, + afterAll, + vi, + type MockInstance, +} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { request } from "./setup"; + +type Entry = { name: string; base_id: string | null; printable_area: string[] | null }; + +let root: string; +let previousPath: string | undefined; + +function write(dir: string, file: string, json: Record) { + fs.writeFileSync(path.join(dir, `${file}.json`), JSON.stringify(json), "utf8"); +} + +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "bundled-printer-bed-")); + const machine = path.join(root, "machine"); + fs.mkdirSync(machine); + fs.mkdirSync(path.join(root, "filament")); + fs.mkdirSync(path.join(root, "process")); + + // ---- the ordinary case: bed two levels above the instantiable leaf ------ + write(machine, "fdm_machine_common", { + name: "fdm_machine_common", + type: "machine", + instantiation: "false", + }); + write(machine, "fdm_bbl_3dp_002_common", { + name: "fdm_bbl_3dp_002_common", + type: "machine", + inherits: "fdm_machine_common", + instantiation: "false", + printable_area: ["0x0", "350x0", "350x320", "0x320"], + }); + write(machine, "Bambu Lab H2D nozzle base", { + name: "Bambu Lab H2D nozzle base", + type: "machine", + inherits: "fdm_bbl_3dp_002_common", + instantiation: "false", + }); + write(machine, "Bambu Lab H2D 0.4 nozzle", { + name: "Bambu Lab H2D 0.4 nozzle", + type: "machine", + inherits: "Bambu Lab H2D nozzle base", + instantiation: "true", + }); + + // ---- a leaf that states its own bed: the walk must not overwrite it ----- + write(machine, "Bambu Lab X1 Carbon 0.4 nozzle", { + name: "Bambu Lab X1 Carbon 0.4 nozzle", + type: "machine", + inherits: "fdm_bbl_3dp_002_common", + instantiation: "true", + printable_area: ["0x0", "256x0", "256x256", "0x256"], + }); + + // ---- packaging warts that exist verbatim in the bundled trees ----------- + // BambuStudio v02.07.01.57 ships this one with a trailing space inside the + // last point. + write(machine, "Bambu Lab X2D 0.4 nozzle", { + name: "Bambu Lab X2D 0.4 nozzle", + type: "machine", + instantiation: "true", + printable_area: ["0x0", "256x0", "256x256", "0x256 "], + }); + // OrcaSlicer's Creality tree writes the whole polygon as one comma-joined + // string instead of an array. + write(machine, "Creality Ender-5 Max 0.4 nozzle", { + name: "Creality Ender-5 Max 0.4 nozzle", + type: "machine", + instantiation: "true", + printable_area: "0x0,400x0,400x400,0x400", + }); + + // ---- a bed that is not an origin-anchored rectangle --------------------- + // 8 profiles in OrcaSlicer's vendor tree declare 72-point round beds and 3 + // declare 6-point ones. Reducing to {width,height} here would throw the + // shape away; the contract is that every declared point survives, in order. + write(machine, "Hexagonal 0.4 nozzle", { + name: "Hexagonal 0.4 nozzle", + type: "machine", + instantiation: "true", + printable_area: ["50x0", "150x0", "200x87", "150x173", "50x173", "0x87"], + }); + + // ---- a parent whose file basename sanitizes a slash in its name --------- + // Printers go through the same declared-name index the filament walk uses, + // so this resolves for free — pinned so a regression there is caught on + // both categories rather than only on filament. + fs.writeFileSync( + path.join(machine, "Bambu Lab H2D-Pro @base.json"), + JSON.stringify({ + name: "Bambu Lab H2D/Pro @base", + type: "machine", + instantiation: "false", + printable_area: ["0x0", "350x0", "350x320", "0x320"], + }), + "utf8", + ); + write(machine, "Bambu Lab H2D Pro 0.4 nozzle", { + name: "Bambu Lab H2D Pro 0.4 nozzle", + type: "machine", + inherits: "Bambu Lab H2D/Pro @base", + instantiation: "true", + }); + + // ---- entries that legitimately have no bed ----------------------------- + // A `machine_model` catalogue entry: a printer FAMILY, not a slicing preset. + // It carries no `instantiation` key, so the listing includes it today; it + // declares no bed anywhere and must report null rather than borrow one. + write(machine, "Bambu Lab H2D", { + name: "Bambu Lab H2D", + type: "machine_model", + nozzle_diameter: "0.4;0.2;0.6;0.8", + family: "BBL-3DP", + }); + + // ---- degrade paths ------------------------------------------------------ + write(machine, "Orphan 0.4 nozzle", { + name: "Orphan 0.4 nozzle", + type: "machine", + inherits: "no_such_machine_base", + instantiation: "true", + }); + write(machine, "cycle_a", { + name: "cycle_a", + type: "machine", + inherits: "cycle_b", + instantiation: "false", + printable_area: ["0x0", "999x0", "999x999", "0x999"], + }); + write(machine, "cycle_b", { + name: "cycle_b", + type: "machine", + inherits: "cycle_a", + instantiation: "false", + }); + write(machine, "Cyclic 0.4 nozzle", { + name: "Cyclic 0.4 nozzle", + type: "machine", + inherits: "cycle_b", + instantiation: "true", + }); + fs.writeFileSync( + path.join(machine, "broken_machine_base.json"), + "{ not json", + "utf8", + ); + write(machine, "Corrupt Ancestor 0.4 nozzle", { + name: "Corrupt Ancestor 0.4 nozzle", + type: "machine", + inherits: "broken_machine_base", + instantiation: "true", + }); + // Two points is not a polygon. It must degrade to null, not to a bed whose + // bounding box happens to be 10 x 0 — `null` has to keep meaning "absent". + write(machine, "Degenerate 0.4 nozzle", { + name: "Degenerate 0.4 nozzle", + type: "machine", + instantiation: "true", + printable_area: ["0x0", "10x0"], + }); + + previousPath = process.env.BUNDLED_PROFILES_PATH; + process.env.BUNDLED_PROFILES_PATH = root; +}); + +afterAll(() => { + if (previousPath === undefined) delete process.env.BUNDLED_PROFILES_PATH; + else process.env.BUNDLED_PROFILES_PATH = previousPath; + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("GET /profiles/bundled — printer printable_area", () => { + let byName: Map; + let warnSpy: MockInstance; + let infoSpy: MockInstance; + + beforeAll(async () => { + // The listing sits behind a 1h in-process cache, so the spies must be in + // place before the one request that actually builds it. + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + const res = await request.get("/profiles/bundled").expect(200); + byName = new Map( + (res.body.printer as Entry[]).map((p) => [p.name, p] as const), + ); + }); + + afterAll(() => { + warnSpy.mockRestore(); + infoSpy.mockRestore(); + }); + + it("resolves a bed declared two levels up the inherits chain", () => { + expect(byName.get("Bambu Lab H2D 0.4 nozzle")?.printable_area).toEqual([ + "0x0", + "350x0", + "350x320", + "0x320", + ]); + }); + + it("keeps a bed the leaf states itself", () => { + expect( + byName.get("Bambu Lab X1 Carbon 0.4 nozzle")?.printable_area, + ).toEqual(["0x0", "256x0", "256x256", "0x256"]); + }); + + it("resolves through a parent whose file basename sanitizes a slash", () => { + expect(byName.get("Bambu Lab H2D Pro 0.4 nozzle")?.printable_area).toEqual([ + "0x0", + "350x0", + "350x320", + "0x320", + ]); + }); + + it("emits the polygon raw rather than reducing it to width x height", () => { + // Six points in, six points out, in order. A {width,height} reduction + // would report 200 x 173 and silently turn a hexagon into a rectangle. + expect(byName.get("Hexagonal 0.4 nozzle")?.printable_area).toEqual([ + "50x0", + "150x0", + "200x87", + "150x173", + "50x173", + "0x87", + ]); + }); + + it("trims stray whitespace inside a declared point", () => { + expect(byName.get("Bambu Lab X2D 0.4 nozzle")?.printable_area).toEqual([ + "0x0", + "256x0", + "256x256", + "0x256", + ]); + }); + + it("splits a polygon written as one comma-joined string", () => { + expect( + byName.get("Creality Ender-5 Max 0.4 nozzle")?.printable_area, + ).toEqual(["0x0", "400x0", "400x400", "0x400"]); + }); + + it("reports null for a machine_model catalogue entry", () => { + const entry = byName.get("Bambu Lab H2D"); + expect(entry).toHaveProperty("printable_area"); + expect(entry?.printable_area).toBeNull(); + }); + + it("degrades to null on a dangling parent instead of failing the listing", () => { + expect(byName.get("Orphan 0.4 nozzle")?.printable_area).toBeNull(); + }); + + it("degrades a cyclic chain to null rather than a bed found inside the cycle", () => { + // cycle_a states a 999 x 999 bed. A walk that blew the depth cap resolved + // nothing, so reporting that value would be a fabricated answer. + expect(byName.get("Cyclic 0.4 nozzle")?.printable_area).toBeNull(); + }); + + it("degrades a corrupt ancestor to null", () => { + expect(byName.get("Corrupt Ancestor 0.4 nozzle")?.printable_area).toBeNull(); + }); + + it("degrades a polygon with fewer than three points to null", () => { + expect(byName.get("Degenerate 0.4 nozzle")?.printable_area).toBeNull(); + }); + + it("still returns every printer preset when some chains fail to resolve", () => { + expect([...byName.keys()].sort()).toEqual([ + "Bambu Lab H2D", + "Bambu Lab H2D 0.4 nozzle", + "Bambu Lab H2D Pro 0.4 nozzle", + "Bambu Lab X1 Carbon 0.4 nozzle", + "Bambu Lab X2D 0.4 nozzle", + "Corrupt Ancestor 0.4 nozzle", + "Creality Ender-5 Max 0.4 nozzle", + "Cyclic 0.4 nozzle", + "Degenerate 0.4 nozzle", + "Hexagonal 0.4 nozzle", + "Orphan 0.4 nozzle", + ]); + }); + + it("warns when a printer's inherits walk degrades to leaf-only metadata", () => { + const warnings = warnSpy.mock.calls.map((c) => String(c[0])); + expect( + warnings.some( + (m) => + m.includes("inherits walk failed for printer preset") && + m.includes("Cyclic 0.4 nozzle"), + ), + ).toBe(true); + expect( + warnings.some( + (m) => + m.includes("inherits walk failed for printer preset") && + m.includes("Corrupt Ancestor 0.4 nozzle"), + ), + ).toBe(true); + }); + + it("warns when a declared printable_area is too short to be a polygon", () => { + const warnings = warnSpy.mock.calls.map((c) => String(c[0])); + expect( + warnings.some((m) => + m.includes("ignoring printable_area with 2 usable point(s)"), + ), + ).toBe(true); + }); + + it("reports how many printers resolved a bed once the listing is built", () => { + // The dangling-parent degrade is silent by design and never throws, so a + // tier-wide regression to all-null would produce no warning at all. This + // line is what makes that case diagnosable — and it is the number that + // distinguishes "old sidecar" from "resolver broken" in a deployment. + const lines = infoSpy.mock.calls.map((c) => String(c[0])); + const built = lines.find((m) => + m.includes("[profiles/bundled] listing built"), + ); + expect(built).toBeDefined(); + expect(built).toContain("11 printer"); + expect(built).toContain("printable_area 6"); + }); +});