diff --git a/src/processors/snapProcessor.ts b/src/processors/snapProcessor.ts index 5491157..70ac1fa 100644 --- a/src/processors/snapProcessor.ts +++ b/src/processors/snapProcessor.ts @@ -84,7 +84,7 @@ interface SnapPage { class SnapProcessor extends BaseProcessor { readonly capabilities = { wordList: 'none' as const, - preservesAssetsOnSave: false, + preservesAssetsOnSave: true, newCellCreation: 'allowed' as const, }; @@ -971,6 +971,382 @@ class SnapProcessor extends BaseProcessor { return await readBinaryFromInput(outputPath); } + /** + * Save a modified tree while preserving the original SQLite schema and data. + * + * Strategy: copy the original .sps verbatim, then open the copy and replay + * `page.pendingMutations` as targeted SQL UPDATE/INSERT statements. Everything + * not in the mutation log (PageLayout, ScanGroup, image blobs, ContentTypeData, + * ButtonPageLink, etc.) is preserved byte-for-byte from the original. + * + * This is the asset-preserving counterpart to `saveFromTree` (which builds a + * stripped-down DB from scratch and is unsuitable for round-tripping real + * TD Snap page sets). + * + * Supported mutations: + * - updateButton(id, patch) → UPDATE Button SET Label/Message WHERE Id = ? + * - removeButton(id) → UPDATE ElementPlacement SET Visible = 0 for all + * placements pointing at the button's ElementReference + * - addButton(button) → INSERT into ElementReference + Button + one + * ElementPlacement per existing PageLayout for + * the target page (so the button shows in every + * layout the user has). Image/audio not yet handled. + * + * WordList mutations are no-ops on Snap (capabilities.wordList === 'none'). + */ + async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise { + const { pathExists, mkDir, removePath, dirname, readBinaryFromInput, writeBinaryToPath } = + this.options.fileAdapter; + if (!isNodeRuntime()) { + throw new Error('saveModifiedTree is only supported in Node.js for Snap files.'); + } + + const outputDir = dirname(outputPath); + if (!(await pathExists(outputDir))) { + await mkDir(outputDir, { recursive: true }); + } + if (await pathExists(outputPath)) { + await removePath(outputPath); + } + + // 1. Copy the original verbatim — preserves all 23+ tables, blobs, settings. + const originalBytes = await readBinaryFromInput(originalPath); + await writeBinaryToPath(outputPath, originalBytes); + + // Short-circuit: if no page has any mutations, we're done. + const hasAnyMutations = Object.values(tree.pages).some( + (page) => page.pendingMutations.length > 0 + ); + if (!hasAnyMutations) { + return; + } + + // 2. Open the copy. + const Database = requireBetterSqlite3(); + const db = new Database(outputPath, { readonly: false }); + + try { + // 3. Schema introspection — different Snap versions have different optional columns. + const tableColumns = (table: string): Set => { + try { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ + name: string; + }>; + return new Set(rows.map((r) => r.name)); + } catch { + return new Set(); + } + }; + const buttonCols = tableColumns('Button'); + const placementCols = tableColumns('ElementPlacement'); + const hasPlacementVisible = placementCols.has('Visible'); + const hasPlacementPageLayoutId = placementCols.has('PageLayoutId'); + + // 4. UniqueId → numeric Page.Id map. + const pageRows = db.prepare('SELECT Id, UniqueId FROM Page').all() as Array<{ + Id: number; + UniqueId: string | null; + }>; + const uniqueIdToPageId = new Map(); + for (const row of pageRows) { + if (row.UniqueId) uniqueIdToPageId.set(String(row.UniqueId), row.Id); + // Allow lookup by stringified numeric Id too, since loadIntoTree falls back to it. + uniqueIdToPageId.set(String(row.Id), row.Id); + } + + // Page.Id → list of PageLayout.Id, plus parsed dimensions, plus occupied cells per layout. + // PageLayoutSetting is a comma string like "4,3,False,0" → cols=4, rows=3. + interface LayoutInfo { + id: number; + cols: number; + rows: number; + occupied: Set; + } + const layoutsByPage = new Map(); + try { + const layoutRows = db + .prepare('SELECT Id, PageId, PageLayoutSetting FROM PageLayout') + .all() as Array<{ Id: number; PageId: number; PageLayoutSetting: string | null }>; + + // Pre-load occupied placements per layout so we can avoid (x,y) collisions. + const placementsByLayout = new Map>(); + const placementRows = db + .prepare( + 'SELECT PageLayoutId, GridPosition FROM ElementPlacement WHERE GridPosition IS NOT NULL AND PageLayoutId IS NOT NULL' + ) + .all() as Array<{ PageLayoutId: number; GridPosition: string }>; + for (const r of placementRows) { + let set = placementsByLayout.get(r.PageLayoutId); + if (!set) { + set = new Set(); + placementsByLayout.set(r.PageLayoutId, set); + } + set.add(r.GridPosition); + } + + for (const row of layoutRows) { + const parts = String(row.PageLayoutSetting ?? '').split(','); + const cols = parseInt(parts[0], 10) || 4; + const rows = parseInt(parts[1], 10) || 4; + const info: LayoutInfo = { + id: row.Id, + cols, + rows, + occupied: placementsByLayout.get(row.Id) ?? new Set(), + }; + const list = layoutsByPage.get(row.PageId); + if (list) list.push(info); + else layoutsByPage.set(row.PageId, [info]); + } + } catch { + // PageLayout table may not exist on older schemas — placements get NULL PageLayoutId. + } + + // Find first empty cell on a layout, starting from a preferred (x,y). + // Returns null if the layout is fully occupied. + const findFreeCell = (info: LayoutInfo, prefX: number, prefY: number): string | null => { + const inBounds = (x: number, y: number): boolean => + x >= 0 && x < info.cols && y >= 0 && y < info.rows; + if (inBounds(prefX, prefY)) { + const key = `${prefX},${prefY}`; + if (!info.occupied.has(key)) { + info.occupied.add(key); + return key; + } + } + for (let y = 0; y < info.rows; y++) { + for (let x = 0; x < info.cols; x++) { + const key = `${x},${y}`; + if (!info.occupied.has(key)) { + info.occupied.add(key); + return key; + } + } + } + return null; + }; + + // Generate a UUID for new Button.UniqueId. Required: Node has crypto.randomUUID since 14.17. + const nodeCrypto = getNodeRequire()?.('crypto') as typeof import('crypto') | undefined; + const uuid = (): string => + nodeCrypto?.randomUUID + ? nodeCrypto.randomUUID() + : // Fallback (very unlikely path, but shouldn't break older Nodes) + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + + // 5. Next-available IDs for inserts. + const nextId = (table: string): number => { + const row = db.prepare(`SELECT COALESCE(MAX(Id), 0) AS maxId FROM ${table}`).get() as { + maxId: number; + }; + return (row.maxId || 0) + 1; + }; + let nextButtonId = nextId('Button'); + let nextElementRefId = nextId('ElementReference'); + let nextPlacementId = nextId('ElementPlacement'); + // CommandSequence is optional but TD Snap crashes on some pages (e.g. dashboards + // like "Google Home Speaker") when a button has no CommandSequence row. Every + // button in the original DB has one. We track this only if the table exists. + const hasCommandSequence = tableColumns('CommandSequence').size > 0; + let nextCommandSequenceId = hasCommandSequence ? nextId('CommandSequence') : 0; + + // 6. Replay mutations inside one transaction for atomicity + speed. + const replay = db.transaction(() => { + for (const page of Object.values(tree.pages)) { + if (page.pendingMutations.length === 0) continue; + + const numericPageId = uniqueIdToPageId.get(String(page.id)); + if (numericPageId === undefined) { + // eslint-disable-next-line no-console + console.warn( + `[Snap] saveModifiedTree: page "${page.name}" (${page.id}) not found in original DB; skipping ${page.pendingMutations.length} mutation(s)` + ); + continue; + } + + for (const mutation of page.pendingMutations) { + switch (mutation.type) { + case 'updateButton': { + const sets: string[] = []; + const args: unknown[] = []; + if (mutation.patch.label !== undefined && buttonCols.has('Label')) { + sets.push('Label = ?'); + args.push(mutation.patch.label); + } + if (mutation.patch.message !== undefined && buttonCols.has('Message')) { + sets.push('Message = ?'); + args.push(mutation.patch.message); + } + if (sets.length === 0) break; + args.push(Number(mutation.buttonId)); + db.prepare(`UPDATE Button SET ${sets.join(', ')} WHERE Id = ?`).run(...args); + break; + } + + case 'removeButton': { + // Hide all placements that reference the button's ElementReference. + const buttonRow = db + .prepare('SELECT ElementReferenceId FROM Button WHERE Id = ?') + .get(Number(mutation.buttonId)) as { ElementReferenceId: number } | undefined; + if (!buttonRow || buttonRow.ElementReferenceId == null) break; + if (hasPlacementVisible) { + db.prepare( + 'UPDATE ElementPlacement SET Visible = 0 WHERE ElementReferenceId = ?' + ).run(buttonRow.ElementReferenceId); + } else { + // Older schemas without Visible: delete the placements outright. + db.prepare('DELETE FROM ElementPlacement WHERE ElementReferenceId = ?').run( + buttonRow.ElementReferenceId + ); + } + break; + } + + case 'addButton': { + const button = mutation.button; + const elementRefId = nextElementRefId++; + const buttonId = nextButtonId++; + + // ElementReference: TD Snap requires ElementType, ForegroundColor, + // BackgroundColor, and AudioCueRecordingId to be non-NULL on render + // — NULL values crash dashboard pages (e.g. "Google Home Speaker"). + // Defaults below match the modal values across real Snap files + // (>99% of existing rows in Core First Scanning use them). + const erColumns = tableColumns('ElementReference'); + const erCandidates: Array<{ col: string; value: unknown }> = [ + { col: 'Id', value: elementRefId }, + { col: 'PageId', value: numericPageId }, + { col: 'ElementType', value: 0 }, // 0 = button; only nonzero in 1/20608 rows + { col: 'ForegroundColor', value: -14934754 }, // dark text default (99.8% of rows) + { col: 'BackgroundColor', value: -132102 }, // light cell default (85.7% of rows) + { col: 'AudioCueRecordingId', value: 0 }, // 0 = no audio cue (99.99% of rows) + ]; + const erFieldsPresent = erCandidates.filter((f) => erColumns.has(f.col)); + db.prepare( + `INSERT INTO ElementReference (${erFieldsPresent + .map((f) => f.col) + .join(', ')}) VALUES (${erFieldsPresent.map(() => '?').join(', ')})` + ).run(...erFieldsPresent.map((f) => f.value)); + + // Button: provide non-NULL defaults for columns TD Snap reads. + // ContentType = 6 (normal speak button), CommandFlags = 8 (standard), + // LabelOwnership / ImageOwnership = 3 (owned by this page set), + // ActiveContentType = 0, BorderThickness = 0, UniqueId = fresh GUID, + // image / sound / symbol IDs = 0 (= "no asset"). + const candidateFields: Array<{ col: string; value: unknown }> = [ + { col: 'Id', value: buttonId }, + { col: 'Label', value: button.label || '' }, + { col: 'Message', value: button.message || button.label || '' }, + { col: 'ElementReferenceId', value: elementRefId }, + { col: 'ContentType', value: 6 }, + { col: 'CommandFlags', value: 8 }, + { col: 'LabelOwnership', value: 3 }, + { col: 'ImageOwnership', value: 3 }, + { col: 'ActiveContentType', value: 0 }, + { col: 'BorderThickness', value: 0 }, + { col: 'UniqueId', value: uuid() }, + { col: 'LibrarySymbolId', value: 0 }, + { col: 'PageSetImageId', value: 0 }, + { col: 'MessageRecordingId', value: 0 }, + // UseMessageRecording: omit. 99.99% of existing rows have NULL, + // and forcing 0 makes Sarah/Mum the only outliers in the DB. + { col: 'SymbolColorDataId', value: 0 }, + ]; + const presentFields = candidateFields.filter((f) => buttonCols.has(f.col)); + const sql = `INSERT INTO Button (${presentFields + .map((f) => f.col) + .join(', ')}) VALUES (${presentFields.map(() => '?').join(', ')})`; + db.prepare(sql).run(...presentFields.map((f) => f.value)); + + // Insert a default CommandSequence row that explicitly speaks the + // button's Label ($type:3 = MessageAction, value 0 = speak Label). + // This is the most common pattern in real Snap files (9514 / 19402 + // buttons in Core First Scanning). Without an explicit action, + // dashboard pages (e.g. "Google Home Speaker") crash on render — + // empty $values is only used for hidden/help-text buttons. + if (hasCommandSequence) { + db.prepare( + 'INSERT INTO CommandSequence (Id, SerializedCommands, ButtonId) VALUES (?, ?, ?)' + ).run( + nextCommandSequenceId++, + '{"$type":"1","$values":[{"$type":"3","MessageAction":0}]}', + buttonId + ); + } + + const layouts = layoutsByPage.get(numericPageId) ?? []; + const prefX = button.x ?? 0; + const prefY = button.y ?? 0; + + if (layouts.length === 0) { + const placementId = nextPlacementId++; + if (hasPlacementPageLayoutId) { + db.prepare( + "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', NULL, 1)" + ).run(placementId, elementRefId, `${prefX},${prefY}`); + } else { + db.prepare( + "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', 1)" + ).run(placementId, elementRefId, `${prefX},${prefY}`); + } + } else { + // INVARIANT: every Button must have exactly one ElementPlacement + // per PageLayout on its page. Snap renders buttons × layouts and + // crashes if a (button, layout) pair is missing. + // + // Hidden placements (Visible=0) MUST use a position that doesn't + // collide with an existing visible placement on that layout. + // The existing convention puts hidden placements at out-of-grid + // coordinates (e.g. position (2,4) on a 4×3 layout where row 4 + // doesn't exist). We do the same: synthesise (cols, 0) which is + // guaranteed out of bounds since valid X is 0..cols-1. + for (const info of layouts) { + const cell = findFreeCell(info, prefX, prefY); + const visible = cell !== null ? 1 : 0; + const gridPosition = cell ?? `${info.cols},0`; + const placementId = nextPlacementId++; + if (hasPlacementPageLayoutId && hasPlacementVisible) { + db.prepare( + "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', ?, ?)" + ).run(placementId, elementRefId, gridPosition, info.id, visible); + } else if (hasPlacementPageLayoutId) { + db.prepare( + "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId) VALUES (?, ?, ?, '1,1', ?)" + ).run(placementId, elementRefId, gridPosition, info.id); + } else if (hasPlacementVisible) { + db.prepare( + "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', ?)" + ).run(placementId, elementRefId, gridPosition, visible); + } else { + db.prepare( + "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan) VALUES (?, ?, ?, '1,1')" + ).run(placementId, elementRefId, gridPosition); + } + } + } + break; + } + + case 'addWordListItem': + case 'removeWordListItem': + case 'clearWordList': + // Snap has no WordList concept — these are no-ops by capability contract. + break; + } + } + } + }); + + replay(); + } finally { + db.close(); + } + } + async saveFromTree(tree: AACTree, outputPath: string): Promise { const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter; if (!isNodeRuntime()) { diff --git a/test/snapProcessor.saveModifiedTree.test.ts b/test/snapProcessor.saveModifiedTree.test.ts new file mode 100644 index 0000000..dfdfe70 --- /dev/null +++ b/test/snapProcessor.saveModifiedTree.test.ts @@ -0,0 +1,251 @@ +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACButton } from '../src/core/treeStructure'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import Database from 'better-sqlite3'; + +/** + * Tests for SnapProcessor.saveModifiedTree — the asset-preserving save path. + * + * Validates: + * 1. Capabilities flag is true (Snap declares preservesAssetsOnSave). + * 2. Round-trip with zero mutations leaves the file byte-identical (full schema + * + all 23 tables preserved, no data lost). + * 3. addButton inserts a complete Button + ElementReference + CommandSequence + * + one ElementPlacement per existing PageLayout for the target page. + * The Button row carries the modal-non-NULL values TD Snap requires + * (ContentType=6, CommandFlags=8, ForegroundColor / BackgroundColor set). + * 4. updateButton applies Label / Message changes to the matching Button row + * by id; nothing else moves. + * 5. removeButton flips Visible=0 on every placement of the target button + * without touching the Button row itself. + * 6. WordList mutations are no-ops (Snap capability says wordList: 'none'). + */ + +const exampleSPSFile: string = path.join(__dirname, 'assets/snap/example.sps'); + +function makeOutputPath(suffix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'snap-save-test-')); + return path.join(dir, `out-${suffix}.sps`); +} + +function tableNames(filePath: string): string[] { + const db = new Database(filePath, { readonly: true }); + try { + return ( + db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ + name: string; + }> + ).map((r) => r.name); + } finally { + db.close(); + } +} + +describe('SnapProcessor.saveModifiedTree', () => { + it('declares preservesAssetsOnSave: true', () => { + const processor = new SnapProcessor(); + expect(processor.capabilities.preservesAssetsOnSave).toBe(true); + expect(processor.capabilities.wordList).toBe('none'); + }); + + it('round-trips with zero mutations and preserves the full schema', async () => { + const processor = new SnapProcessor(); + const tree = await processor.loadIntoTree(exampleSPSFile); + + // Sanity: load shouldn't record any mutations (load uses _loadButton). + let totalMutations = 0; + for (const page of Object.values(tree.pages)) { + totalMutations += page.pendingMutations.length; + } + expect(totalMutations).toBe(0); + + const outputPath = makeOutputPath('roundtrip'); + await processor.saveModifiedTree(exampleSPSFile, tree, outputPath); + + // File size identical, all 23 tables preserved. + const origSize = fs.statSync(exampleSPSFile).size; + const outSize = fs.statSync(outputPath).size; + expect(outSize).toBe(origSize); + + const origTables = tableNames(exampleSPSFile); + const outTables = tableNames(outputPath); + expect(outTables).toEqual(origTables); + }); + + it('addButton inserts Button + ElementReference + CommandSequence + per-layout placements', async () => { + const processor = new SnapProcessor(); + const tree = await processor.loadIntoTree(exampleSPSFile); + + // Pick any page that has at least one PageLayout, so we can verify per-layout coverage. + const pages = Object.values(tree.pages); + const target = pages.find((p) => p.buttons.length > 0) ?? pages[0]; + expect(target).toBeTruthy(); + + target.addButton( + new AACButton({ + id: 'will-be-replaced-with-fresh-sql-id', + label: 'TestPersonalisedButton', + message: 'TestPersonalisedButton', + x: 0, + y: 0, + }) + ); + + const outputPath = makeOutputPath('add'); + await processor.saveModifiedTree(exampleSPSFile, tree, outputPath); + + const db = new Database(outputPath, { readonly: true }); + try { + // Button row exists with all the required-non-NULL columns set. + const btn = db + .prepare('SELECT * FROM Button WHERE Label = ?') + .get('TestPersonalisedButton') as Record | undefined; + expect(btn).toBeDefined(); + const buttonRow = btn as Record; + expect(buttonRow.ContentType).toBe(6); + expect(buttonRow.CommandFlags).toBe(8); + expect(buttonRow.LabelOwnership).toBe(3); + expect(buttonRow.ImageOwnership).toBe(3); + expect(buttonRow.UniqueId).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i)); + expect(buttonRow.UseMessageRecording).toBeNull(); // matches the 99.99% pattern + + // ElementReference row exists with explicit colours + ElementType=0. + const er = db + .prepare('SELECT * FROM ElementReference WHERE Id = ?') + .get(buttonRow.ElementReferenceId as number) as Record; + expect(er.ElementType).toBe(0); + expect(er.ForegroundColor).not.toBeNull(); + expect(er.BackgroundColor).not.toBeNull(); + expect(er.AudioCueRecordingId).toBe(0); + + // CommandSequence row inserted with the canonical "speak the label" payload. + const cs = db + .prepare('SELECT SerializedCommands FROM CommandSequence WHERE ButtonId = ?') + .get(buttonRow.Id as number) as { SerializedCommands: string } | undefined; + expect(cs).toBeDefined(); + expect(cs?.SerializedCommands).toBe( + '{"$type":"1","$values":[{"$type":"3","MessageAction":0}]}' + ); + + // One ElementPlacement per PageLayout on the target page (the load + save invariant + // that prevents TD Snap from crashing on dashboard-style pages). + const layoutCount = ( + db + .prepare( + 'SELECT COUNT(*) AS n FROM PageLayout WHERE PageId = (SELECT Id FROM Page WHERE UniqueId = ?)' + ) + .get(target.id) as { n: number } + ).n; + const placements = db + .prepare('SELECT * FROM ElementPlacement WHERE ElementReferenceId = ?') + .all(buttonRow.ElementReferenceId as number) as Array>; + expect(placements.length).toBe(layoutCount); + + // No two placements collide on the same cell of the same layout (the bug + // that caused crashes when hidden placements landed on occupied cells). + for (const p of placements) { + const conflicts = ( + db + .prepare( + `SELECT COUNT(*) AS n FROM ElementPlacement + WHERE PageLayoutId = ? AND GridPosition = ? AND Id != ?` + ) + .get(p.PageLayoutId, p.GridPosition, p.Id) as { n: number } + ).n; + // visible=1 placements must not collide; hidden ones can technically share + // synthetic out-of-bounds positions, but our impl uses (cols,0) which is + // unique to each layout's column count, so we still expect zero collisions. + if (p.Visible === 1) expect(conflicts).toBe(0); + } + } finally { + db.close(); + } + }); + + it('updateButton patches Label/Message on the matching Button row', async () => { + const processor = new SnapProcessor(); + const tree = await processor.loadIntoTree(exampleSPSFile); + + // Pick an existing button to update. + const page = Object.values(tree.pages).find((p) => p.buttons.length > 0); + if (!page) throw new Error('fixture has no page with buttons'); + const btn = page.buttons[0]; + const newLabel = `${btn.label || 'Untitled'}__updated__${Date.now()}`; + const newMessage = 'updated message'; + + page.updateButton(btn.id, { label: newLabel, message: newMessage }); + + const outputPath = makeOutputPath('update'); + await processor.saveModifiedTree(exampleSPSFile, tree, outputPath); + + const db = new Database(outputPath, { readonly: true }); + try { + const row = db + .prepare('SELECT Label, Message FROM Button WHERE Id = ?') + .get(Number(btn.id)) as { Label: string | null; Message: string | null } | undefined; + expect(row).toBeDefined(); + expect(row?.Label).toBe(newLabel); + expect(row?.Message).toBe(newMessage); + } finally { + db.close(); + } + }); + + it('removeButton hides every placement of the target button (Visible=0)', async () => { + const processor = new SnapProcessor(); + const tree = await processor.loadIntoTree(exampleSPSFile); + + const page = Object.values(tree.pages).find((p) => p.buttons.length > 0); + if (!page) throw new Error('fixture has no page with buttons'); + const btn = page.buttons[0]; + + page.removeButton(btn.id); + + const outputPath = makeOutputPath('remove'); + await processor.saveModifiedTree(exampleSPSFile, tree, outputPath); + + const db = new Database(outputPath, { readonly: true }); + try { + const buttonRow = db + .prepare('SELECT ElementReferenceId FROM Button WHERE Id = ?') + .get(Number(btn.id)) as { ElementReferenceId: number }; + + // All placements for this ElementReference are hidden. + const placements = db + .prepare('SELECT Visible FROM ElementPlacement WHERE ElementReferenceId = ?') + .all(buttonRow.ElementReferenceId) as Array<{ Visible: number | null }>; + expect(placements.length).toBeGreaterThan(0); + for (const p of placements) { + expect(p.Visible === 0 || p.Visible === null).toBe(true); + } + } finally { + db.close(); + } + }); + + it('WordList mutations are silent no-ops on Snap (capability: wordList=none)', async () => { + const processor = new SnapProcessor(); + const tree = await processor.loadIntoTree(exampleSPSFile); + + const page = Object.values(tree.pages)[0]; + page.addWordListItem({ text: 'should-not-appear' }); + page.removeWordListItem('anything'); + page.clearWordList(); + + const outputPath = makeOutputPath('wordlist-noop'); + await processor.saveModifiedTree(exampleSPSFile, tree, outputPath); + + // The text should not appear anywhere in the output DB. + const db = new Database(outputPath, { readonly: true }); + try { + const found = db + .prepare("SELECT COUNT(*) AS n FROM Button WHERE Label = 'should-not-appear'") + .get() as { n: number }; + expect(found.n).toBe(0); + } finally { + db.close(); + } + }); +});