Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 138 additions & 3 deletions test/binaryEditKeepsUntouched.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
// (D1);
// * an edit and its undo give back the bytes with only the LastMod line new (D2);
// * what cannot be merged with certainty is written from the model whole, as every edited
// entry was: markup the scan cannot read, a duplicate name, an origin left in the text;
// entry was: markup the scan cannot read, a duplicate name, an origin left in the text, and a
// merge that, read again on its own, is not the model's text;
// * no origin the writers spell for the merge reaches a saved file;
// * the merge of a 5 MB entry costs a small multiple of writing it from the model;
// * and the three functions a host that splices chunk text needs: spliceEntryObject,
// keptPartsViolations and readEntryFragment.
// * and the three functions a host that splices chunk text needs: spliceEntryObject, which
// lays what it writes from the model out as the chunk is, keptPartsViolations, each rule it
// holds a write to, and readEntryFragment.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -448,6 +450,48 @@ describe('what cannot be merged with certainty is written from the model whole',
expect(out).toBe(regenerated(entry));
expect(out).not.toContain('dex-core-origin');
});

it('a merge that, read again on its own, is not the model\'s text: here, one that reads as no entry', () => {
// A struct entry with its Name line moved after its Value, as a hand edit may leave it, and a
// sibling MATLAB spelled `1.0E+6`. The reader reads both, and pairs each with its bytes: the
// first `<P Name="Name"` in s's bytes is its own Name line.
const moved = (object: string): string => {
const lines = object.split('\n');
const name = lines.splice(1, 1)[0];
lines.splice(lines.length - 2, 0, name);
return lines.join('\n');
};
const value = '<P Name="Value" Class="struct">\n <Element>\n <P Name="a" Class="double">1.0E+6</P>\n </Element>\n </P>';
const xml = chunk(moved(entryXml('s', 'u-s', value)) + entryXml('t', 'u-t', '<P Name="Value" Class="double">1.0E+6</P>'));
expect(objectOf(xml, 's').indexOf('<P Name="Name"')).toBeGreaterThan(objectOf(xml, 's').indexOf('<P Name="Value"'));
// The control: renamed, s keeps its bytes, the Name line where the file has it.
{
const { session, root } = fromChunk(xml);
expect(entrySource.entrySourceOf(entryNamed(root, 's'))?.fragment).toBe(objectOf(xml, 's'));
expect(entryNamed(root, 's').setProperty('Name', 'sRenamed')).toBe(true);
expect(objectOf(savedChunk(session, root), 'sRenamed')).toBe(renamedAndStamped(objectOf(xml, 's'), 'sRenamed'));
}
// A field named `Name` added to the struct. Merged, it would sit in the Value, before the
// entry's own Name line, and the reader, which pairs an entry with its bytes by the first Name
// line in them, would pair the merge with no entry: read again, it is not the model's text.
// So the entry is written from the model whole, its own Name line first.
const { session, root } = fromChunk(xml);
const s = entryNamed(root, 's');
const field = session.addChildTo(s.id);
expect(field).not.toBeNull();
expect(field.setProperty('Name', 'Name')).toBe(true);
expect(s.children.map((c: any) => c.name)).toEqual(['a', 'Name']);
const saved = savedChunk(session, root);
expect(objectOf(saved, 's')).toBe(regenerated(s));
expect(objectOf(saved, 's')).not.toContain('1.0E+6');
expect(objectOf(saved, 't')).toBe(objectOf(xml, 't'));
// Which keeps the saved chunk one the reader pairs: reopened, t, renamed, still keeps its
// bytes. Had the merge been written, no entry of the reopened chunk would have had any.
const again = fromChunk(saved);
expect(entrySource.entrySourceOf(entryNamed(again.root, 's'))?.fragment).toBe(objectOf(saved, 's'));
expect(entryNamed(again.root, 't').setProperty('Name', 'tRenamed')).toBe(true);
expect(objectOf(savedChunk(again.session, again.root), 'tRenamed')).toBe(renamedAndStamped(objectOf(xml, 't'), 'tRenamed'));
});
});

