diff --git a/package-lock.json b/package-lock.json index 6779464..b4a9ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "BSD-3-Clause", "dependencies": { "@lit/context": "^1.1.6", - "data-explorer-core": "github:mathworks/data-explorer-core#v1.38.1", + "data-explorer-core": "github:mathworks/data-explorer-core#v1.39.0", "fast-xml-parser": "^5.8.0", "fflate": "^0.8.3", "highlight.js": "^11.11.1", @@ -3184,8 +3184,8 @@ } }, "node_modules/data-explorer-core": { - "version": "1.38.1", - "resolved": "git+ssh://git@github.com/mathworks/data-explorer-core.git#b8c3f2ba7977c812fc03256f5ac4deaf8c4eac68", + "version": "1.39.0", + "resolved": "git+ssh://git@github.com/mathworks/data-explorer-core.git#8e334082682eae7901cc07016f4fb90b3cdc7c73", "license": "BSD-3-Clause", "dependencies": { "fast-xml-parser": "^5.8.0", diff --git a/package.json b/package.json index 35b89fd..ce4c393 100644 --- a/package.json +++ b/package.json @@ -236,7 +236,7 @@ }, "dependencies": { "@lit/context": "^1.1.6", - "data-explorer-core": "github:mathworks/data-explorer-core#v1.38.1", + "data-explorer-core": "github:mathworks/data-explorer-core#v1.39.0", "fast-xml-parser": "^5.8.0", "fflate": "^0.8.3", "highlight.js": "^11.11.1", diff --git a/src/host/BinarySlddEditorProvider.ts b/src/host/BinarySlddEditorProvider.ts index 85ce8bf..3bcba5f 100644 --- a/src/host/BinarySlddEditorProvider.ts +++ b/src/host/BinarySlddEditorProvider.ts @@ -63,7 +63,13 @@ import { deleteEntriesByNameXml, type StructuralResult, } from './xmlStructuralEdit.js'; -import { catalogRenameOf, scXmlRenamePatch, type ScPartPatch } from './scRename.js'; +import { + catalogFunctionRenameOf, + catalogRenameOf, + scXmlFunctionRenamePatch, + scXmlRenamePatch, + type ScPartPatch, +} from './scRename.js'; import { applyEntryOps, entryRecord, @@ -1105,6 +1111,9 @@ export class BinarySlddEditorProvider implements vscode.CustomEditorProvider + new Map([[rename.interfaceName, new Map([[rename.oldName, rename.newName]])]]); + /** * The range replacements that carry a rename into an uncompressed-text dictionary's * catalog, as offsets into the document text the caller is about to edit. @@ -85,6 +142,18 @@ export function scJsonRenameEdits(text: string, oldName: string, newName: string return scRenameEdits(scanScJsonText(text), oldName, newName); } +/** + * The range replacements that carry a function rename into an uncompressed-text + * dictionary's catalog, as offsets into the document text the caller is about to edit: + * the function's `p_Name`, and its `p_FunctionPrototype` where that declares it by name. + * + * Core's own edits, the ones its save applies, run on the text as it stands. Empty when the + * catalog does not list the function under that interface, or when there is no catalog part. + */ +export function scJsonFunctionRenameEdits(text: string, rename: CatalogFunctionRename): ScTextEdit[] { + return scFunctionRenameEdits(text, scanScJsonFunctions(text), asFunctionRenames(rename)); +} + /** A pass-through zip member, before and after a rename was carried into it. */ export interface ScPartPatch { member: string; @@ -117,3 +186,25 @@ export function scXmlRenamePatch( } return { member: SC_PART_XML, before, after: new TextEncoder().encode(applyScEdits(xml, edits)) }; } + +/** + * The catalog member swap that carries a function rename into a compressed-binary + * dictionary, or null when there is nothing to carry. It has `scXmlRenamePatch`'s shape, so + * it rides `pushEdit` and its undo the same way, and `before` is again the member's original + * bytes. + */ +export function scXmlFunctionRenamePatch( + zipMeta: Record, + rename: CatalogFunctionRename, +): ScPartPatch | null { + const before = zipMeta[SC_PART_XML]; + if (!before) { + return null; + } + const xml = new TextDecoder().decode(before); + const edits = scFunctionRenameEdits(xml, scanScXmlFunctions(xml), asFunctionRenames(rename)); + if (edits.length === 0) { + return null; + } + return { member: SC_PART_XML, before, after: new TextEncoder().encode(applyScEdits(xml, edits)) }; +} diff --git a/test-integration/suite/archRenameCarry.test.ts b/test-integration/suite/archRenameCarry.test.ts index b0a0d67..1c9311e 100644 --- a/test-integration/suite/archRenameCarry.test.ts +++ b/test-integration/suite/archRenameCarry.test.ts @@ -33,7 +33,12 @@ import { findEntryObjectSpan, } from 'data-explorer-core'; import { BinarySlddEditorProvider } from '../../src/host/BinarySlddEditorProvider'; -import { catalogRenameOf, scXmlRenamePatch } from '../../src/host/scRename'; +import { + catalogFunctionRenameOf, + catalogRenameOf, + scXmlFunctionRenamePatch, + scXmlRenamePatch, +} from '../../src/host/scRename'; import { mutateEntry } from '../../src/host/entryOps'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -57,14 +62,15 @@ function token() { const settle = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** - * A working copy of the vitest suite's architectural fixture. + * A working copy of one of the vitest suite's architectural fixtures (`arch_binary.sldd` + * unless named). * * Sourced from `test/fixtures` rather than duplicated here, so the dictionary this asks * about is the same one the unit tests ask about; and written OUTSIDE the workspace folder, * whose exact file set two other suites assert. */ -async function workingCopy(name: string): Promise { - const src = vscode.Uri.joinPath(ws(), '..', '..', '..', 'test', 'fixtures', 'arch_binary.sldd'); +async function workingCopy(name: string, fixture = 'arch_binary.sldd'): Promise { + const src = vscode.Uri.joinPath(ws(), '..', '..', '..', 'test', 'fixtures', fixture); const dst = vscode.Uri.joinPath(ws(), '..', name); await vscode.workspace.fs.copy(src, dst, { overwrite: true }); return dst; @@ -132,6 +138,43 @@ function renameEntry(doc: any, root: any, name: string, newName: string) { return { before, after, part: part! }; } +/** + * applyEdit's rename of a service interface's FUNCTION, minus the webview message: the + * catalog keeps its own copy of the function (`p_Name`, `p_FunctionPrototype`), so this + * edit carries a member swap too, through the function gate rather than the entry one. + */ +function renameFunction(doc: any, root: any, entryName: string, name: string, newName: string) { + const entry = entryNamed(root, entryName); + assert.ok(entry, `the fixture has an entry named ${entryName}`); + const node = (entry.children as any[]).find((c: any) => c.name === name); + assert.ok(node, `${entryName} has a function named ${name}`); + const selector = entrySelectorOf(entry); + assert.strictEqual(catalogRenameOf('Name', newName, node, entry), null, 'a function is not an entry rename'); + const functionRename = catalogFunctionRenameOf('Name', newName, node, entry); + assert.ok(functionRename, 'a function of a top-level service interface is a rename the catalog can carry'); + + assert.strictEqual(mutateEntry(entry, () => node.setProperty('Name', newName)), true); + + const before = doc.chunkXml as string; + const span = findEntryObjectSpan(before, selector); + assert.ok(span, 'the entry’s XML object is locatable'); + const frag = serializeEntryToXml(entry).replace(/\n$/, ''); + const after = before.slice(0, span!.offset) + frag + before.slice(span!.offset + span!.length); + const part = scXmlFunctionRenamePatch(doc.zipMeta, functionRename!); + assert.ok(part, 'the catalog lists this function, so the rename has a part to carry'); + + doc.pushEdit('Edit Name', before, after, undefined, part); + return { before, after, part: part! }; +} + +/** `text` with each `from` replaced by its `to`, checking first that `from` occurs exactly once. */ +function replacedOnce(text: string, pairs: [string, string][]): string { + return pairs.reduce((out, [from, to]) => { + assert.strictEqual(out.split(from).length - 1, 1, `"${from}" occurs exactly once`); + return out.replace(from, () => to); + }, text); +} + suite('Architectural rename carries into the System Composer catalog', () => { let provider: BinarySlddEditorProvider; @@ -205,6 +248,68 @@ suite('Architectural rename carries into the System Composer catalog', () => { await vscode.workspace.fs.delete(uri); }); + test('a save writes a renamed service function in the chunk and in the catalog', async () => { + // The catalog's copy of a service interface's function: without the carry the saved + // chunk says `compute` and the saved catalog still says `f`, which MATLAB reads as two + // different functions. + const uri = await workingCopy('arch_rename_function_save.sldd', 'arch_service_binary.sldd'); + const original = await partsOnDisk(uri); + + const { doc } = await open(provider, uri); + const { part } = renameFunction(doc, DataModel.getDataSource((doc as any).srcId), 'ServiceInterface', 'f', 'compute'); + assert.deepStrictEqual((doc as any).zipMeta[SC_PART_XML], part.after, 'the edit swapped the catalog member on the document'); + await provider.saveCustomDocument(doc, token()); + + const saved = await partsOnDisk(uri); + assert.ok(saved.chunkXml.includes('>compute<'), 'the saved chunk spells the new name'); + assert.ok(saved.chunkXml.includes('y = compute(u)'), 'and the Prototype that declares it'); + // Exactly the function's two sites: the data interface named f and g's argument g stay. + assert.strictEqual( + new TextDecoder().decode(saved.zipMeta[SC_PART_XML]), + replacedOnce(new TextDecoder().decode(original.zipMeta[SC_PART_XML]), [ + ['y = f(u)', 'y = compute(u)'], + ['f\n ', 'compute\n '], + ]), + 'the saved catalog renames the function at its p_Name and p_FunctionPrototype, and nowhere else', + ); + assert.strictEqual(kindsOnDisk(saved).ServiceInterface, 'Service Interface', 'and still models the entry'); + + doc.dispose(); + await vscode.workspace.fs.delete(uri); + }); + + test('undo of a service function rename restores the catalog member byte for byte, and a save writes that back', async () => { + const uri = await workingCopy('arch_rename_function_undo.sldd', 'arch_service_binary.sldd'); + const original = await partsOnDisk(uri); + + const { doc } = await open(provider, uri); + const edits: vscode.CustomDocumentEditEvent[] = []; + const sub = provider.onDidChangeCustomDocument((e) => edits.push(e)); + const { before, part } = renameFunction(doc, DataModel.getDataSource((doc as any).srcId), 'ServiceInterface', 'f', 'compute'); + assert.strictEqual(edits.length, 1, 'the rename is ONE edit on VS Code’s undo stack'); + + edits[0].undo(); + assert.strictEqual((doc as any).chunkXml, before, 'undo restored the chunk'); + assert.deepStrictEqual( + (doc as any).zipMeta[SC_PART_XML], + original.zipMeta[SC_PART_XML], + 'and the catalog member, byte for byte', + ); + + edits[0].redo(); + assert.deepStrictEqual((doc as any).zipMeta[SC_PART_XML], part.after, 'redo swapped it back'); + + edits[0].undo(); + await provider.saveCustomDocument(doc, token()); + const saved = await partsOnDisk(uri); + assert.strictEqual(saved.chunkXml, original.chunkXml, 'the saved chunk is the undone one'); + assert.deepStrictEqual(saved.zipMeta[SC_PART_XML], original.zipMeta[SC_PART_XML], 'and so is the saved catalog'); + + sub.dispose(); + doc.dispose(); + await vscode.workspace.fs.delete(uri); + }); + test('a revert brings the catalog back from disk too, not just the chunk', async () => { // The half of a revert that is easy to miss: it re-reads the file, and a revert that // restored only `data/chunk0.xml` would leave the patched catalog in place — a file whose diff --git a/test-integration/suite/functionRenameThroughEditors.test.ts b/test-integration/suite/functionRenameThroughEditors.test.ts new file mode 100644 index 0000000..a83941d --- /dev/null +++ b/test-integration/suite/functionRenameThroughEditors.test.ts @@ -0,0 +1,497 @@ +// Copyright 2026 The MathWorks, Inc. +// Renaming a service interface's function through the REAL editors, the way the table asks +// for it: one `edit` message, through each provider's own message handler. +// +// A `Simulink.FunctionElement` is spelled twice in an architectural dictionary: in the data +// part, and in the System Composer catalog, which keeps its own copy of each function under +// the interface (`p_Name`, and the `p_FunctionPrototype` that declares the function by name). +// A rename that moved only the first would reopen as two functions MATLAB cannot reconcile. +// Each provider carries it in one branch of its edit handler: extra ranges in the same +// WorkspaceEdit in the text editor, and a member swap on the same undo step in the binary one. +// +// test/scRenameCarry.test.ts pins the helpers that branch calls, and archRenameCarry.test.ts +// pins what a save and an undo do with the member swap. Both compose the edit themselves, as +// the handler composes it, so neither one reaches the handler: with either provider put back +// to the one before the carry, all of their tests stay green. This file is the half they +// cannot be. It sends the message the table sends, through a fake panel, and grades what the +// provider wrote against core's own save of the same renames (`createSession` / `ingest` / +// `serializeSource`), which carries a function rename by itself. +// +// Measured, by putting one provider back to origin/main at a time and running this file: the +// four cases per editor that carry a rename fail, and the two that must write no catalog +// edit (a function the catalog does not list, a rename core refuses) pass, as controls. +// +// The text editor's undo is applied as the inverse of the one change event the rename fired, +// as ONE WorkspaceEdit, because the `undo` command does not take effect in this test host +// (see cutPasteUndo.test.ts). One event holding every range is what makes the rename a single +// step on VS Code's stack; the inverse shows those ranges are the whole rename. +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { unzipSync, zipSync } from 'fflate'; +import { SC_PART_XML, createSession, ingest } from 'data-explorer-core'; +import { SlddTextEditorProvider } from '../../src/host/SlddTextEditorProvider'; +import { BinarySlddEditorProvider } from '../../src/host/BinarySlddEditorProvider'; +import { childrenOf, ctx, fakePanel, isEntryRow, nameOf, settle, token, waitFor, ws } from './tools/hostHarness'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const enc = (text: string) => new TextEncoder().encode(text); +const dec = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +/** The text and binary fixtures the unit suite asks the same questions of. */ +const TEXT_FIXTURE = 'arch.sldd'; +const BINARY_FIXTURE = 'arch_service_binary.sldd'; +const CHUNK_XML = 'data/chunk0.xml'; + +/** Sourced from `test/fixtures`, so the dictionary this asks about is the unit suite's. */ +const fixtureUri = (name: string) => vscode.Uri.joinPath(ws(), '..', '..', '..', 'test', 'fixtures', name); + +/** Written OUTSIDE the workspace folder, whose exact file set two other suites assert. */ +const workingUri = (name: string) => vscode.Uri.joinPath(ws(), '..', name); + +const CATALOG_KEY = '"__MW_TEXT_PART__/simulink/systemcomposer/interfaceDictionary"'; + +/** A textual dictionary's catalog part: from its key to the end of the text. */ +function catalogPartOf(text: string): string { + const at = text.indexOf(CATALOG_KEY); + assert.ok(at > 0, 'the dictionary has a catalog part'); + return text.slice(at); +} + +/** `text` with each `from` replaced by its `to`, checking first that `from` occurs exactly once. */ +function replacedOnce(text: string, pairs: [from: string, to: string][]): string { + return pairs.reduce((out, [from, to]) => { + assert.strictEqual(out.split(from).length - 1, 1, `"${from}" occurs exactly once`); + return out.replace(from, () => to); + }, text); +} + +/** One rename: of the entry `entry`, or of its function `fn`, to `to`. */ +interface Step { + entry: string; + fn?: string; + to: string; +} + +const ONE_RENAME: Step[] = [{ entry: 'ServiceInterface', fn: 'f', to: 'compute' }]; + +/** The sequences a rename has to get right in an editor that writes as it goes. */ +const SEQUENCES: [label: string, steps: Step[]][] = [ + [ + 'the interface renamed first, then its function', + [ + { entry: 'ServiceInterface', to: 'Svc' }, + { entry: 'Svc', fn: 'f', to: 'compute' }, + ], + ], + [ + 'a chain', + [ + { entry: 'ServiceInterface', fn: 'f', to: 'compute' }, + { entry: 'ServiceInterface', fn: 'compute', to: 'step' }, + ], + ], +]; + +/** The node `step` names in a tree of sections, entries and their rows. */ +function nodeOf(root: any, step: Step): any { + const entry = (root.children as any[]).flatMap((s: any) => s.children as any[]).find((e: any) => e.name === step.entry); + assert.ok(entry, `core's tree has an entry named ${step.entry}`); + if (step.fn === undefined) return entry; + const fn = (entry.children as any[]).find((c: any) => c.name === step.fn); + assert.ok(fn, `${step.entry} has a function named ${step.fn}`); + return fn; +} + +/** Core's own save of `content` after `steps` ran on a fresh session's tree. */ +function coreSaved(content: Uint8Array, filename: string, steps: Step[]): any { + const session = createSession(); + const src: any = ingest(session, content, { filename }); + steps.forEach((step) => + assert.strictEqual( + nodeOf(src, step).setProperty('Name', step.to), + true, + `core accepts ${step.fn ?? step.entry} -> ${step.to}`, + ), + ); + const out = session.serializeSource(src.name); + session.removeDataSource(src.name); + assert.ok(out, 'core can write this dictionary'); + return out; +} + +/** What core says when it refuses `step` on `content`. */ +function coreRefusal(content: Uint8Array, filename: string, step: Step): string { + const session = createSession(); + const src: any = ingest(session, content, { filename }); + const result = nodeOf(src, step).setProperty('Name', step.to); + session.removeDataSource(src.name); + assert.ok(result && typeof result === 'object' && result.error, 'core refuses this rename too'); + return String(result.reason); +} + +/** The row the table holds for `step`, from the newest paint that carries it. */ +function rowOf(posts: any[], step: Step): any { + for (let i = posts.length - 1; i >= 0; i--) { + const msg = posts[i]; + if (msg.type !== 'setRows' && msg.type !== 'updateEntryRows') continue; + const rows = msg.rows as any[]; + const entry = rows.find((r) => isEntryRow(r) && nameOf(r) === step.entry); + if (!entry) continue; + if (step.fn === undefined) return entry; + const fn = childrenOf(rows, entry.ID).find((r) => nameOf(r) === step.fn); + if (fn) return fn; + } + assert.fail(`the table was sent a row for ${step.fn ?? step.entry} under ${step.entry}`); +} + +/** The `edit` message the table posts for a committed rename of `row`. */ +const renameMessage = (row: any, to: string) => ({ + type: 'edit', + rowId: row.ID, + columnId: 'Name', + oldValue: nameOf(row), + newValue: to, +}); + +const refusalsIn = (posts: any[]) => posts.filter((m) => m.type === 'validationError'); +const problemsIn = (posts: any[]) => posts.filter((m) => m.type === 'error' || m.type === 'validationError'); + +// --- the text editor --------------------------------------------------------------------- + +/** A working copy of the text fixture (rewritten by `edit` first), open in the real provider. */ +async function openText(copyName: string, edit: (text: string) => string = (text) => text) { + const original = edit(dec(await vscode.workspace.fs.readFile(fixtureUri(TEXT_FIXTURE)))); + const uri = workingUri(copyName); + await vscode.workspace.fs.writeFile(uri, enc(original)); + const doc = await vscode.workspace.openTextDocument(uri); + const changes: vscode.TextDocumentChangeEvent[] = []; + const sub = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() === uri.toString() && e.contentChanges.length > 0) changes.push(e); + }); + const view = fakePanel(); + await new SlddTextEditorProvider(ctx()).resolveCustomTextEditor(doc, view.panel, token()); + view.send({ type: 'ready' }); + await waitFor('the first paint', () => view.posts.some((m) => m.type === 'setRows')); + + /** Send one rename and wait for it to be written or refused; the posts it caused. */ + const rename = async (step: Step): Promise => { + const row = rowOf(view.posts, step); + const mark = view.posts.length; + const version = doc.version; + view.send(renameMessage(row, step.to)); + await waitFor( + `the rename to ${step.to} to be written or refused`, + () => doc.version > version || problemsIn(view.posts.slice(mark)).length > 0, + ); + // A rename that carried the catalog is several ranges, which takes the wide repaint; the + // next step reads its row from that. A tail, not the work. + await settle(300); + return view.posts.slice(mark); + }; + + const close = async () => { + view.close(); + sub.dispose(); + await vscode.workspace.fs.delete(uri); + }; + return { uri, doc, original, changes, rename, close }; +} + +suite('A service function rename through the text editor carries into the catalog', () => { + suiteSetup(async () => { + await vscode.extensions.getExtension('mathworks.simulink-data-explorer')?.activate(); + }); + + test('one rename writes the function’s p_Name and p_FunctionPrototype, in the same write as the entry', async () => { + const t = await openText('fn_rename_text_one.sldd'); + try { + const posts = await t.rename(ONE_RENAME[0]); + assert.deepStrictEqual(problemsIn(posts), [], 'the rename was accepted'); + + assert.strictEqual(t.changes.length, 1, 'one write'); + assert.strictEqual(t.changes[0].contentChanges.length, 3, 'holding the entry span, p_Name and p_FunctionPrototype'); + const text = t.doc.getText(); + assert.ok(text.includes('"Prototype": "y = compute(u,v)"'), 'the entry’s Prototype follows the Name'); + assert.strictEqual( + catalogPartOf(text), + replacedOnce(catalogPartOf(t.original), [ + ['"p_FunctionPrototype": "y = f(u,v)"', '"p_FunctionPrototype": "y = compute(u,v)"'], + ['"p_Name": "f"', '"p_Name": "compute"'], + ]), + 'the catalog moves at the function’s two sites and nowhere else', + ); + + const core = coreSaved(enc(t.original), TEXT_FIXTURE, ONE_RENAME); + assert.strictEqual(core.kind, 'text'); + assert.ok(await t.doc.save(), 'the document saved'); + const onDisk = dec(await vscode.workspace.fs.readFile(t.uri)); + assert.strictEqual( + catalogPartOf(onDisk), + catalogPartOf(core.text), + 'the saved catalog is the one core’s own save writes', + ); + } finally { + await t.close(); + } + }); + + for (const [label, steps] of SEQUENCES) { + test(`writes the catalog core’s own save writes: ${label}`, async () => { + const t = await openText(`fn_rename_text_${label.replace(/\W+/g, '_')}.sldd`); + try { + for (const step of steps) { + assert.deepStrictEqual(problemsIn(await t.rename(step)), [], `${step.fn ?? step.entry} -> ${step.to} was accepted`); + } + assert.strictEqual(t.changes.length, steps.length, 'one write per rename'); + const core = coreSaved(enc(t.original), TEXT_FIXTURE, steps); + assert.strictEqual( + catalogPartOf(t.doc.getText()), + catalogPartOf(core.text), + 'the catalog is the one core’s own save writes', + ); + } finally { + await t.close(); + } + }); + } + + test('a function the catalog does not list leaves the catalog untouched', async () => { + // `f2` is in the entry and not in the catalog, so the rename is the entry span alone. + const step: Step = { entry: 'ServiceInterface', fn: 'f2', to: 'h' }; + const t = await openText('fn_rename_text_unlisted.sldd'); + try { + assert.deepStrictEqual(problemsIn(await t.rename(step)), [], 'the rename was accepted'); + assert.strictEqual(t.changes.length, 1, 'one write'); + assert.strictEqual(t.changes[0].contentChanges.length, 1, 'of the entry span alone'); + const text = t.doc.getText(); + assert.ok(text.includes('"Prototype": "y = h(u,v)"'), 'the entry was renamed'); + assert.strictEqual(catalogPartOf(text), catalogPartOf(t.original), 'and the catalog is as read'); + assert.strictEqual( + catalogPartOf(text), + catalogPartOf(coreSaved(enc(t.original), TEXT_FIXTURE, [step]).text), + 'as core’s own save leaves it', + ); + } finally { + await t.close(); + } + }); + + test('a rename the Prototype cannot follow is refused with core’s reason, and writes nothing', async () => { + const t = await openText('fn_rename_text_refused.sldd', (text) => + replacedOnce(text, [['"Prototype": "y = f(u,v)"', '"Prototype": "y = step(u,v)"']]), + ); + try { + const posts = await t.rename(ONE_RENAME[0]); + const refusals = refusalsIn(posts); + assert.strictEqual(refusals.length, 1, `one validationError (${JSON.stringify(posts.map((m) => m.type))})`); + const reason = coreRefusal(enc(t.original), TEXT_FIXTURE, ONE_RENAME[0]); + assert.ok(reason.startsWith("'f' cannot be renamed: its Prototype 'y = step(u,v)'"), reason); + assert.strictEqual(refusals[0].reason, reason, 'carrying core’s own reason'); + assert.strictEqual(t.changes.length, 0, 'no write'); + assert.strictEqual(t.doc.getText(), t.original); + assert.strictEqual(t.doc.isDirty, false); + } finally { + await t.close(); + } + }); + + test('undo is one step: the inverse of the one write brings back the original bytes', async () => { + const t = await openText('fn_rename_text_undo.sldd'); + try { + assert.deepStrictEqual(problemsIn(await t.rename(ONE_RENAME[0])), [], 'the rename was accepted'); + const core = coreSaved(enc(t.original), TEXT_FIXTURE, ONE_RENAME); + assert.strictEqual(catalogPartOf(t.doc.getText()), catalogPartOf(core.text), 'the write carried the catalog'); + assert.strictEqual(t.changes.length, 1, 'in one write, so one step on the undo stack'); + + // Each change's range is in the text before the write; in the text after it, a range + // starts where it did plus what the changes before it grew or shrank by. + const written = [...t.changes[0].contentChanges].sort((a, b) => a.rangeOffset - b.rangeOffset); + const inverse = new vscode.WorkspaceEdit(); + let shift = 0; + for (const c of written) { + const start = c.rangeOffset + shift; + inverse.replace( + t.uri, + new vscode.Range(t.doc.positionAt(start), t.doc.positionAt(start + c.text.length)), + t.original.slice(c.rangeOffset, c.rangeOffset + c.rangeLength), + ); + shift += c.text.length - c.rangeLength; + } + assert.ok(await vscode.workspace.applyEdit(inverse), 'the undo applied'); + await waitFor('the undo to land', () => t.changes.length === 2); + assert.strictEqual(t.doc.getText(), t.original, 'the document is the bytes it opened with'); + } finally { + await t.close(); + } + }); +}); + +// --- the binary editor ------------------------------------------------------------------- + +/** A working copy of the binary fixture (its members rewritten by `edit` first), open in the real provider. */ +async function openBinary(copyName: string, edit?: (zip: Record) => void) { + const zip = unzipSync(await vscode.workspace.fs.readFile(fixtureUri(BINARY_FIXTURE))); + edit?.(zip); + const bytes = zipSync(zip); + const uri = workingUri(copyName); + await vscode.workspace.fs.writeFile(uri, bytes); + const provider = new BinarySlddEditorProvider(ctx()); + const edits: vscode.CustomDocumentEditEvent[] = []; + const sub = provider.onDidChangeCustomDocument((e) => edits.push(e)); + const doc: any = await provider.openCustomDocument(uri, {} as vscode.CustomDocumentOpenContext, token()); + const view = fakePanel(); + await provider.resolveCustomEditor(doc, view.panel, token()); + // No waits in here: this provider answers `ready` and `edit` synchronously. + view.send({ type: 'ready' }); + assert.ok(view.posts.some((m) => m.type === 'setRows'), 'the first paint sent the whole table'); + + /** Send one rename; the posts it caused. */ + const rename = (step: Step): any[] => { + const row = rowOf(view.posts, step); + const mark = view.posts.length; + view.send(renameMessage(row, step.to)); + return view.posts.slice(mark); + }; + + /** Save, and read back the members the file now holds. */ + const save = async (): Promise> => { + await provider.saveCustomDocument(doc, token()); + return unzipSync(await vscode.workspace.fs.readFile(uri)); + }; + + const close = async () => { + view.close(); + sub.dispose(); + doc.dispose(); + await vscode.workspace.fs.delete(uri); + }; + return { doc, bytes, original: zip, edits, rename, save, close }; +} + +/** The catalog member core's own save writes after `steps`. */ +const coreSavedMember = (bytes: Uint8Array, steps: Step[]): string => { + const out = coreSaved(bytes, BINARY_FIXTURE, steps); + assert.strictEqual(out.kind, 'binary'); + return dec(unzipSync(out.bytes)[SC_PART_XML]); +}; + +suite('A service function rename through the binary editor carries into the catalog', () => { + suiteSetup(async () => { + await vscode.extensions.getExtension('mathworks.simulink-data-explorer')?.activate(); + }); + + test('one rename swaps the member for one with the function’s p_Name and p_FunctionPrototype, on one undo step', async () => { + const b = await openBinary('fn_rename_binary_one.sldd'); + try { + assert.deepStrictEqual(problemsIn(b.rename(ONE_RENAME[0])), [], 'the rename was accepted'); + assert.strictEqual(b.edits.length, 1, 'one edit'); + assert.ok(b.doc.chunkXml.includes('y = compute(u)'), 'the entry’s Prototype follows the Name'); + assert.strictEqual( + dec(b.doc.zipMeta[SC_PART_XML]), + replacedOnce(dec(b.original[SC_PART_XML]), [ + ['y = f(u)', 'y = compute(u)'], + ['f\n ', 'compute\n '], + ]), + 'the catalog moves at the function’s two sites: the data interface named f stays', + ); + + const saved = await b.save(); + assert.ok(dec(saved[CHUNK_XML]).includes('y = compute(u)'), 'the saved chunk holds the rename'); + assert.strictEqual( + dec(saved[SC_PART_XML]), + coreSavedMember(b.bytes, ONE_RENAME), + 'the saved catalog is the one core’s own save writes', + ); + } finally { + await b.close(); + } + }); + + for (const [label, steps] of SEQUENCES) { + test(`writes the catalog core’s own save writes: ${label}`, async () => { + const b = await openBinary(`fn_rename_binary_${label.replace(/\W+/g, '_')}.sldd`); + try { + for (const step of steps) { + assert.deepStrictEqual(problemsIn(b.rename(step)), [], `${step.fn ?? step.entry} -> ${step.to} was accepted`); + } + assert.strictEqual(b.edits.length, steps.length, 'one edit per rename'); + const saved = await b.save(); + assert.strictEqual( + dec(saved[SC_PART_XML]), + coreSavedMember(b.bytes, steps), + 'the saved catalog is the one core’s own save writes', + ); + } finally { + await b.close(); + } + }); + } + + test('a function the catalog does not list leaves the member untouched', async () => { + // The catalog stops listing `g`: drop its function element, so `g` is in the chunk alone. + const b = await openBinary('fn_rename_binary_unlisted.sldd', (zip) => { + const xml = dec(zip[SC_PART_XML]); + const listed = xml.match(/\n *]*uuid="fe-g">[\s\S]*?<\/p_FunctionElements>/g) ?? []; + assert.strictEqual(listed.length, 1, 'the fixture’s catalog lists g once'); + zip[SC_PART_XML] = enc(xml.replace(listed[0], '')); + }); + const step: Step = { entry: 'ServiceInterface', fn: 'g', to: 'h' }; + try { + const member = b.doc.zipMeta[SC_PART_XML]; + assert.deepStrictEqual(problemsIn(b.rename(step)), [], 'the rename was accepted'); + assert.strictEqual(b.edits.length, 1, 'one edit'); + assert.ok(b.doc.chunkXml.includes('[a,b] = h(g)'), 'the entry was renamed'); + assert.strictEqual(b.doc.zipMeta[SC_PART_XML], member, 'and the member is the one read, unswapped'); + const saved = await b.save(); + assert.deepStrictEqual(saved[SC_PART_XML], b.original[SC_PART_XML], 'so the save writes it as read'); + assert.strictEqual(dec(saved[SC_PART_XML]), coreSavedMember(b.bytes, [step]), 'as core’s own save leaves it'); + } finally { + await b.close(); + } + }); + + test('a rename the Prototype cannot follow is refused with core’s reason, and writes nothing', async () => { + const b = await openBinary('fn_rename_binary_refused.sldd', (zip) => { + zip[CHUNK_XML] = enc(replacedOnce(dec(zip[CHUNK_XML]), [['>y = f(u)<', '>y = step(u)<']])); + }); + try { + const chunk = b.doc.chunkXml; + const member = b.doc.zipMeta[SC_PART_XML]; + const posts = b.rename(ONE_RENAME[0]); + const refusals = refusalsIn(posts); + assert.strictEqual(refusals.length, 1, `one validationError (${JSON.stringify(posts.map((m) => m.type))})`); + const reason = coreRefusal(b.bytes, BINARY_FIXTURE, ONE_RENAME[0]); + assert.ok(reason.startsWith("'f' cannot be renamed: its Prototype 'y = step(u)'"), reason); + assert.strictEqual(refusals[0].reason, reason, 'carrying core’s own reason'); + assert.strictEqual(b.edits.length, 0, 'no edit'); + assert.strictEqual(b.doc.chunkXml, chunk, 'the chunk is as read'); + assert.strictEqual(b.doc.zipMeta[SC_PART_XML], member, 'and so is the member'); + } finally { + await b.close(); + } + }); + + test('undo is one step, and puts the member back byte for byte', async () => { + const b = await openBinary('fn_rename_binary_undo.sldd'); + try { + const chunk = b.doc.chunkXml; + const member = b.doc.zipMeta[SC_PART_XML]; + assert.deepStrictEqual(problemsIn(b.rename(ONE_RENAME[0])), [], 'the rename was accepted'); + assert.strictEqual(b.edits.length, 1, 'one edit, so one step on the undo stack'); + assert.strictEqual( + dec(b.doc.zipMeta[SC_PART_XML]), + coreSavedMember(b.bytes, ONE_RENAME), + 'the edit swapped in core’s catalog', + ); + + b.edits[0].undo(); + assert.strictEqual(b.doc.chunkXml, chunk, 'undo restored the chunk'); + assert.strictEqual(b.doc.zipMeta[SC_PART_XML], member, 'and the member read from the file'); + const saved = await b.save(); + assert.deepStrictEqual(saved[SC_PART_XML], b.original[SC_PART_XML], 'which a save writes byte for byte'); + } finally { + await b.close(); + } + }); +}); diff --git a/test/fixtures/arch_service_binary.sldd b/test/fixtures/arch_service_binary.sldd new file mode 100644 index 0000000..d4a7aed Binary files /dev/null and b/test/fixtures/arch_service_binary.sldd differ diff --git a/test/fixtures/make-fixtures.mjs b/test/fixtures/make-fixtures.mjs index 9ed2739..e2f910d 100644 --- a/test/fixtures/make-fixtures.mjs +++ b/test/fixtures/make-fixtures.mjs @@ -572,6 +572,75 @@ writeFileSync( }), ); +// --- arch_service_binary.sldd: a SERVICE INTERFACE in the compressed-binary format --- +// +// The fixture a FUNCTION rename's carry needs, which `arch_binary.sldd` cannot be: it has no +// service interface, and other suites count its entries. The catalog keeps its own copy of +// each function of a service interface (`p_Name` and `p_FunctionPrototype` under the +// interface's definition), so renaming a function has to patch that member too. +// +// The same shape as core's functionElementRename.test.ts, which is the corpus's one binary +// dictionary with a service interface (example/dd1.sldd), trimmed: `ServiceInterface` holds +// `f: y = f(u)` and `g: [a,b] = g(g)`. It carries two traps, because the host patches text: +// a DATA interface that is also named `f`, and `g`'s first argument, which is named `g`. A +// rename of either function that took either of those would corrupt something else. +const svcArg = (argName) => + `

Fixed

${argName}

double

real

1.0

`; +const svcFn = (fnName, prototype, args) => + `

${prototype}

${fnName}

0

${args.map(svcArg).join('')}

`; +const svcChunkXml = + `\n` + + `\n` + + ` \n` + + `

ServiceInterface

\n` + + `

1c399950-c59b-404a-91e9-0cd02c0527ce

\n` + + `

${ARCH_NS}

\n` + + `

20260306T201108.832214

\n` + + `

user

\n` + + `

1

\n` + + `

${svcFn('f', 'y = f(u)', ['u', 'y'])}${svcFn('g', '[a,b] = g(g)', ['g', 'a', 'b'])}

\n` + + `
\n` + + `
\n`; +const svcFnXml = (fnName, prototype, args) => + `\n ` + + args + .map( + (a) => + `\n ` + + `\n ${a}` + + `\n `, + ) + .join('') + + `\n ${prototype}` + + `\n 1` + + `\n ${fnName}` + + `\n `; +const svcScXml = + `\n` + + `\n` + + ` \n` + + ` svc\n` + + ` \n` + + ` \n` + + ` f\n` + + ` \n` + + ` ` + + svcFnXml('f', 'y = f(u)', ['u', 'y']) + + svcFnXml('g', '[a,b] = g(g)', ['g', 'a', 'b']) + + `\n ServiceInterface\n` + + ` \n` + + ` \n` + + ` \n` + + `\n`; +writeFileSync( + here('arch_service_binary.sldd'), + zipSync({ + 'data/chunk0.xml': strToU8(svcChunkXml), + 'simulink/systemcomposer/interfaceDictionary.xml': strToU8(svcScXml), + 'metadata/mwcoreProperties.xml': strToU8(`R2027a`), + }), +); + // --- chain_*: a model whose dictionary chain runs THROUGH A COMPRESSED dictionary --- // // The set that pins the cheap tier's one real hazard. Scoping a Usage answer to "the models @@ -680,6 +749,6 @@ writeFileSync( console.log( 'wrote model_with_refs.slx, model_with_refs.mdl, legacy_ctrl.mdl, compressed.sldd, ' + 'object_array_binary.sldd, nd_numeric.mat, params.sldd, shared_gain.slx, shadow_ws.slx, ' + - 'sid_blocks.slx, arch_binary.sldd, chain_top.sldd, chain_leaf.sldd, chain_model.slx, ' + + 'sid_blocks.slx, arch_binary.sldd, arch_service_binary.sldd, chain_top.sldd, chain_leaf.sldd, chain_model.slx, ' + 'shadow_first.sldd, shadow_second.sldd, shadow_pair.slx', ); diff --git a/test/scRenameCarry.test.ts b/test/scRenameCarry.test.ts index 8c2f879..f85cd97 100644 --- a/test/scRenameCarry.test.ts +++ b/test/scRenameCarry.test.ts @@ -41,6 +41,8 @@ import { DataModel, SlddNode, SC_PART_XML, + createSession, + ingest, serializeEntryToXml, entrySelectorOf, findEntryObjectSpan, @@ -50,7 +52,14 @@ import { findEntrySpan, detectIndent } from '../src/host/entrySplice.js'; import { reserializeEntry, findOwningEntry } from '../src/host/structuralEdit.js'; import { mutateEntry } from '../src/host/entryOps.js'; import { readSlddParts } from '../src/host/slddContent.js'; -import { catalogRenameOf, scJsonRenameEdits, scXmlRenamePatch } from '../src/host/scRename.js'; +import { + catalogFunctionRenameOf, + catalogRenameOf, + scJsonFunctionRenameEdits, + scJsonRenameEdits, + scXmlFunctionRenamePatch, + scXmlRenamePatch, +} from '../src/host/scRename.js'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -72,23 +81,32 @@ const kindsOf = (sldd: SlddNode): Record => { const openUris: string[] = []; -/** SlddTextEditorProvider's applyEdit, minus the parts that need a webview. */ -function openJson(uri: string) { +/** + * SlddTextEditorProvider's applyEdit, minus the parts that need a webview. + * + * `edit` rewrites the fixture's text before anything reads it, for a case the checked-in + * file does not have (a Prototype that does not declare its own function). + */ +function openJson(uri: string, edit: (text: string) => string = (text) => text) { if (!openUris.includes(uri)) openUris.push(uri); DataModel.removeDataSource(uri); invalidate(uri); - const doc = { text: readFileSync(fixture('./fixtures/arch.sldd'), 'utf8') }; + const doc = { text: edit(readFileSync(fixture('./fixtures/arch.sldd'), 'utf8')) }; const live = () => getModel(uri, 'arch.sldd', doc.text); const entryNamed = (name: string) => (live() as any).children.flatMap((s: any) => s.children).find((e: any) => e.name === name); + /** A function of a service interface entry, as its row resolves. */ + const functionNamed = (entryName: string, name: string) => + entryNamed(entryName).children.find((c: any) => c.name === name); /** * A table rename, as the provider composes it: read the document text, mutate the * tree it is holding, then submit ONE edit holding the entry's new span text and - * every catalog site that names it. + * every catalog site that names it. A refused rename returns the refusal and writes + * nothing, which is where the provider's validationError branch returns. */ - const rename = (rowId: string, newValue: string) => { + const attemptRename = (rowId: string, newValue: string) => { const currentText = doc.text; live(); const node = findNode(uri, rowId); @@ -97,17 +115,31 @@ function openJson(uri: string) { const selector = entrySelectorOf(entry); // Read BEFORE the mutation: the catalog is keyed by the name the file still spells. const carry = catalogRenameOf('Name', newValue, node, entry); + const functionCarry = catalogFunctionRenameOf('Name', newValue, node, entry); - expect(mutateEntry(entry, () => (node as any).setProperty('Name', newValue))).toBe(true); + const result = mutateEntry(entry, () => (node as any).setProperty('Name', newValue)); + if (result !== true) { + return { result, edits: [], carry, functionCarry }; + } const span = findEntrySpan(currentText, selector); expect(span, `the entry text for "${selector.name}" is locatable`).not.toBeNull(); const edits = [ { start: span!.offset, end: span!.offset + span!.length, text: reserializeEntry(entry, detectIndent(currentText)) }, - ...(carry ? scJsonRenameEdits(currentText, carry.oldName, carry.newName) : []), + ...(carry + ? scJsonRenameEdits(currentText, carry.oldName, carry.newName) + : functionCarry + ? scJsonFunctionRenameEdits(currentText, functionCarry) + : []), ]; doc.text = applyDocEdits(currentText, edits); - return { edits, carry }; + return { result, edits, carry, functionCarry }; + }; + + const rename = (rowId: string, newValue: string) => { + const attempt = attemptRename(rowId, newValue); + expect(attempt.result, `the rename of row ${rowId} to ${newValue} is accepted`).toBe(true); + return attempt; }; /** post(): the wide repaint every multi-change event falls back to. */ @@ -116,7 +148,7 @@ function openJson(uri: string) { return getModel(uri, 'arch.sldd', doc.text) as unknown as SlddNode; }; - return { doc, live, entryNamed, rename, reread }; + return { doc, live, entryNamed, functionNamed, attemptRename, rename, reread }; } /** @@ -153,9 +185,9 @@ afterAll(() => { // --------------------------------------------------------------------------- /** BinarySlddEditorProvider's document, at the size this question needs. */ -function openBinary() { +function openBinary(fixtureName = 'arch_binary.sldd') { const srcId = 'screname:' + Math.random(); - const zip = unzipSync(new Uint8Array(readFileSync(fixture('./fixtures/arch_binary.sldd')))); + const zip = unzipSync(new Uint8Array(readFileSync(fixture('./fixtures/' + fixtureName)))); const zipMeta: Record = {}; for (const [member, data] of Object.entries(zip)) if (member !== 'data/chunk0.xml') zipMeta[member] = data; const doc = { chunkXml: new TextDecoder().decode(zip['data/chunk0.xml']), zipMeta }; @@ -163,7 +195,7 @@ function openBinary() { const build = () => { DataModel.removeDataSource(srcId); return DataModel.addDataSource(srcId, readSlddParts(doc.chunkXml, doc.zipMeta), { - path: 'arch_binary.sldd', + path: fixtureName, }) as unknown as SlddNode; }; const live = () => ((DataModel as any).getDataSource(srcId) ?? build()) as SlddNode; @@ -174,20 +206,36 @@ function openBinary() { * A table rename, as the provider composes it: splice the entry's `` in * chunkXml and swap the catalog member, then hand both to pushEdit so undo can put * them back together. + * + * `child` names a row under the entry (a service interface's function) to rename instead + * of the entry itself. A refused rename returns the refusal and patches nothing, which is + * where the provider's validationError branch returns. */ - const rename = (name: string, newValue: string) => { + const attemptRename = (name: string, newValue: string, child?: string) => { const entry = entryNamed(name); + const node = child === undefined ? entry : entry.children.find((c: any) => c.name === child); + expect(node, `the entry ${name} has a row named ${child}`).toBeTruthy(); const selector = entrySelectorOf(entry); - const carry = catalogRenameOf('Name', newValue, entry, entry); - const part = carry ? scXmlRenamePatch(doc.zipMeta, carry.oldName, carry.newName) : null; + // Both read BEFORE the mutation: the catalog is keyed by the names the file still spells. + const carry = catalogRenameOf('Name', newValue, node, entry); + const functionCarry = catalogFunctionRenameOf('Name', newValue, node, entry); - expect(DataModel.mutateSubtree(entry, () => entry.setProperty('Name', newValue))).toBe(true); + const result = mutateEntry(entry, () => node.setProperty('Name', newValue)); + if (result !== true) { + return { result, part: null, undo: () => {}, carry, functionCarry }; + } const before = doc.chunkXml; const span = findEntryObjectSpan(before, selector); expect(span, `the entry XML for "${name}" is locatable`).not.toBeNull(); const frag = serializeEntryToXml(entry).replace(/\n$/, ''); const after = before.slice(0, span!.offset) + frag + before.slice(span!.offset + span!.length); + // After the validation gate, as in the provider: a refused edit reads no part. + const part = carry + ? scXmlRenamePatch(doc.zipMeta, carry.oldName, carry.newName) + : functionCarry + ? scXmlFunctionRenamePatch(doc.zipMeta, functionCarry) + : null; // pushEdit's forward half, and the undo it hands VS Code. doc.chunkXml = after; @@ -196,27 +244,36 @@ function openBinary() { doc.chunkXml = before; if (part) doc.zipMeta[part.member] = part.before; }; - return { part, undo }; + return { result, part, undo, carry, functionCarry }; + }; + + const rename = (name: string, newValue: string, child?: string) => { + const attempt = attemptRename(name, newValue, child); + expect(attempt.result, `the rename of ${child ?? name} to ${newValue} is accepted`).toBe(true); + return attempt; }; /** repaintAll(): the wide re-read of chunk + pass-through parts. */ const reread = () => build(); - /** writeTo(): the re-zip a save performs, read back as a file would be. */ - const roundTrip = () => { + /** writeTo()'s re-zip, unzipped again: the members a save puts in the file. */ + const saved = () => { const zipped = zipSync({ ...doc.zipMeta, 'data/chunk0.xml': new TextEncoder().encode(doc.chunkXml) }); const files = unzipSync(zipped); const meta: Record = {}; for (const [member, data] of Object.entries(files)) if (member !== 'data/chunk0.xml') meta[member] = data; - return SlddNode.parse( - readSlddParts(new TextDecoder().decode(files['data/chunk0.xml']), meta), - 'arch_binary.sldd', - ); + return { chunkXml: new TextDecoder().decode(files['data/chunk0.xml']), zipMeta: meta }; + }; + + /** writeTo(): the re-zip a save performs, read back as a file would be. */ + const roundTrip = () => { + const files = saved(); + return SlddNode.parse(readSlddParts(files.chunkXml, files.zipMeta), fixtureName); }; const dispose = () => DataModel.removeDataSource(srcId); - return { doc, live, entryNamed, rename, reread, roundTrip, dispose }; + return { doc, live, entryNamed, attemptRename, rename, reread, saved, roundTrip, dispose }; } // --------------------------------------------------------------------------- @@ -393,3 +450,413 @@ describe('carrying a rename in a compressed-binary dictionary', () => { expect(scXmlRenamePatch(zipMeta, 'Kp', 'Ki')).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// A service interface's FUNCTION, which the catalog keeps a copy of too. +// --------------------------------------------------------------------------- +// +// A `Simulink.FunctionElement` is a row under its ServiceBus entry, so the entry gate above +// never sees it. But the catalog spells each function of a service interface itself, under +// that interface's definition: its `p_Name`, and its `p_FunctionPrototype`, which declares it +// BY NAME (`y = f(u,v)`). Core renames the data part's Prototype with the Name (and refuses a +// rename the Prototype cannot follow), and its own save carries the rename into the catalog. +// Neither editor saves through core, so each has to carry it the way it carries an entry +// rename: extra ranges in the same WorkspaceEdit, or a member swap on the same undo step. +// +// The standard is core's save, not a description of it: every scenario below is run twice, +// once through the provider's composition and once through a fresh core session's +// `serializeSource`, and the two catalogs must be the same text. + +const CATALOG_KEY = '"__MW_TEXT_PART__/simulink/systemcomposer/interfaceDictionary"'; + +/** A textual dictionary's catalog part: from its key to the end of the text. */ +function catalogPartOf(text: string): string { + const at = text.indexOf(CATALOG_KEY); + expect(at, 'the dictionary has a catalog part').toBeGreaterThan(0); + return text.slice(at); +} + +/** Everything before the catalog part: the entries. */ +const dataPartOf = (text: string): string => text.slice(0, text.indexOf(CATALOG_KEY)); + +/** `text` with each `from` replaced by its `to`, checking first that `from` occurs exactly once. */ +function replacedOnce(text: string, pairs: [from: string, to: string][]): string { + return pairs.reduce((out, [from, to]) => { + expect(out.split(from).length - 1, `"${from}" occurs exactly once`).toBe(1); + return out.replace(from, () => to); + }, text); +} + +/** One rename: of the entry `entry`, or of its function `fn`, to `to`. */ +interface Step { + entry: string; + fn?: string; + to: string; +} + +/** Core's own save of `content` after `steps` ran on a fresh session's tree. */ +function coreSaved(content: Uint8Array, filename: string, steps: Step[]) { + const session = createSession(); + const src: any = ingest(session, content, { filename }); + steps.forEach(({ entry, fn, to }) => { + const e = src.children.flatMap((s: any) => s.children).find((x: any) => x.name === entry); + const node = fn === undefined ? e : e.children.find((c: any) => c.name === fn); + expect(node.setProperty('Name', to), `core accepts ${fn ?? entry} -> ${to}`).toBe(true); + }); + const out = session.serializeSource(src.name)!; + session.removeDataSource(src.name); + return out; +} + +/** The scenarios a record of renames has to get right, and an editor that edits as it goes. */ +const STEPS: [label: string, steps: Step[]][] = [ + ['one rename', [{ entry: 'ServiceInterface', fn: 'f', to: 'compute' }]], + [ + 'the interface renamed first, then its function', + [ + { entry: 'ServiceInterface', to: 'Svc' }, + { entry: 'Svc', fn: 'f', to: 'compute' }, + ], + ], + [ + 'the function renamed first, then its interface', + [ + { entry: 'ServiceInterface', fn: 'f', to: 'compute' }, + { entry: 'ServiceInterface', to: 'Svc' }, + ], + ], + [ + 'a chain', + [ + { entry: 'ServiceInterface', fn: 'f', to: 'compute' }, + { entry: 'ServiceInterface', fn: 'compute', to: 'step' }, + ], + ], + [ + 'a rename back', + [ + { entry: 'ServiceInterface', fn: 'f', to: 'compute' }, + { entry: 'ServiceInterface', fn: 'compute', to: 'f' }, + ], + ], +]; + +describe('the function renames that reach the catalog', () => { + const entry = { name: 'ServiceInterface', className: 'Simulink.ServiceBus' }; + const fn = { name: 'f', className: 'Simulink.FunctionElement', parent: entry }; + + it('carries a function of a top-level service interface, keyed by the interface', () => { + expect(catalogFunctionRenameOf('Name', 'compute', fn, entry)).toEqual({ + interfaceName: 'ServiceInterface', + oldName: 'f', + newName: 'compute', + }); + }); + + it('carries the rows a real service interface reads as', () => { + // The fakes above only stand for the tree if a real function row has the entry as its + // parent and the class the gate asks for, which is what this pins. + const d = openJson('test://sc-fn-gate.sldd'); + const real = d.functionNamed('ServiceInterface', 'f'); + expect(catalogFunctionRenameOf('Name', 'compute', real, d.entryNamed('ServiceInterface'))).toEqual({ + interfaceName: 'ServiceInterface', + oldName: 'f', + newName: 'compute', + }); + }); + + it('does not carry an edit to any other column', () => { + expect(catalogFunctionRenameOf('Description', 'a note', fn, entry)).toBeNull(); + expect(catalogFunctionRenameOf('Value', 'y = g(u)', fn, entry)).toBeNull(); + }); + + it('does not carry a rename that renames nothing', () => { + expect(catalogFunctionRenameOf('Name', 'f', fn, entry)).toBeNull(); + }); + + it('does not carry an element of a data bus', () => { + const bus = { name: 'DataInterface', className: 'Simulink.Bus' }; + expect(catalogFunctionRenameOf('Name', 'Speed', { name: 'ValueType', className: 'Simulink.BusElement', parent: bus }, bus)).toBeNull(); + }); + + it('does not carry a row deeper than the function, such as one of its arguments', () => { + const arg = { name: 'u', className: 'Simulink.BusElement', parent: fn }; + expect(catalogFunctionRenameOf('Name', 'x', arg, entry)).toBeNull(); + }); + + it('does not carry the entry itself, which is the entry gate’s case, so the two never both fire', () => { + expect(catalogFunctionRenameOf('Name', 'Svc', entry, entry)).toBeNull(); + expect(catalogRenameOf('Name', 'Svc', entry, entry)).not.toBeNull(); + expect(catalogRenameOf('Name', 'compute', fn, entry)).toBeNull(); + }); +}); + +describe('carrying a function rename in an uncompressed-text dictionary', () => { + it('writes the function’s two catalog sites, and nothing else, in the same edit', () => { + const d = openJson('test://sc-fn-json-sites.sldd'); + const before = d.doc.text; + + const { edits, carry, functionCarry } = d.rename(d.functionNamed('ServiceInterface', 'f').id, 'compute'); + + expect(carry).toBeNull(); + expect(functionCarry).toEqual({ interfaceName: 'ServiceInterface', oldName: 'f', newName: 'compute' }); + // The entry span, the function's `p_Name`, and its `p_FunctionPrototype`. + expect(edits).toHaveLength(3); + // The entries are what the entry span alone writes: the Name and its Prototype. + expect(dataPartOf(d.doc.text)).toBe(dataPartOf(applyDocEdits(before, [edits[0]]))); + expect(dataPartOf(d.doc.text)).toContain('"Name": "compute"'); + expect(dataPartOf(d.doc.text)).toContain('"Prototype": "y = compute(u,v)"'); + // And the catalog differs from the one read at exactly the function's own two sites. + expect(catalogPartOf(d.doc.text)).toBe( + replacedOnce(catalogPartOf(before), [ + ['"p_FunctionPrototype": "y = f(u,v)"', '"p_FunctionPrototype": "y = compute(u,v)"'], + ['"p_Name": "f"', '"p_Name": "compute"'], + ]), + ); + }); + + it.each(STEPS)('writes the catalog core’s own save writes: %s', (_label, steps) => { + const d = openJson('test://sc-fn-json-core.sldd'); + const original = d.doc.text; + + steps.forEach(({ entry, fn, to }) => { + const row = fn === undefined ? d.entryNamed(entry) : d.functionNamed(entry, fn); + const { edits } = d.rename(row.id, to); + // A multi-range edit takes the wide repaint, which re-reads the document. + if (edits.length > 1) d.reread(); + }); + + const core = coreSaved(new TextEncoder().encode(original), 'arch.sldd', steps); + expect(core.kind).toBe('text'); + expect(catalogPartOf(d.doc.text)).toBe(catalogPartOf((core as { text: string }).text)); + }); + + it('writes the catalog core’s own save writes for a swap, which the sibling check makes three renames', () => { + const d = openJson('test://sc-fn-json-swap.sldd'); + const original = d.doc.text; + const steps: Step[] = [ + { entry: 'ServiceInterface', fn: 'f', to: 'tmp' }, + { entry: 'ServiceInterface', fn: 'f1', to: 'f' }, + { entry: 'ServiceInterface', fn: 'tmp', to: 'f1' }, + ]; + steps.forEach(({ entry, fn, to }) => { + d.rename(d.functionNamed(entry, fn!).id, to); + d.reread(); + }); + + const core = coreSaved(new TextEncoder().encode(original), 'arch.sldd', steps) as { text: string }; + expect(catalogPartOf(d.doc.text)).toBe(catalogPartOf(core.text)); + expect(catalogPartOf(d.doc.text)).toContain('"p_FunctionPrototype": "y = f(u,v,w,y)"'); + expect(catalogPartOf(d.doc.text)).toContain('"p_FunctionPrototype": "y = f1(u,v)"'); + }); + + it('still reads as a service interface that lists the renamed function', () => { + const d = openJson('test://sc-fn-json-reread.sldd'); + const catalogBefore = d.live().systemComposer; + + d.rename(d.functionNamed('ServiceInterface', 'f').id, 'compute'); + + const reread = d.reread() as any; + expect(kindsOf(reread).ServiceInterface).toBe('Service Interface'); + const names = d.entryNamed('ServiceInterface').children.map((c: any) => c.name); + expect(names).toContain('compute'); + expect(names).not.toContain('f'); + expect(reread.systemComposer.interfaces).toEqual(catalogBefore.interfaces); + }); + + it('writes nothing extra for a function the catalog does not list', () => { + // `f2` is in the entry and not in the catalog: the gate passes it, and the catalog has + // no site for it, so the edit is the entry span alone and the catalog is as read. + const d = openJson('test://sc-fn-json-unlisted.sldd'); + const before = d.doc.text; + + const { edits, functionCarry } = d.rename(d.functionNamed('ServiceInterface', 'f2').id, 'h'); + + expect(functionCarry).toEqual({ interfaceName: 'ServiceInterface', oldName: 'f2', newName: 'h' }); + expect(edits).toHaveLength(1); + expect(catalogPartOf(d.doc.text)).toBe(catalogPartOf(before)); + }); + + it('refuses a rename the Prototype cannot follow, with core’s reason, and writes nothing', () => { + // A Prototype that does not declare the function by its own name: core cannot rename the + // two together, and MATLAB requires them to match, so the rename is refused. That refusal + // is the `{ error, reason }` every other refused edit returns, which the provider shows. + const d = openJson('test://sc-fn-json-prototype.sldd', (text) => + replacedOnce(text, [['"Prototype": "y = f(u,v)"', '"Prototype": "y = step(u,v)"']]), + ); + const before = d.doc.text; + + const { result, edits } = d.attemptRename(d.functionNamed('ServiceInterface', 'f').id, 'compute'); + + expect(result).toMatchObject({ error: true }); + expect((result as { reason: string }).reason).toContain("'f' cannot be renamed: its Prototype 'y = step(u,v)'"); + expect(edits).toEqual([]); + expect(d.doc.text).toBe(before); + expect(d.functionNamed('ServiceInterface', 'f').Prototype).toBe('y = step(u,v)'); + }); + + it('refuses a sibling’s name, and writes nothing', () => { + const d = openJson('test://sc-fn-json-sibling.sldd'); + const before = d.doc.text; + + const { result, edits } = d.attemptRename(d.functionNamed('ServiceInterface', 'f').id, 'f1'); + + expect(result).toMatchObject({ error: true }); + expect(edits).toEqual([]); + expect(d.doc.text).toBe(before); + }); +}); + +describe('carrying a function rename in a compressed-binary dictionary', () => { + const SVC = 'arch_service_binary.sldd'; + const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + const coreSavedMember = (steps: Step[]) => { + const out = coreSaved(new Uint8Array(readFileSync(fixture('./fixtures/' + SVC))), SVC, steps); + expect(out.kind).toBe('binary'); + return decode(unzipSync((out as { bytes: Uint8Array }).bytes)[SC_PART_XML]); + }; + + it('patches the function’s two catalog sites, and nothing else', () => { + const d = openBinary(SVC); + try { + const original = d.doc.zipMeta[SC_PART_XML]; + + const { part, functionCarry } = d.rename('ServiceInterface', 'compute', 'f'); + + expect(functionCarry).toEqual({ interfaceName: 'ServiceInterface', oldName: 'f', newName: 'compute' }); + expect(part?.member).toBe(SC_PART_XML); + // `before` is the member's original bytes, so undo restores the file as read. + expect(part!.before).toBe(original); + expect(decode(part!.after)).toBe( + replacedOnce(decode(original), [ + ['y = f(u)', 'y = compute(u)'], + ['f\n ', 'compute\n '], + ]), + ); + expect(d.doc.chunkXml).toContain('>compute<'); + expect(d.doc.chunkXml).toContain('y = compute(u)'); + } finally { + d.dispose(); + } + }); + + it('leaves the data interface named f, and g’s argument named g, alone', () => { + const d = openBinary(SVC); + try { + const original = decode(d.doc.zipMeta[SC_PART_XML]); + + const { part } = d.rename('ServiceInterface', 'h', 'g'); + + expect(decode(part!.after)).toBe( + replacedOnce(original, [ + ['[a,b] = g(g)', '[a,b] = h(g)'], + ['g\n ', 'h\n '], + ]), + ); + } finally { + d.dispose(); + } + }); + + it.each(STEPS)('writes the member core’s own save writes: %s', (_label, steps) => { + const d = openBinary(SVC); + try { + steps.forEach(({ entry, fn, to }) => { + const { part } = d.rename(entry, to, fn); + // A part forces the wide repaint, which re-reads chunk and parts together. + if (part) d.reread(); + }); + expect(decode(d.saved().zipMeta[SC_PART_XML])).toBe(coreSavedMember(steps)); + } finally { + d.dispose(); + } + }); + + it('survives the re-zip a save performs', () => { + const d = openBinary(SVC); + try { + const { part } = d.rename('ServiceInterface', 'compute', 'f'); + + const files = d.saved(); + expect(files.zipMeta[SC_PART_XML]).toEqual(part!.after); + expect(files.chunkXml).toContain('>compute<'); + const reread = d.roundTrip() as any; + expect(kindsOf(reread).ServiceInterface).toBe('Service Interface'); + } finally { + d.dispose(); + } + }); + + it('puts both surfaces back on undo, the member byte for byte', () => { + const d = openBinary(SVC); + try { + const originalPart = d.doc.zipMeta[SC_PART_XML]; + const originalChunk = d.doc.chunkXml; + + const { part, undo } = d.rename('ServiceInterface', 'compute', 'f'); + undo(); + + expect(d.doc.zipMeta[SC_PART_XML]).toBe(part!.before); + expect(d.doc.zipMeta[SC_PART_XML]).toBe(originalPart); + expect(d.doc.chunkXml).toBe(originalChunk); + } finally { + d.dispose(); + } + }); + + it('refuses a rename the Prototype cannot follow, with core’s reason, and patches nothing', () => { + const d = openBinary(SVC); + try { + d.doc.chunkXml = replacedOnce(d.doc.chunkXml, [['y = f(u)', 'y = step(u)']]); + const originalPart = d.doc.zipMeta[SC_PART_XML]; + const originalChunk = d.doc.chunkXml; + + const { result, part } = d.attemptRename('ServiceInterface', 'compute', 'f'); + + expect(result).toMatchObject({ error: true }); + expect((result as { reason: string }).reason).toContain("'f' cannot be renamed: its Prototype 'y = step(u)'"); + expect(part).toBeNull(); + expect(d.doc.zipMeta[SC_PART_XML]).toBe(originalPart); + expect(d.doc.chunkXml).toBe(originalChunk); + } finally { + d.dispose(); + } + }); + + it('refuses a sibling’s name, and patches nothing', () => { + const d = openBinary(SVC); + try { + const originalPart = d.doc.zipMeta[SC_PART_XML]; + + const { result, part } = d.attemptRename('ServiceInterface', 'g', 'f'); + + expect(result).toMatchObject({ error: true }); + expect(part).toBeNull(); + expect(d.doc.zipMeta[SC_PART_XML]).toBe(originalPart); + } finally { + d.dispose(); + } + }); + + it('does not patch for a function the catalog does not list', () => { + const d = openBinary(SVC); + try { + // A name no function has; the data interface `f`, which has no functions; and an + // interface the catalog does not define at all. + expect(scXmlFunctionRenamePatch(d.doc.zipMeta, { interfaceName: 'ServiceInterface', oldName: 'h', newName: 'k' })).toBeNull(); + expect(scXmlFunctionRenamePatch(d.doc.zipMeta, { interfaceName: 'f', oldName: 'f', newName: 'k' })).toBeNull(); + expect(scXmlFunctionRenamePatch(d.doc.zipMeta, { interfaceName: 'Other', oldName: 'f', newName: 'k' })).toBeNull(); + } finally { + d.dispose(); + } + }); + + it('does not patch a dictionary that has no catalog part at all', () => { + const zip = unzipSync(new Uint8Array(readFileSync(fixture('./fixtures/compressed.sldd')))); + const zipMeta: Record = {}; + for (const [member, data] of Object.entries(zip)) if (member !== 'data/chunk0.xml') zipMeta[member] = data; + + expect(zipMeta[SC_PART_XML]).toBeUndefined(); + expect(scXmlFunctionRenamePatch(zipMeta, { interfaceName: 'ServiceInterface', oldName: 'f', newName: 'k' })).toBeNull(); + }); +});