diff --git a/packages/core/src/lib/designFile.ts b/packages/core/src/lib/designFile.ts index 2b5b2231..63a19dac 100644 --- a/packages/core/src/lib/designFile.ts +++ b/packages/core/src/lib/designFile.ts @@ -12,7 +12,7 @@ import { dbSourceRefSchema, type DbSourceRef } from "../types/DataSource"; import type { LabelObject } from "../types/Group"; import { blockOverlaySchema, overlayText, type BlockOverlay } from "./zplOverlay/overlay"; import { reconstructLegacyBlockHeads, storedFormatPathOf } from "./zplHeadScan"; -import { visitLeavesInPages, foldSerialLeaf, fixTlc39SlotsLeaf, bindSingleMarkerLeaf, safeUniqueNameById } from "./objectTree"; +import { visitLeavesInPages, foldSerialLeaf, fixTlc39SlotsLeaf, bindSingleMarkerLeaf, migrateGs1DatabarLeaf, safeUniqueNameById } from "./objectTree"; import { sanitiseLoadedVariables } from "./loadedVariables"; import { insertReverseBackingBoxes, pageNeedsReverseBacking } from "./reverseBacking"; import { ok, err } from "./result"; @@ -20,11 +20,8 @@ import { schemaIssues } from "./schemaIssues"; import { getEntry } from "../registry"; import { propDomainIssue, truncateIntegerProps, type PropSpec } from "../types/propSpec"; -/** Current design-file schema version. Bump when the persisted shape - * changes in a way an older app cannot read. Add a vN schema + - * migrator below and dispatch on `schemaVersion` in `parseDesignFile`. - * The persist middleware in `labelStore` has its own independent - * version for localStorage state; do not conflate. */ +/** Bump when an older app could not read the persisted shape, with a hop in the migration chain + * of `parseDesignFile`. The persist middleware in `labelStore` versions localStorage on its own. */ export const CURRENT_DESIGN_SCHEMA_VERSION = 6; export type DesignFileError = "parse_error" | "invalid_schema" | "fn_slots_exhausted"; @@ -129,7 +126,7 @@ export function parseDesignFile(text: string): DesignFileResult { // Valid JSON that is not an object (null, true, "x") must reject, not throw. if (!json || typeof json !== "object") return err("invalid_schema"); - migrateGs1databarModuleWidth(json); + visitLeavesInPages((json as { pages?: unknown }).pages, migrateGs1DatabarLeaf); visitLeavesInPages((json as { pages?: unknown }).pages, fixTlc39SlotsLeaf); migrateReverseTextBackground(json); migrateSerialToTextMode(json); @@ -184,27 +181,6 @@ function migrateReverseTextBackground(json: unknown): void { j.schemaVersion = 2; } -/** Rename gs1databar `props.moduleWidth` → `props.magnification` on - * legacy design files. Mirrors the persist-store v5→v6 hop in - * `labelStore.ts`. Idempotent. */ -function migrateGs1databarModuleWidth(json: unknown): void { - if (!json || typeof json !== "object") return; - const pages = (json as { pages?: unknown }).pages; - visitLeavesInPages(pages, (leaf) => { - if ( - leaf.type === "gs1databar" && - leaf.props && - typeof leaf.props === "object" && - "moduleWidth" in leaf.props && - !("magnification" in leaf.props) - ) { - const props = leaf.props as Record; - props.magnification = props.moduleWidth; - delete props.moduleWidth; - } - }); -} - /** Convert the legacy standalone `serial` object type into a `text` field with * a `serial` prop (the dissolved field-mode model). Keeps content/font/rotation * and folds increment/zplMode into `props.serial`. Idempotent. */ diff --git a/packages/core/src/lib/designOps.test.ts b/packages/core/src/lib/designOps.test.ts index ec743a76..1e87e914 100644 --- a/packages/core/src/lib/designOps.test.ts +++ b/packages/core/src/lib/designOps.test.ts @@ -42,22 +42,36 @@ describe("applyDesignOps", () => { const seen: { dpmm: number; variables: string[] }[] = []; const input = doc(); input.pages[0]!.objects.push(rightBarcode("bc")); - const result = applyDesignOps( - input, - [{ op: "addVariable", variable: { name: "qty" } }, { op: "update", id: "bc", props: { content: "«qty»" } }], - (_o, ctx) => { - seen.push({ dpmm: ctx.label.dpmm, variables: ctx.variables.map((v) => v.name) }); - return null; - }, - ); + input.variables.push(variable("qty", 2, "1")); + const result = applyDesignOps(input, [{ op: "update", id: "bc", props: { content: "«qty»" } }], (_o, ctx) => { + seen.push({ dpmm: ctx.label.dpmm, variables: ctx.variables.map((v) => v.name) }); + return null; + }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.doc.pages[0]?.objects[1]).toMatchObject({ dirty: true, props: { content: "«qty»" } }); expect([...result.edited]).toEqual([0]); + expect([...result.touched]).toEqual([]); + expect(result.doc.pages.every((p) => p.overlay === overlay)).toBe(true); expect(seen.length).toBeGreaterThan(0); expect(seen.every((s) => s.dpmm === 8 && s.variables.includes("qty"))).toBe(true); }); + it("measures under a variable the same batch adds, and that addition costs every capture", () => { + const seen: string[][] = []; + const input = doc(); + input.pages[0]!.objects.push(rightBarcode("bc")); + const result = applyDesignOps(input, [{ op: "addVariable", variable: { name: "qty" } }, { op: "update", id: "bc", props: { content: "«qty»" } }], (_o, ctx) => { + seen.push(ctx.variables.map((v) => v.name)); + return null; + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(seen.length).toBeGreaterThan(0); + expect(seen.every((names) => names.includes("qty"))).toBe(true); + expect([...result.touched]).toEqual([0, 1]); + }); + it("renames a variable through every marker without costing the captures", () => { const result = applyDesignOps(doc(), [{ op: "updateVariable", name: "lot", newName: "batch", comment: "c" }], noProbe); expect(result.ok).toBe(true); diff --git a/packages/core/src/lib/objectTree.ts b/packages/core/src/lib/objectTree.ts index 2ccb63f9..a0c9d540 100644 --- a/packages/core/src/lib/objectTree.ts +++ b/packages/core/src/lib/objectTree.ts @@ -69,6 +69,15 @@ export function fixTlc39SlotsLeaf(leaf: UnknownNode): void { } } +/** Legacy gs1databar `moduleWidth` is the ^BR magnification. */ +export function migrateGs1DatabarLeaf(leaf: UnknownNode): void { + if (leaf.type !== "gs1databar" || !leaf.props || typeof leaf.props !== "object") return; + const props = leaf.props as Record; + if (!("moduleWidth" in props) || "magnification" in props) return; + props.magnification = props.moduleWidth; + delete props.moduleWidth; +} + /** Legacy standalone `serial` object type → `text` field with a `serial` prop. */ export function foldSerialLeaf(leaf: UnknownNode): void { if (leaf.type !== "serial" || !leaf.props || typeof leaf.props !== "object") return; diff --git a/packages/core/src/lib/zplOverlay/overlay.ts b/packages/core/src/lib/zplOverlay/overlay.ts index b7b67914..eeb2eed5 100644 --- a/packages/core/src/lib/zplOverlay/overlay.ts +++ b/packages/core/src/lib/zplOverlay/overlay.ts @@ -10,8 +10,7 @@ import { z } from "zod"; -/** Key identifying a labelConfig/printerProfile field a config command sets. - * Refined to a union as config linkage lands (later stage); string for now. */ +/** The labelConfig or printerProfile field a config command sets. */ export type ConfigFieldKey = string; export type OverlaySegment = diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index cc6d4960..09a26815 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -988,6 +988,14 @@ describe('setPageStoredFormatPath', () => { }); }); +describe('migrateLegacy: v5 gs1databar moduleWidth', () => { + it('becomes the magnification', () => { + const persisted = { pages: [{ objects: [{ id: 'g', type: 'gs1databar', x: 0, y: 0, rotation: 0, props: { moduleWidth: 4 } }] }] }; + const migrated = migrateLegacy(persisted, 5) as { pages: { objects: { props: Record }[] }[] }; + expect(migrated.pages[0]?.objects[0]?.props).toEqual({ magnification: 4 }); + }); +}); + describe('migrateLegacy: a main-era ^DF that only the overlay bytes carried', () => { it('lands on the page and its head, so naming the page rewrites instead of doubling', () => { const imported = importZplText(['^XA', '^DFE:OLD.ZPL', '^FO10,10^A0N,30,30^FDa^FS', '^XZ'].join('\n'), 8); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index cb8b32a9..5abb359f 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -8,6 +8,7 @@ import { visitLeavesInPages, foldSerialLeaf, fixTlc39SlotsLeaf, + migrateGs1DatabarLeaf, bindSingleMarkerLeaf, safeUniqueNameById, } from '@zplab/core/lib/objectTree'; @@ -165,11 +166,9 @@ export function migrateLegacy(persistedState: unknown, version: number): unknown s = { ...s, printerProfile: {} }; } - // v5→v6: gs1databar props.moduleWidth → props.magnification. The - // value was always semantically the ^BR magnification multiplier - // (1-10), not a dot quantity; the rename clarifies that. + // v5→v6: gs1databar props.moduleWidth → props.magnification. if (version < 6) { - s = { ...s, pages: migrateGs1DatabarInPages(s.pages) }; + visitLeavesInPages(s.pages, migrateGs1DatabarLeaf); } // v6→v7: ^MA/^MI type codes corrected to match spec (R/C instead of @@ -230,17 +229,6 @@ export function migrateLegacy(persistedState: unknown, version: number): unknown } } - // v15→v16: TLC39 props move to the real ^BT slots (w2/h2, wideRatio); - // emit and canvas read the new shape unguarded. - if (version < 16 && Array.isArray(s.pages)) { - visitLeavesInPages(s.pages, fixTlc39SlotsLeaf); - } - - // v17→v18: the same drive pin the .zpld lane applies at schema v6. - if (version < 18 && Array.isArray(s.pages)) { - visitLeavesInPages(s.pages, pinBareFontDriveLeaf); - } - // v9→v10: reverse text dropped its synthesized self-background ^GB for a // spec-true ^FR knockout. Give every legacy reverse text a real black box // behind it so the white-on-black look survives the model change. @@ -353,6 +341,17 @@ export function migrateLegacy(persistedState: unknown, version: number): unknown } } + // v15→v16: TLC39 props move to the real ^BT slots (w2/h2, wideRatio); + // emit and canvas read the new shape unguarded. + if (version < 16 && Array.isArray(s.pages)) { + visitLeavesInPages(s.pages, fixTlc39SlotsLeaf); + } + + // v17→v18: the same drive pin the .zpld lane applies at schema v6. + if (version < 18 && Array.isArray(s.pages)) { + visitLeavesInPages(s.pages, pinBareFontDriveLeaf); + } + // v18→v19: the print target moves from loose browser keys into the session. Persist writes the // merged state back only after a migration, so the takeover has to run here and not at slice init. if (version < 19 && !('printTarget' in s)) { @@ -415,21 +414,6 @@ function migrateSingleBindInPages( return pages; } -function migrateGs1DatabarInPages(pages: unknown): unknown { - visitLeavesInPages(pages, (leaf) => { - if ( - leaf.type !== 'gs1databar' || - !leaf.props || - typeof leaf.props !== 'object' - ) return; - const props = leaf.props as Record; - if (!('moduleWidth' in props) || 'magnification' in props) return; - props.magnification = props.moduleWidth; - delete props.moduleWidth; - }); - return pages; -} - function migrateCirclesInPages(pages: unknown): unknown { if (!Array.isArray(pages)) return pages; return pages.map((page) => {