function binaryDictionaries(): string[] {
Expand Down Expand Up @@ -586,6 +630,28 @@ describe('for a host that splices chunk text (D12)', () => {
});
}

it('spliceEntryObject lays out what it writes from the model as the chunk is: a bus element added, CRLF and two spaces', () => {
// The same bus, in the fixture's LF and four spaces and in CRLF and two spaces, given the same
// new element: the element is written from the model, which writes LF and four spaces, so only
// the chunk's own layout, read off it, makes the two splices one text in two layouts.
const { xml: lf } = open(fixture('bus_types_binary.sldd'));
const spliced = (xml: string): string => {
const { session, root } = fromChunk(xml);
const bus = entryNamed(root, 'btBus');
expect(session.addChildTo(bus.id)).not.toBeNull();
expect(bus.children.length).toBe(5);
const out = core.spliceEntryObject(xml, bus);
expect(out).not.toBeNull();
return out!;
};
const inLf = spliced(lf);
const inCrlf = spliced(crlf(lf));
expect(objectOf(inLf, 'btBus').split('<Element Class="Simulink.BusElement">').length - 1).toBe(5);
expect(inLf).not.toContain('\r');
expect(inCrlf).toBe(crlf(inLf));
expect(inCrlf.replace(/\r\n/g, '')).not.toContain('\n');
});

it('spliceEntryObject answers null for an entry the chunk does not hold', () => {
const { root, xml } = open(fixture('cellarr_binary.sldd'));
expect(core.spliceEntryObject(xml, entries(root)[0], 'noSuchEntry')).toBeNull();
Expand Down Expand Up @@ -614,6 +680,75 @@ describe('for a host that splices chunk text (D12)', () => {
expect(core.keptPartsViolations(entry, regenerated(entry))).toEqual(['P[Value] (open tag)']);
});

// Each rule keptPartsViolations holds a write to, on a write that breaks only that one. A
// Simulink.Parameter with its Min edited, whose siblings the model did not change.
const parameter = (): { entry: any; written: string } => {
const value = [
'<P Name="Value">',
' <Element Class="Simulink.Parameter">',
' <P Name="Value" Class="double">1.0E-5</P>',
' <P Name="Min" Class="double">-1.0E-5</P>',
' <P Name="Max" Class="double">1.0E+10</P>',
' <P Name="Description" Class="char">kept</P>',
' </Element>',
' </P>',
].join('\n');
const { session, root } = fromChunk(chunk(entryXml('p', 'u-p', value)));
const entry = entryNamed(root, 'p');
expect(entry.setProperty('Min', '-2')).toBe(true);
const written = objectOf(savedChunk(session, root), 'p');
expect(written).toContain('<P Name="Min" Class="double">-2.0</P>');
expect(core.keptPartsViolations(entry, written)).toEqual([]);
return { entry, written };
};
const MAX = '\n <P Name="Max" Class="double">1.0E+10</P>';
const DESCRIPTION = '\n <P Name="Description" Class="char">kept</P>';

it('keptPartsViolations: a child written twice, or with no name, where the model writes each once', () => {
const { entry, written } = parameter();
const twice = written.replace(DESCRIPTION, DESCRIPTION + DESCRIPTION);
expect(core.keptPartsViolations(entry, twice)).toEqual(['P[Value]/Element[0]: a child of it is written twice or cannot be named']);
const unnamed = written.replace(DESCRIPTION, DESCRIPTION + '\n <P Class="char">kept</P>');
expect(core.keptPartsViolations(entry, unnamed)).toEqual(['P[Value]/Element[0]: a child of it is written twice or cannot be named']);
});

it('keptPartsViolations: a child the model did not change, not written', () => {
const { entry, written } = parameter();
expect(written).toContain(MAX);
expect(core.keptPartsViolations(entry, written.replace(MAX, ''))).toEqual(['P[Value]/Element[0]/P[Max] (not written)']);
// The one the model changed may go: what is written there is the model's to say.
expect(core.keptPartsViolations(entry, written.replace('\n <P Name="Min" Class="double">-2.0</P>', ''))).toEqual([]);
});

it('keptPartsViolations: a property the reader does not read, not kept (D13)', () => {
const unread = '\n <P Name="Unread" Class="char">kept as read</P>';
const xml = chunk(entryXml('a', 'u-a', '<P Name="Value" Class="double">1.0E+6</P>').replace('\n <P Name="IsDerived"', unread + '\n <P Name="IsDerived"'));
const { session, root } = fromChunk(xml);
const entry = entryNamed(root, 'a');
expect(entry.setProperty('Name', 'aRenamed')).toBe(true);
const written = objectOf(savedChunk(session, root), 'aRenamed');
expect(written).toContain(unread);
expect(core.keptPartsViolations(entry, written)).toEqual([]);
expect(core.keptPartsViolations(entry, written.replace(unread, ''))).toEqual(['P[Unread] (not read, and not kept)']);
expect(core.keptPartsViolations(entry, written.replace('kept as read', 'changed'))).toEqual(['P[Unread] (not read, and not kept)']);
});

it('keptPartsViolations: a bus written with more or fewer elements than the model holds', () => {
const { xml } = open(fixture('bus_types_binary.sldd'));
const { session, root } = fromChunk(xml);
const bus = entryNamed(root, 'btBus');
expect(session.deleteNodeById(bus.children[1].id)).toBe(true);
expect(bus.children.length).toBe(3);
const written = objectOf(savedChunk(session, root), 'btBus');
expect(core.keptPartsViolations(bus, written)).toEqual([]);
const at = written.lastIndexOf('<Element Class="Simulink.BusElement">');
const last = written.slice(written.lastIndexOf('\n', at), written.indexOf('</Element>', at) + '</Element>'.length);
expect(last).toContain('<P Name="Name" Class="char">matrix</P>');
const path = 'P[Value]/Element[0]/P[Elements_internal]';
expect(core.keptPartsViolations(bus, written.replace(last, ''))).toEqual([`${path}: written with 2 elements where the model holds 3`]);
expect(core.keptPartsViolations(bus, written.replace(last, last + last))).toEqual([`${path}: written with 4 elements where the model holds 3`]);
});

it('readEntryFragment rebuilds an entry with the bytes it came from, as an undo that rebuilds from text does', () => {
const { session, root, xml } = openOlderSave(fixture('cellarr_binary.sldd'));
const old = entryNamed(root, 'cObjArr');
Expand Down
46 changes: 44 additions & 2 deletions test/matNodeCeiling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@
//
// Every case opens the file through MatNode.fromParsed, the path addMatSource takes, twice:
// once under the ceiling the case is about and once with no ceiling it can reach, which is the
// tree every file opened as before. What a cut tree shows is graded against that one.
// tree every file opened as before. What a cut tree shows is graded against that one. The last
// case opens other files after a .mat one, to see that the ceiling was armed for that file alone.
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { deflateSync } from 'node:zlib';
import { createSession } from '../src/index.js';
import { createSession, ingest } from '../src/index.js';
import MatNode from '../src/datamodel/node/container/MatNode.js';
import MatlabVariableNode from '../src/datamodel/node/data/MatlabVariableNode.js';
import { buildUnderCeiling } from '../src/datamodel/node/data/matNodeCeiling.js';
import { parseMat } from '../src/datamodel/parser/MatParser.js';
import { encodeMatStream } from '../src/datamodel/parser/MatWriter.js';
import { MAT_NODE_CEILING } from '../src/datamodel/display/DisplayConvention.js';
Expand Down Expand Up @@ -368,4 +370,44 @@ describe('a .mat file past its node ceiling', () => {
const value = JSON.parse(copied(big)).value;
expect([value._dimensions, value._elements.length, value._elements[299_999]]).toEqual([[1, 300_000], 300_000, { v: 299_999 }]);
});

it('is armed only while a .mat file is built: what opens after one used up, or after a build that threw, is whole', () => {
// matNodeCeiling arms the ceiling for one .mat file's tree and sets it back in a finally.
// Nothing else is built under it: a text dictionary's cdata and a binary one's hex, each a
// value read out of a MAT stream, and a model's workspace. Each holds sparse arrays, built
// with their rows as a .mat file's are. A ceiling left armed, used up, would build them
// with none.
// Each opened on its own: how many nodes, how many of them a MAT stream's value built with
// rows, and which have none (`_cutByCeiling`).
const others = (): [string, number, number, string[]][] =>
['sparse_text.sldd', 'sparse_binary.sldd', 'sparse_ws.slx'].map((name) => {
const u8 = new Uint8Array(readFileSync(fileURLToPath(new URL('fixtures/sparse/' + name, import.meta.url))));
const source: Any = ingest(createSession(), u8.buffer as ArrayBuffer, { filename: name });
const nodes = nodesUnder(source);
const withRows = nodes.filter((n) => n._matVar && n.children.length > 0).length;
return [name, nodes.length, withRows, nodes.filter((n) => n._cutByCeiling).map((n) => n.matlabPath)];
});
const before = others();
for (const [name, , withRows, cut] of before) {
expect([name, withRows > 0, cut]).toEqual([name, true, []]);
}

// A .mat file whose ceiling is used up: everything after its first variable has no rows.
const { root, warnings } = open(everyKind(), 21);
expect([shownWithoutContents(root).length, warnings.map((w) => w.code)]).toEqual([12, ['part-not-expanded']]);
expect(others()).toEqual(before);

// A build that throws after it used up its ceiling: the throw reaches the caller, and the
// ceiling is not left armed.
const parsed = parseMat(everyKind());
const thrown = (): unknown =>
buildUnderCeiling(21, () => {
const node = new MatNode('threw.mat');
MatNode._addVariables(node, parsed);
expect(shownWithoutContents(node).length).toBe(12);
throw new Error('the build failed');
});
expect(thrown).toThrow('the build failed');
expect(others()).toEqual(before);
});
});
38 changes: 30 additions & 8 deletions test/parity/matlab/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ can.

| case | display | note |
|---|---|---|
| empty numeric, any class, shape and complexity | `[ ]` | MATLAB's Workspace browser's own text, space inside (`mat2str` says `[]`). An int-backed enumeration's empty too: MATLAB counts it numeric. Not a sparse double's: see the next row |
| empty numeric, any class, shape and complexity | `[ ]` | MATLAB's Workspace browser's own text, space inside (`mat2str` says `[]`). An int-backed enumeration's empty too, as a text dictionary holds it: MATLAB counts it numeric. A binary dictionary's and a `.mat`'s is not `[ ]`: see the kept list below. Not a sparse double's: see the next row |
| empty sparse double | `<0x0 sparse double>`, `<2x0 sparse double>` | **a deliberate deviation from MATLAB**, whose browser shows `[ ]` (measured at 0x0 and 2x0). See "The sparse empty keeps its summary" below |
| empty cell | `{}` at 0x0, `<0x3 cell>` at any other empty shape | the browser's `{}` and `{0×3 cell}` |
| a text dictionary's `"value": null` | `[ ]`, a 1x0 double, with no parse warning | what MATLAB reads the entry back as; MATLAB writes `null` for a value it could not save in a text dictionary (a 2x2x2 string), and opens the file again without complaint. It read as `0` (P8 of the 2026-10-16 list) |
Expand Down Expand Up @@ -104,13 +104,35 @@ logical-backed enumeration. A complex scalar is its own class (`int16`, not
`double`), and so is each element of a complex array. getWorkspaceDisplay's own
Class (`complex double`) is a different spelling; the browser's is the one shown.

**Only the rows above that cite the browser follow it so far.** Whether the rest
follow MATLAB is pending the maintainer's rulings on numeric precision (P1),
the summary's spelling (Q1: MATLAB writes `0×3 string`, `{0×3 cell}`, with no
angle brackets), and H1-H6 (the Parameter row, the edit seed, 1x1 cells, char
control characters, length bounds), in the 2026-10-15 display study
(data-explorer-vscode `docs/deep-work/2026-10-15-decided-items/display-study/`,
`proposed-open-questions.md` and `not-followable-*.md`).
**The rest differs from the browser on purpose, as recorded deviations.** The
maintainer's ruling of 2026-10-15 (evening, group B, display alignment): "if our
display makes more sense than MATLAB, keep ours. For example, to be consistent, we
always use `<m x n className>` for summary, then keep it. Only fix our display that
could cause confusion to user." A display that could mislead (a wrong class, size,
shape or value, a value that looks like a different one, or `null` / `unknown` where
the file holds the information) was fixed: P1-P8 and P10 of the 2026-10-16 list.
The rows below are kept. The browser's side of each was measured in R2027a by the
2026-10-15 display study (data-explorer-vscode
`docs/deep-work/2026-10-15-decided-items/display-study/`, `not-followable-*.md` and
`vs-matlab.md`). Q1, P1 and H1-H6 are that study's ids, so the study's P1 is
precision, not the 2026-10-16 list's P1.

| kept | here | MATLAB's Workspace browser | why |
|---|---|---|---|
| Q1, a summary's spelling | `<1x11 int8>`, `<0x3 cell>` | `1×11 int8`, `{0×3 cell}` | one spelling for every summary. The angle brackets are the italic signal, and the host withholds the in-place editor on them |
| P1 (the study's), precision | full JS round-trip precision, `3.141592653589793` | the user's `format`, short by default: `3.1416`, so `pi` and `3.14159` look the same, and `[1e-10 1]` is `[0,1]` | the host filters and compares on the cell text |
| H1, a Parameter's entry row | its value: `500`, `DispColor.Green` | `1×1 Parameter`, whatever the value is | a Parameter with a scalar value has no Value row of its own, so its entry cell is the only place the value shows and is edited |
| H2, the edit text | none of its own: the cell text seeds the editor | a separate text, in `format long` (`FormatDataUtils.getDisplayEditValue`) | the cell is already a valid literal at full precision |
| H4, a 1x1 cell | `{1}` | `1` | the browser tells `{1}` from `1` only by the Class column, which the host hides by default |
| H6, the length guard | a summary past `SUMMARY_MAX_CHARS = 1000` (see "Threshold rule") | the whole text, up to 99,999 characters | a runaway guard against a 100 KB grid cell |
| logical | `true` / `false` | `1` / `0` | |
| quotes in char and string | doubled: `'it''s'`, `"say ""hi"""` | as they are: `'it's'`, `"say "hi""` | the browser's text is no literal; this one is |
| cell elements | comma-separated: `{1, 'two', [3 4]}` | space-separated: `{1 'two' [3 4]}` | |
| P9 of the 2026-10-16 list, a `.mat` `Simulink.VariantExpression` | `<1x1 Simulink.Variant>`, Class `Simulink.Variant` | Class `Simulink.VariantExpression` | the class name the `.mat` itself records, and the class's documented former name. A dictionary's reads as a VariantExpression, with its condition. No fixture of this repo holds one |
| an int-backed enumeration's empty, from a binary dictionary or a `.mat` | `<1x0 DispLevel>`, `<0x0 DispColor>` | `[ ]` | read there as an enumeration value (an ObjectNode), it keeps the summary every empty object array has. It names the class and the size, so nothing on it misleads. A text dictionary's is `[ ]` (the first row above) |

H3 is the evidence for the study's P1. H5, control characters, is not kept: it is P7
of the 2026-10-16 list, fixed (the char row above).

### A property sheet is not a table cell

Expand Down
Loading
Loading