capdag 0.100.226 → 0.104.240
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capdag.js +111 -7
- package/capdag.test.js +831 -0
- package/machine.pegjs +19 -7
- package/package.json +1 -1
package/capdag.js
CHANGED
|
@@ -1222,13 +1222,14 @@ class MediaSpec {
|
|
|
1222
1222
|
* @param {string|null} profile - Optional profile URL
|
|
1223
1223
|
* @param {Object|null} schema - Optional JSON Schema for local validation
|
|
1224
1224
|
* @param {string|null} title - Optional display-friendly title
|
|
1225
|
-
* @param {string|null} description - Optional description
|
|
1225
|
+
* @param {string|null} description - Optional short plain-text description
|
|
1226
1226
|
* @param {string|null} mediaUrn - Source media URN for tag-based checks
|
|
1227
1227
|
* @param {Object|null} validation - Optional validation rules (min, max, min_length, max_length, pattern, allowed_values)
|
|
1228
1228
|
* @param {Object|null} metadata - Optional metadata (arbitrary key-value pairs for display/categorization)
|
|
1229
1229
|
* @param {string[]} extensions - File extensions for storing this media type (e.g., ['pdf'], ['jpg', 'jpeg'])
|
|
1230
|
+
* @param {string|null} documentation - Optional long-form markdown documentation. Rendered in media info panels, the cap navigator, capdag-dot-com, and anywhere else a rich-text explanation of the media spec is useful.
|
|
1230
1231
|
*/
|
|
1231
|
-
constructor(contentType, profile = null, schema = null, title = null, description = null, mediaUrn = null, validation = null, metadata = null, extensions = []) {
|
|
1232
|
+
constructor(contentType, profile = null, schema = null, title = null, description = null, mediaUrn = null, validation = null, metadata = null, extensions = [], documentation = null) {
|
|
1232
1233
|
this.contentType = contentType;
|
|
1233
1234
|
this.profile = profile;
|
|
1234
1235
|
this.schema = schema;
|
|
@@ -1238,6 +1239,7 @@ class MediaSpec {
|
|
|
1238
1239
|
this.validation = validation;
|
|
1239
1240
|
this.metadata = metadata;
|
|
1240
1241
|
this.extensions = extensions;
|
|
1242
|
+
this.documentation = documentation;
|
|
1241
1243
|
}
|
|
1242
1244
|
|
|
1243
1245
|
/**
|
|
@@ -1387,12 +1389,19 @@ function resolveMediaUrn(mediaUrn, mediaSpecs = []) {
|
|
|
1387
1389
|
const def = mediaSpecs.find(spec => spec.urn === mediaUrn);
|
|
1388
1390
|
|
|
1389
1391
|
if (def) {
|
|
1390
|
-
// Object form: { urn, media_type, title, profile_uri?, schema?, description?, validation?, metadata?, extensions? }
|
|
1392
|
+
// Object form: { urn, media_type, title, profile_uri?, schema?, description?, documentation?, validation?, metadata?, extensions? }
|
|
1391
1393
|
const mediaType = def.media_type || def.mediaType;
|
|
1392
1394
|
const profileUri = def.profile_uri || def.profileUri || null;
|
|
1393
1395
|
const schema = def.schema || null;
|
|
1394
1396
|
const title = def.title || null;
|
|
1395
1397
|
const description = def.description || null;
|
|
1398
|
+
// Long-form markdown body for rich info panels. Strict
|
|
1399
|
+
// snake_case (`documentation`) to match the JSON schema; no
|
|
1400
|
+
// camelCase fallback because all generator pipelines write the
|
|
1401
|
+
// canonical form.
|
|
1402
|
+
const documentation = typeof def.documentation === 'string' && def.documentation.length > 0
|
|
1403
|
+
? def.documentation
|
|
1404
|
+
: null;
|
|
1396
1405
|
const validation = def.validation || null;
|
|
1397
1406
|
const metadata = def.metadata || null;
|
|
1398
1407
|
const extensions = Array.isArray(def.extensions) ? def.extensions : [];
|
|
@@ -1404,7 +1413,7 @@ function resolveMediaUrn(mediaUrn, mediaSpecs = []) {
|
|
|
1404
1413
|
);
|
|
1405
1414
|
}
|
|
1406
1415
|
|
|
1407
|
-
return new MediaSpec(mediaType, profileUri, schema, title, description, mediaUrn, validation, metadata, extensions);
|
|
1416
|
+
return new MediaSpec(mediaType, profileUri, schema, title, description, mediaUrn, validation, metadata, extensions, documentation);
|
|
1408
1417
|
}
|
|
1409
1418
|
}
|
|
1410
1419
|
|
|
@@ -1888,11 +1897,12 @@ class Cap {
|
|
|
1888
1897
|
* @param {CapUrn} urn - The capability URN
|
|
1889
1898
|
* @param {string} title - The human-readable title (required)
|
|
1890
1899
|
* @param {string} command - The command string
|
|
1891
|
-
* @param {string|null} capDescription - Optional description
|
|
1900
|
+
* @param {string|null} capDescription - Optional short plain-text description
|
|
1892
1901
|
* @param {Object} metadata - Optional metadata object
|
|
1893
1902
|
* @param {Object|null} metadataJson - Optional arbitrary metadata as JSON object
|
|
1903
|
+
* @param {string|null} documentation - Optional long-form markdown documentation. Rendered in capability info panels, the cap navigator, capdag-dot-com, and anywhere else a rich-text explanation of the cap is useful.
|
|
1894
1904
|
*/
|
|
1895
|
-
constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null) {
|
|
1905
|
+
constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null, documentation = null) {
|
|
1896
1906
|
if (!(urn instanceof CapUrn)) {
|
|
1897
1907
|
throw new Error('URN must be a CapUrn instance');
|
|
1898
1908
|
}
|
|
@@ -1907,6 +1917,7 @@ class Cap {
|
|
|
1907
1917
|
this.title = title;
|
|
1908
1918
|
this.command = command;
|
|
1909
1919
|
this.cap_description = capDescription;
|
|
1920
|
+
this.documentation = documentation;
|
|
1910
1921
|
this.metadata = metadata || {};
|
|
1911
1922
|
this.mediaSpecs = []; // Media spec definitions array
|
|
1912
1923
|
this.args = []; // Array of CapArg - unified argument format
|
|
@@ -1915,6 +1926,31 @@ class Cap {
|
|
|
1915
1926
|
this.registered_by = null; // Registration attribution
|
|
1916
1927
|
}
|
|
1917
1928
|
|
|
1929
|
+
/**
|
|
1930
|
+
* Get the long-form markdown documentation, if any.
|
|
1931
|
+
* @returns {string|null}
|
|
1932
|
+
*/
|
|
1933
|
+
getDocumentation() {
|
|
1934
|
+
return this.documentation;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Set the long-form markdown documentation.
|
|
1939
|
+
* @param {string|null} documentation
|
|
1940
|
+
*/
|
|
1941
|
+
setDocumentation(documentation) {
|
|
1942
|
+
this.documentation = (typeof documentation === 'string' && documentation.length > 0)
|
|
1943
|
+
? documentation
|
|
1944
|
+
: null;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* Clear the long-form markdown documentation.
|
|
1949
|
+
*/
|
|
1950
|
+
clearDocumentation() {
|
|
1951
|
+
this.documentation = null;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1918
1954
|
/**
|
|
1919
1955
|
* Get the media type expected for stdin (derived from args with stdin source)
|
|
1920
1956
|
* @returns {string|null} The media URN for stdin, or null if cap doesn't accept stdin
|
|
@@ -2098,6 +2134,7 @@ class Cap {
|
|
|
2098
2134
|
this.title === other.title &&
|
|
2099
2135
|
this.command === other.command &&
|
|
2100
2136
|
this.cap_description === other.cap_description &&
|
|
2137
|
+
this.documentation === other.documentation &&
|
|
2101
2138
|
JSON.stringify(this.metadata) === JSON.stringify(other.metadata) &&
|
|
2102
2139
|
JSON.stringify(this.mediaSpecs) === JSON.stringify(other.mediaSpecs) &&
|
|
2103
2140
|
JSON.stringify(this.args.map(a => a.toJSON())) === JSON.stringify(other.args.map(a => a.toJSON())) &&
|
|
@@ -2122,6 +2159,12 @@ class Cap {
|
|
|
2122
2159
|
output: this.output
|
|
2123
2160
|
};
|
|
2124
2161
|
|
|
2162
|
+
// Long-form markdown documentation. Only emitted when set, to match
|
|
2163
|
+
// the Rust serializer which skips this field when None.
|
|
2164
|
+
if (typeof this.documentation === 'string' && this.documentation.length > 0) {
|
|
2165
|
+
result.documentation = this.documentation;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2125
2168
|
if (this.metadata_json !== null && this.metadata_json !== undefined) {
|
|
2126
2169
|
result.metadata_json = this.metadata_json;
|
|
2127
2170
|
}
|
|
@@ -2141,7 +2184,10 @@ class Cap {
|
|
|
2141
2184
|
}
|
|
2142
2185
|
const urn = CapUrn.fromString(json.urn);
|
|
2143
2186
|
|
|
2144
|
-
const
|
|
2187
|
+
const documentation = (typeof json.documentation === 'string' && json.documentation.length > 0)
|
|
2188
|
+
? json.documentation
|
|
2189
|
+
: null;
|
|
2190
|
+
const cap = new Cap(urn, json.title, json.command, json.cap_description, json.metadata, json.metadata_json, documentation);
|
|
2145
2191
|
cap.mediaSpecs = json.media_specs || json.mediaSpecs || [];
|
|
2146
2192
|
// Parse args (new format)
|
|
2147
2193
|
if (json.args && Array.isArray(json.args)) {
|
|
@@ -4648,6 +4694,64 @@ class Machine {
|
|
|
4648
4694
|
return lines.join('\n');
|
|
4649
4695
|
}
|
|
4650
4696
|
|
|
4697
|
+
/**
|
|
4698
|
+
* Serialize this machine graph to machine notation in the specified format.
|
|
4699
|
+
*
|
|
4700
|
+
* The output is deterministic: same graph + same format → same string.
|
|
4701
|
+
* @param {'bracketed' | 'line-based'} format - The notation format to use.
|
|
4702
|
+
* @returns {string}
|
|
4703
|
+
*/
|
|
4704
|
+
toMachineNotationFormatted(format) {
|
|
4705
|
+
if (this._edges.length === 0) {
|
|
4706
|
+
return '';
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
const { aliases, nodeNames, edgeOrder } = this._buildSerializationMaps();
|
|
4710
|
+
const bracketed = format === 'bracketed';
|
|
4711
|
+
const open = bracketed ? '[' : '';
|
|
4712
|
+
const close = bracketed ? ']' : '';
|
|
4713
|
+
const lines = [];
|
|
4714
|
+
|
|
4715
|
+
// Emit headers in alias-sorted order
|
|
4716
|
+
const sortedAliases = Array.from(aliases.entries()).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
4717
|
+
|
|
4718
|
+
for (const [alias, { edgeIdx }] of sortedAliases) {
|
|
4719
|
+
const edge = this._edges[edgeIdx];
|
|
4720
|
+
lines.push(`${open}${alias} ${edge.capUrn}${close}`);
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4723
|
+
// Emit wirings in edge order
|
|
4724
|
+
for (const edgeIdx of edgeOrder) {
|
|
4725
|
+
const edge = this._edges[edgeIdx];
|
|
4726
|
+
let alias = null;
|
|
4727
|
+
for (const [a, info] of aliases) {
|
|
4728
|
+
if (info.edgeIdx === edgeIdx) {
|
|
4729
|
+
alias = a;
|
|
4730
|
+
break;
|
|
4731
|
+
}
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
const sources = edge.sources.map(s => {
|
|
4735
|
+
const key = s.toString();
|
|
4736
|
+
return nodeNames.get(key);
|
|
4737
|
+
});
|
|
4738
|
+
|
|
4739
|
+
const targetKey = edge.target.toString();
|
|
4740
|
+
const targetName = nodeNames.get(targetKey);
|
|
4741
|
+
|
|
4742
|
+
const loopPrefix = edge.isLoop ? 'LOOP ' : '';
|
|
4743
|
+
|
|
4744
|
+
if (sources.length === 1) {
|
|
4745
|
+
lines.push(`${open}${sources[0]} -> ${loopPrefix}${alias} -> ${targetName}${close}`);
|
|
4746
|
+
} else {
|
|
4747
|
+
const group = sources.join(', ');
|
|
4748
|
+
lines.push(`${open}(${group}) -> ${loopPrefix}${alias} -> ${targetName}${close}`);
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
return bracketed ? lines.join('') : lines.join('\n');
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4651
4755
|
/**
|
|
4652
4756
|
* Build the alias map, node name map, and edge ordering for serialization.
|
|
4653
4757
|
*
|
package/capdag.test.js
CHANGED
|
@@ -1836,6 +1836,86 @@ function testJS_capJSONSerialization() {
|
|
|
1836
1836
|
assertEqual(restored.urn.getOutSpec(), MEDIA_OBJECT, 'Should restore outSpec');
|
|
1837
1837
|
}
|
|
1838
1838
|
|
|
1839
|
+
// JS round-trip for the documentation field on Cap. Mirrors TEST920 in
|
|
1840
|
+
// capdag/src/cap/definition.rs — the body is non-trivial (newlines,
|
|
1841
|
+
// backticks, embedded quotes, Unicode) so escaping mismatches between
|
|
1842
|
+
// JSON.stringify on this side and the Rust serializer on the other side
|
|
1843
|
+
// surface as failures here.
|
|
1844
|
+
function testJS_capDocumentationRoundTrip() {
|
|
1845
|
+
const urn = CapUrn.fromString(testUrn('op=documented'));
|
|
1846
|
+
const cap = new Cap(urn, 'Documented Cap', 'documented');
|
|
1847
|
+
const body = '# Documented Cap\r\n\nDoes the thing.\n\n```bash\necho "hi"\n```\n\nSee also: \u2605\n';
|
|
1848
|
+
cap.setDocumentation(body);
|
|
1849
|
+
assertEqual(cap.getDocumentation(), body, 'Setter must store the body verbatim');
|
|
1850
|
+
|
|
1851
|
+
const json = cap.toJSON();
|
|
1852
|
+
assertEqual(json.documentation, body, 'toJSON must include documentation when set');
|
|
1853
|
+
|
|
1854
|
+
// Stringify and parse to simulate writing to disk and reading back.
|
|
1855
|
+
const wireJson = JSON.parse(JSON.stringify(json));
|
|
1856
|
+
const restored = Cap.fromJSON(wireJson);
|
|
1857
|
+
assertEqual(restored.getDocumentation(), body, 'fromJSON must preserve documentation body verbatim');
|
|
1858
|
+
assert(restored.equals(cap), 'Round-tripped cap must equal the original');
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// When documentation is null, toJSON must omit the field entirely. This
|
|
1862
|
+
// matches the Rust serializer's skip-when-None semantics and the ObjC
|
|
1863
|
+
// toDictionary behaviour. A regression where null is emitted as
|
|
1864
|
+
// `documentation: null` would break the symmetric round-trip with Rust
|
|
1865
|
+
// (which has no null sentinel) and pollute generated JSON.
|
|
1866
|
+
function testJS_capDocumentationOmittedWhenNull() {
|
|
1867
|
+
const urn = CapUrn.fromString(testUrn('op=undocumented'));
|
|
1868
|
+
const cap = new Cap(urn, 'Undocumented Cap', 'undocumented');
|
|
1869
|
+
assertEqual(cap.getDocumentation(), null, 'Default documentation must be null');
|
|
1870
|
+
|
|
1871
|
+
const json = cap.toJSON();
|
|
1872
|
+
assert(!('documentation' in json), 'toJSON must omit documentation key when null');
|
|
1873
|
+
|
|
1874
|
+
// fromJSON of a missing key must yield null, not undefined or empty string.
|
|
1875
|
+
const restored = Cap.fromJSON(JSON.parse(JSON.stringify(json)));
|
|
1876
|
+
assertEqual(restored.getDocumentation(), null, 'Missing documentation must round-trip as null');
|
|
1877
|
+
|
|
1878
|
+
// Empty-string body is treated as absent (matches the resolver's
|
|
1879
|
+
// non-empty-string-only rule). This catches code paths that would store
|
|
1880
|
+
// an empty string and then emit it as a literal field.
|
|
1881
|
+
cap.setDocumentation('');
|
|
1882
|
+
assertEqual(cap.getDocumentation(), null, 'Empty string must collapse to null');
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// Documentation propagates from a mediaSpecs definition through
|
|
1886
|
+
// resolveMediaUrn into the resolved MediaSpec. Mirrors TEST924 on the Rust
|
|
1887
|
+
// side. This is the path every UI consumer uses, so a break here makes the
|
|
1888
|
+
// new field invisible everywhere downstream.
|
|
1889
|
+
function testJS_mediaSpecDocumentationPropagatesThroughResolve() {
|
|
1890
|
+
const body = '## Markdown body\n\nWith `code` and a [link](https://example.com).';
|
|
1891
|
+
const mediaSpecs = [
|
|
1892
|
+
{
|
|
1893
|
+
urn: 'media:doc-test;textable',
|
|
1894
|
+
media_type: 'text/plain',
|
|
1895
|
+
title: 'Documented',
|
|
1896
|
+
description: 'short desc',
|
|
1897
|
+
documentation: body
|
|
1898
|
+
}
|
|
1899
|
+
];
|
|
1900
|
+
|
|
1901
|
+
const resolved = resolveMediaUrn('media:doc-test;textable', mediaSpecs);
|
|
1902
|
+
assertEqual(resolved.documentation, body, 'documentation must propagate into MediaSpec');
|
|
1903
|
+
// The short description must remain distinct from the long markdown
|
|
1904
|
+
// body — they are different fields with different semantics.
|
|
1905
|
+
assertEqual(resolved.description, 'short desc', 'description must remain distinct from documentation');
|
|
1906
|
+
|
|
1907
|
+
// Missing documentation must collapse to null, not '' or undefined.
|
|
1908
|
+
const noDoc = resolveMediaUrn('media:doc-test;textable', [
|
|
1909
|
+
{ urn: 'media:doc-test;textable', media_type: 'text/plain', title: 'No Doc' }
|
|
1910
|
+
]);
|
|
1911
|
+
assertEqual(noDoc.documentation, null, 'Missing documentation must resolve to null');
|
|
1912
|
+
|
|
1913
|
+
const emptyDoc = resolveMediaUrn('media:doc-test;textable', [
|
|
1914
|
+
{ urn: 'media:doc-test;textable', media_type: 'text/plain', title: 'Empty', documentation: '' }
|
|
1915
|
+
]);
|
|
1916
|
+
assertEqual(emptyDoc.documentation, null, 'Empty documentation string must collapse to null');
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1839
1919
|
function testJS_stdinSourceKindConstants() {
|
|
1840
1920
|
assert(StdinSourceKind.DATA !== undefined, 'DATA kind should be defined');
|
|
1841
1921
|
assert(StdinSourceKind.FILE_REFERENCE !== undefined, 'FILE_REFERENCE kind should be defined');
|
|
@@ -2793,6 +2873,117 @@ function testMachine_unterminatedBracketFails() {
|
|
|
2793
2873
|
);
|
|
2794
2874
|
}
|
|
2795
2875
|
|
|
2876
|
+
// --- Machine parser line-based mode tests ---
|
|
2877
|
+
|
|
2878
|
+
function testMachine_lineBasedSimpleChain() {
|
|
2879
|
+
const g = Machine.fromString(
|
|
2880
|
+
'extract cap:in="media:pdf";op=extract;out="media:txt;textable"\n' +
|
|
2881
|
+
'doc -> extract -> text'
|
|
2882
|
+
);
|
|
2883
|
+
assertEqual(g.edgeCount(), 1);
|
|
2884
|
+
const edge = g.edges()[0];
|
|
2885
|
+
assert(edge.sources[0].isEquivalent(MediaUrn.fromString('media:pdf')),
|
|
2886
|
+
'Source should be media:pdf');
|
|
2887
|
+
assert(edge.target.isEquivalent(MediaUrn.fromString('media:txt;textable')),
|
|
2888
|
+
'Target should be media:txt;textable');
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
function testMachine_lineBasedTwoStepChain() {
|
|
2892
|
+
const g = Machine.fromString(
|
|
2893
|
+
'extract cap:in="media:pdf";op=extract;out="media:txt;textable"\n' +
|
|
2894
|
+
'embed cap:in="media:txt;textable";op=embed;out="media:embedding-vector;record;textable"\n' +
|
|
2895
|
+
'doc -> extract -> text\n' +
|
|
2896
|
+
'text -> embed -> vectors'
|
|
2897
|
+
);
|
|
2898
|
+
assertEqual(g.edgeCount(), 2);
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
function testMachine_lineBasedLoop() {
|
|
2902
|
+
const g = Machine.fromString(
|
|
2903
|
+
'p2t cap:in="media:disbound-page;textable";op=page_to_text;out="media:txt;textable"\n' +
|
|
2904
|
+
'pages -> LOOP p2t -> texts'
|
|
2905
|
+
);
|
|
2906
|
+
assertEqual(g.edgeCount(), 1);
|
|
2907
|
+
assertEqual(g.edges()[0].isLoop, true);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
function testMachine_lineBasedFanIn() {
|
|
2911
|
+
const g = Machine.fromString(
|
|
2912
|
+
'thumb cap:in="media:pdf";op=generate_thumbnail;out="media:image;png;thumbnail"\n' +
|
|
2913
|
+
'model_dl cap:in="media:model-spec;textable";op=download;out="media:model-spec;textable"\n' +
|
|
2914
|
+
'describe cap:in="media:image;png";op=describe_image;out="media:image-description;textable"\n' +
|
|
2915
|
+
'doc -> thumb -> thumbnail\n' +
|
|
2916
|
+
'spec_input -> model_dl -> model_spec\n' +
|
|
2917
|
+
'(thumbnail, model_spec) -> describe -> description'
|
|
2918
|
+
);
|
|
2919
|
+
assertEqual(g.edgeCount(), 3);
|
|
2920
|
+
assertEqual(g.edges()[2].sources.length, 2);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
function testMachine_mixedBracketedAndLineBased() {
|
|
2924
|
+
const g = Machine.fromString(
|
|
2925
|
+
'[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]\n' +
|
|
2926
|
+
'doc -> extract -> text'
|
|
2927
|
+
);
|
|
2928
|
+
assertEqual(g.edgeCount(), 1);
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
function testMachine_lineBasedEquivalentToBracketed() {
|
|
2932
|
+
const g1 = Machine.fromString(
|
|
2933
|
+
'[extract cap:in="media:pdf";op=extract;out="media:txt;textable"]' +
|
|
2934
|
+
'[doc -> extract -> text]'
|
|
2935
|
+
);
|
|
2936
|
+
const g2 = Machine.fromString(
|
|
2937
|
+
'extract cap:in="media:pdf";op=extract;out="media:txt;textable"\n' +
|
|
2938
|
+
'doc -> extract -> text'
|
|
2939
|
+
);
|
|
2940
|
+
assert(g1.isEquivalent(g2), 'Line-based and bracketed must produce equivalent graphs');
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
function testMachine_lineBasedFormatSerialization() {
|
|
2944
|
+
const g = new Machine([
|
|
2945
|
+
new MachineEdge(
|
|
2946
|
+
[MediaUrn.fromString('media:pdf')],
|
|
2947
|
+
CapUrn.fromString('cap:in="media:pdf";op=extract;out="media:txt;textable"'),
|
|
2948
|
+
MediaUrn.fromString('media:txt;textable'),
|
|
2949
|
+
false
|
|
2950
|
+
),
|
|
2951
|
+
]);
|
|
2952
|
+
const lineBased = g.toMachineNotationFormatted('line-based');
|
|
2953
|
+
assert(!lineBased.includes('['), 'Line-based format must not contain brackets');
|
|
2954
|
+
assert(!lineBased.includes(']'), 'Line-based format must not contain brackets');
|
|
2955
|
+
assert(lineBased.includes('extract cap:'), 'Should contain header');
|
|
2956
|
+
assert(lineBased.includes('-> extract ->'), 'Should contain wiring');
|
|
2957
|
+
|
|
2958
|
+
// Round-trip
|
|
2959
|
+
const reparsed = Machine.fromString(lineBased);
|
|
2960
|
+
assert(g.isEquivalent(reparsed), 'Line-based round-trip must produce equivalent graph');
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function testMachine_lineBasedAndBracketedParseSameGraph() {
|
|
2964
|
+
const g = new Machine([
|
|
2965
|
+
new MachineEdge(
|
|
2966
|
+
[MediaUrn.fromString('media:pdf')],
|
|
2967
|
+
CapUrn.fromString('cap:in="media:pdf";op=extract;out="media:txt;textable"'),
|
|
2968
|
+
MediaUrn.fromString('media:txt;textable'),
|
|
2969
|
+
false
|
|
2970
|
+
),
|
|
2971
|
+
new MachineEdge(
|
|
2972
|
+
[MediaUrn.fromString('media:txt;textable')],
|
|
2973
|
+
CapUrn.fromString('cap:in="media:txt;textable";op=embed;out="media:embedding-vector;record;textable"'),
|
|
2974
|
+
MediaUrn.fromString('media:embedding-vector;record;textable'),
|
|
2975
|
+
false
|
|
2976
|
+
),
|
|
2977
|
+
]);
|
|
2978
|
+
const bracketed = g.toMachineNotationFormatted('bracketed');
|
|
2979
|
+
const lineBased = g.toMachineNotationFormatted('line-based');
|
|
2980
|
+
|
|
2981
|
+
const gBracketed = Machine.fromString(bracketed);
|
|
2982
|
+
const gLineBased = Machine.fromString(lineBased);
|
|
2983
|
+
assert(gBracketed.isEquivalent(gLineBased),
|
|
2984
|
+
'Bracketed and line-based must parse to equivalent graphs');
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2796
2987
|
// --- Machine graph tests (mirrors graph.rs tests) ---
|
|
2797
2988
|
|
|
2798
2989
|
function testMachine_edgeEquivalenceSameUrns() {
|
|
@@ -3552,6 +3743,595 @@ function assertThrowsWithCode(fn, expectedCode) {
|
|
|
3552
3743
|
}
|
|
3553
3744
|
}
|
|
3554
3745
|
|
|
3746
|
+
// ============================================================================
|
|
3747
|
+
// cap-graph-renderer helpers — pure functions that do not require a DOM.
|
|
3748
|
+
// The renderer class itself needs cytoscape + DOM and is exercised by hand
|
|
3749
|
+
// in the browser; these tests cover the pure data transforms underneath it.
|
|
3750
|
+
// ============================================================================
|
|
3751
|
+
|
|
3752
|
+
const {
|
|
3753
|
+
cardinalityLabel: rendererCardinalityLabel,
|
|
3754
|
+
cardinalityFromCap: rendererCardinalityFromCap,
|
|
3755
|
+
canonicalMediaUrn: rendererCanonicalMediaUrn,
|
|
3756
|
+
mediaNodeLabel: rendererMediaNodeLabel,
|
|
3757
|
+
buildStrandGraphData: rendererBuildStrandGraphData,
|
|
3758
|
+
buildRunGraphData: rendererBuildRunGraphData,
|
|
3759
|
+
buildMachineGraphData: rendererBuildMachineGraphData,
|
|
3760
|
+
classifyStrandCapSteps: rendererClassifyStrandCapSteps,
|
|
3761
|
+
validateStrandPayload: rendererValidateStrandPayload,
|
|
3762
|
+
validateRunPayload: rendererValidateRunPayload,
|
|
3763
|
+
validateMachinePayload: rendererValidateMachinePayload,
|
|
3764
|
+
validateStrandStep: rendererValidateStrandStep,
|
|
3765
|
+
validateBodyOutcome: rendererValidateBodyOutcome,
|
|
3766
|
+
} = require('./cap-graph-renderer.js');
|
|
3767
|
+
|
|
3768
|
+
// The renderer module reads its dependencies off `window` or `global` at
|
|
3769
|
+
// call time (it is browser-first). Node has no window, so we install the
|
|
3770
|
+
// needed capdag-js classes on `global` before the tests run. Every
|
|
3771
|
+
// renderer path exercised by the tests resolves through these.
|
|
3772
|
+
if (typeof global.TaggedUrn === 'undefined') {
|
|
3773
|
+
global.TaggedUrn = require('tagged-urn').TaggedUrn;
|
|
3774
|
+
}
|
|
3775
|
+
if (typeof global.MediaUrn === 'undefined') global.MediaUrn = MediaUrn;
|
|
3776
|
+
if (typeof global.CapUrn === 'undefined') global.CapUrn = CapUrn;
|
|
3777
|
+
if (typeof global.Cap === 'undefined') global.Cap = Cap;
|
|
3778
|
+
if (typeof global.CapGraph === 'undefined') global.CapGraph = CapGraph;
|
|
3779
|
+
// Reference the top-of-file destructured createCap via the module export.
|
|
3780
|
+
if (typeof global.createCap === 'undefined') {
|
|
3781
|
+
global.createCap = require('./capdag.js').createCap;
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
function testRenderer_cardinalityLabel_allFourCases() {
|
|
3785
|
+
assertEqual(rendererCardinalityLabel(false, false), '1\u21921', 'scalar-to-scalar');
|
|
3786
|
+
assertEqual(rendererCardinalityLabel(true, false), 'n\u21921', 'sequence-to-scalar');
|
|
3787
|
+
assertEqual(rendererCardinalityLabel(false, true), '1\u2192n', 'scalar-to-sequence');
|
|
3788
|
+
assertEqual(rendererCardinalityLabel(true, true), 'n\u2192n', 'sequence-to-sequence');
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
function testRenderer_cardinalityLabel_usesUnicodeArrow() {
|
|
3792
|
+
// The label must use the real rightwards arrow (U+2192), not ASCII "->".
|
|
3793
|
+
// Downstream styling and tests depend on this glyph.
|
|
3794
|
+
const label = rendererCardinalityLabel(false, true);
|
|
3795
|
+
assert(label.includes('\u2192'), 'label should contain U+2192 rightwards arrow');
|
|
3796
|
+
assert(!label.includes('->'), 'label must not contain the ASCII replacement "->"');
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
function testRenderer_cardinalityFromCap_findsStdinArgNotFirstArg() {
|
|
3800
|
+
// The main input arg is the one whose sources include a stdin source.
|
|
3801
|
+
// A naive implementation that reads args[0] would see `cli-only` (not a
|
|
3802
|
+
// sequence) and report 1→1 even though the stdin arg is a sequence.
|
|
3803
|
+
const cap = {
|
|
3804
|
+
urn: 'cap:in="media:textable;list";op=transcribe;out="media:textable"',
|
|
3805
|
+
args: [
|
|
3806
|
+
{
|
|
3807
|
+
display_name: 'cli-only',
|
|
3808
|
+
is_sequence: false,
|
|
3809
|
+
sources: [{ cli_flag: '--mode' }],
|
|
3810
|
+
},
|
|
3811
|
+
{
|
|
3812
|
+
display_name: 'main-input',
|
|
3813
|
+
is_sequence: true,
|
|
3814
|
+
sources: [{ stdin: {} }],
|
|
3815
|
+
},
|
|
3816
|
+
],
|
|
3817
|
+
output: { is_sequence: false },
|
|
3818
|
+
};
|
|
3819
|
+
assertEqual(rendererCardinalityFromCap(cap), 'n\u21921',
|
|
3820
|
+
'must pick the arg that has a stdin source, not args[0]');
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
function testRenderer_cardinalityFromCap_scalarDefaultsWhenFieldsMissing() {
|
|
3824
|
+
// No args and no output: both sides collapse to 1 (scalar default).
|
|
3825
|
+
// If a bug makes the function return "n" for missing data, this fails.
|
|
3826
|
+
const cap = { urn: 'cap:in="media:";op=noop;out="media:"' };
|
|
3827
|
+
assertEqual(rendererCardinalityFromCap(cap), '1\u21921',
|
|
3828
|
+
'missing args/output must default to scalar on both sides');
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
function testRenderer_cardinalityFromCap_outputOnlySequence() {
|
|
3832
|
+
// One scalar stdin arg, output is a sequence: expects 1→n.
|
|
3833
|
+
const cap = {
|
|
3834
|
+
urn: 'cap:in="media:textable";op=generate;out="media:textable;list"',
|
|
3835
|
+
args: [{ sources: [{ stdin: {} }], is_sequence: false }],
|
|
3836
|
+
output: { is_sequence: true },
|
|
3837
|
+
};
|
|
3838
|
+
assertEqual(rendererCardinalityFromCap(cap), '1\u2192n',
|
|
3839
|
+
'scalar stdin with sequence output must yield 1→n');
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
function testRenderer_cardinalityFromCap_rejectsStringIsSequence() {
|
|
3843
|
+
// The function must use strict `=== true` to avoid treating truthy strings
|
|
3844
|
+
// as booleans. "true" is a string, not a boolean — it must NOT be treated
|
|
3845
|
+
// as a sequence, because downstream renderers expect boolean semantics.
|
|
3846
|
+
const cap = {
|
|
3847
|
+
urn: 'cap:in="media:";op=x;out="media:"',
|
|
3848
|
+
args: [{ sources: [{ stdin: {} }], is_sequence: 'true' }],
|
|
3849
|
+
output: { is_sequence: 'true' },
|
|
3850
|
+
};
|
|
3851
|
+
assertEqual(rendererCardinalityFromCap(cap), '1\u21921',
|
|
3852
|
+
'string "true" must not be treated as boolean true');
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
function testRenderer_cardinalityFromCap_throwsOnNonObject() {
|
|
3856
|
+
// Fail-hard on invalid input; no fallback to a default cardinality.
|
|
3857
|
+
let threw = false;
|
|
3858
|
+
try {
|
|
3859
|
+
rendererCardinalityFromCap(null);
|
|
3860
|
+
} catch (e) {
|
|
3861
|
+
threw = true;
|
|
3862
|
+
}
|
|
3863
|
+
assert(threw, 'cardinalityFromCap(null) must throw');
|
|
3864
|
+
|
|
3865
|
+
threw = false;
|
|
3866
|
+
try {
|
|
3867
|
+
rendererCardinalityFromCap('not-an-object');
|
|
3868
|
+
} catch (e) {
|
|
3869
|
+
threw = true;
|
|
3870
|
+
}
|
|
3871
|
+
assert(threw, 'cardinalityFromCap(string) must throw');
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
function testRenderer_canonicalMediaUrn_normalizesTagOrder() {
|
|
3875
|
+
// Two media URNs with identical tags in different input orders must
|
|
3876
|
+
// produce the same canonical string. If canonicalization is bypassed,
|
|
3877
|
+
// the two strings remain different and this test exposes it.
|
|
3878
|
+
const a = rendererCanonicalMediaUrn('media:video;h264;list');
|
|
3879
|
+
const b = rendererCanonicalMediaUrn('media:list;h264;video');
|
|
3880
|
+
assertEqual(a, b, 'tag-order differences must not survive canonicalization');
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
function testRenderer_canonicalMediaUrn_preservesValueTags() {
|
|
3884
|
+
const c = rendererCanonicalMediaUrn('media:model;quant=q4');
|
|
3885
|
+
assert(c.includes('quant=q4'), 'value tag must be preserved');
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
function testRenderer_canonicalMediaUrn_rejectsCapUrn() {
|
|
3889
|
+
// MediaUrn.fromString enforces the media: prefix. Feeding a cap URN to
|
|
3890
|
+
// canonicalMediaUrn must fail hard.
|
|
3891
|
+
let threw = false;
|
|
3892
|
+
try {
|
|
3893
|
+
rendererCanonicalMediaUrn('cap:op=x;in="media:";out="media:"');
|
|
3894
|
+
} catch (e) {
|
|
3895
|
+
threw = true;
|
|
3896
|
+
}
|
|
3897
|
+
assert(threw, 'canonicalMediaUrn must reject non-media URNs');
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
function testRenderer_mediaNodeLabel_oneLinePerTag_valueAndMarker() {
|
|
3901
|
+
// A media URN with one value tag and one marker tag renders two lines:
|
|
3902
|
+
// value tag as "key: value", marker tag as bare key. Order is canonical
|
|
3903
|
+
// (alphabetical, matching TaggedUrn's sorted key iteration).
|
|
3904
|
+
const label = rendererMediaNodeLabel('media:video;quant=q4');
|
|
3905
|
+
const lines = label.split('\n');
|
|
3906
|
+
assertEqual(lines.length, 2, 'two tags must produce two lines');
|
|
3907
|
+
assert(lines.includes('quant: q4'), 'value tag rendered as key: value');
|
|
3908
|
+
assert(lines.includes('video'), 'marker tag rendered as bare key');
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
function testRenderer_mediaNodeLabel_stableAcrossTagOrder() {
|
|
3912
|
+
// Labels must be tag-order-independent so that the same media URN
|
|
3913
|
+
// produces the same multi-line label regardless of how the source
|
|
3914
|
+
// happened to spell it.
|
|
3915
|
+
const a = rendererMediaNodeLabel(rendererCanonicalMediaUrn('media:list;textable'));
|
|
3916
|
+
const b = rendererMediaNodeLabel(rendererCanonicalMediaUrn('media:textable;list'));
|
|
3917
|
+
assertEqual(a, b, 'label must be stable across tag orderings');
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
// ---------------- strand builder ----------------
|
|
3921
|
+
|
|
3922
|
+
function makeCapStep(capUrn, title, fromSpec, toSpec, inSeq, outSeq) {
|
|
3923
|
+
return {
|
|
3924
|
+
step_type: {
|
|
3925
|
+
Cap: {
|
|
3926
|
+
cap_urn: capUrn,
|
|
3927
|
+
title,
|
|
3928
|
+
specificity: 0,
|
|
3929
|
+
input_is_sequence: inSeq,
|
|
3930
|
+
output_is_sequence: outSeq,
|
|
3931
|
+
},
|
|
3932
|
+
},
|
|
3933
|
+
from_spec: fromSpec,
|
|
3934
|
+
to_spec: toSpec,
|
|
3935
|
+
};
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
function makeForEachStep(mediaSpec) {
|
|
3939
|
+
return {
|
|
3940
|
+
step_type: { ForEach: { media_spec: mediaSpec } },
|
|
3941
|
+
from_spec: mediaSpec,
|
|
3942
|
+
to_spec: mediaSpec,
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
function makeCollectStep(mediaSpec) {
|
|
3947
|
+
return {
|
|
3948
|
+
step_type: { Collect: { media_spec: mediaSpec } },
|
|
3949
|
+
from_spec: mediaSpec,
|
|
3950
|
+
to_spec: mediaSpec,
|
|
3951
|
+
};
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
function testRenderer_validateStrandStep_rejectsUnknownVariant() {
|
|
3955
|
+
// A step with an unknown variant must fail hard at validation; no
|
|
3956
|
+
// silent coercion.
|
|
3957
|
+
let threw = false;
|
|
3958
|
+
try {
|
|
3959
|
+
rendererValidateStrandStep({
|
|
3960
|
+
step_type: { WrongVariant: {} },
|
|
3961
|
+
from_spec: 'media:a',
|
|
3962
|
+
to_spec: 'media:a',
|
|
3963
|
+
}, 'test');
|
|
3964
|
+
} catch (e) {
|
|
3965
|
+
threw = true;
|
|
3966
|
+
assert(e.message.includes('WrongVariant'), 'error must name the bad variant');
|
|
3967
|
+
}
|
|
3968
|
+
assert(threw, 'unknown variant must throw');
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
function testRenderer_validateStrandStep_requiresBooleanIsSequence() {
|
|
3972
|
+
// A Cap variant must have boolean is_sequence fields; number or string
|
|
3973
|
+
// must reject.
|
|
3974
|
+
let threw = false;
|
|
3975
|
+
try {
|
|
3976
|
+
rendererValidateStrandStep({
|
|
3977
|
+
step_type: { Cap: {
|
|
3978
|
+
cap_urn: 'cap:in="media:a";op=x;out="media:b"',
|
|
3979
|
+
title: 't',
|
|
3980
|
+
input_is_sequence: 1, // number, not boolean
|
|
3981
|
+
output_is_sequence: false,
|
|
3982
|
+
}},
|
|
3983
|
+
from_spec: 'media:a',
|
|
3984
|
+
to_spec: 'media:b',
|
|
3985
|
+
}, 'test');
|
|
3986
|
+
} catch (e) {
|
|
3987
|
+
threw = true;
|
|
3988
|
+
assert(e.message.includes('input_is_sequence'), 'error must name the bad field');
|
|
3989
|
+
}
|
|
3990
|
+
assert(threw, 'non-boolean is_sequence must throw');
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
function testRenderer_classifyStrandCapSteps_capFlags() {
|
|
3994
|
+
// Strand: ForEach → cap1 → cap2 → cap3 → Collect. cap1 should have
|
|
3995
|
+
// prevForEach=true; cap3 should have nextCollect=true; cap2 should
|
|
3996
|
+
// have neither.
|
|
3997
|
+
const steps = [
|
|
3998
|
+
makeForEachStep('media:pdf;list'),
|
|
3999
|
+
makeCapStep('cap:in="media:pdf";op=a;out="media:png"', 'a', 'media:pdf', 'media:png', false, false),
|
|
4000
|
+
makeCapStep('cap:in="media:png";op=b;out="media:jpg"', 'b', 'media:png', 'media:jpg', false, false),
|
|
4001
|
+
makeCapStep('cap:in="media:jpg";op=c;out="media:txt"', 'c', 'media:jpg', 'media:txt', false, false),
|
|
4002
|
+
makeCollectStep('media:txt'),
|
|
4003
|
+
];
|
|
4004
|
+
const { capStepIndices, capFlags } = rendererClassifyStrandCapSteps(steps);
|
|
4005
|
+
assertEqual(capStepIndices.length, 3, 'three cap steps');
|
|
4006
|
+
assert(capFlags.get(1).prevForEach, 'cap1 has prevForEach');
|
|
4007
|
+
assert(!capFlags.get(1).nextCollect, 'cap1 has no nextCollect');
|
|
4008
|
+
assert(!capFlags.get(2).prevForEach, 'cap2 has no prevForEach');
|
|
4009
|
+
assert(!capFlags.get(2).nextCollect, 'cap2 has no nextCollect');
|
|
4010
|
+
assert(!capFlags.get(3).prevForEach, 'cap3 has no prevForEach');
|
|
4011
|
+
assert(capFlags.get(3).nextCollect, 'cap3 has nextCollect');
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
function testRenderer_classifyStrandCapSteps_nestedForks() {
|
|
4015
|
+
// Nested strand: ForEach → cap1 → ForEach → cap2 → Collect → cap3 → Collect.
|
|
4016
|
+
// cap1 has prevForEach (outer), cap2 has prevForEach (inner) and
|
|
4017
|
+
// nextCollect (inner), cap3 has nextCollect (outer).
|
|
4018
|
+
const steps = [
|
|
4019
|
+
makeForEachStep('media:a;list'),
|
|
4020
|
+
makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
|
|
4021
|
+
makeForEachStep('media:b;list'),
|
|
4022
|
+
makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
|
|
4023
|
+
makeCollectStep('media:c'),
|
|
4024
|
+
makeCapStep('cap:in="media:c";op=c;out="media:d"', 'c', 'media:c', 'media:d', false, false),
|
|
4025
|
+
makeCollectStep('media:d'),
|
|
4026
|
+
];
|
|
4027
|
+
const { capFlags } = rendererClassifyStrandCapSteps(steps);
|
|
4028
|
+
assert(capFlags.get(1).prevForEach && !capFlags.get(1).nextCollect, 'cap1 outer entry');
|
|
4029
|
+
assert(capFlags.get(3).prevForEach && capFlags.get(3).nextCollect, 'cap2 inner both');
|
|
4030
|
+
assert(!capFlags.get(5).prevForEach && capFlags.get(5).nextCollect, 'cap3 outer exit');
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
function testRenderer_buildStrandGraphData_labelsForkBoundaries() {
|
|
4034
|
+
// End-to-end: a foreach strand in which source_spec (media:pdf;list)
|
|
4035
|
+
// differs from the first cap's from_spec (media:pdf) produces an
|
|
4036
|
+
// explicit fix-up edge source→firstCap.from labeled "for each". The
|
|
4037
|
+
// cap edge carries only the cap title. The topology is
|
|
4038
|
+
// media:pdf;list --[for each]--> media:pdf --[extract text]--> media:txt
|
|
4039
|
+
// Only two edges — ForEach is NOT a separate node, Collect is absent
|
|
4040
|
+
// because the strand's target_spec already matches the last cap's
|
|
4041
|
+
// to_spec (no fan-in fix-up needed).
|
|
4042
|
+
const payload = {
|
|
4043
|
+
source_spec: 'media:pdf;list',
|
|
4044
|
+
target_spec: 'media:txt',
|
|
4045
|
+
steps: [
|
|
4046
|
+
makeForEachStep('media:pdf;list'),
|
|
4047
|
+
makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract text', 'media:pdf', 'media:txt', false, false),
|
|
4048
|
+
],
|
|
4049
|
+
};
|
|
4050
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4051
|
+
assertEqual(built.edges.length, 2, 'one fix-up edge + one cap edge');
|
|
4052
|
+
const forEachEdge = built.edges[0];
|
|
4053
|
+
assertEqual(forEachEdge.label, 'for each', 'first edge is the for-each fan-out');
|
|
4054
|
+
assertEqual(forEachEdge.source, 'media:pdf;list', 'for-each sources from source_spec');
|
|
4055
|
+
assertEqual(forEachEdge.target, 'media:pdf', 'for-each targets first cap.from_spec');
|
|
4056
|
+
const capEdge = built.edges[1];
|
|
4057
|
+
assertEqual(capEdge.label, 'extract text', 'cap edge carries only the cap title');
|
|
4058
|
+
assertEqual(capEdge.source, 'media:pdf', 'cap sources from its from_spec');
|
|
4059
|
+
assertEqual(capEdge.target, 'media:txt', 'cap targets its to_spec');
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
function testRenderer_buildStrandGraphData_collectFixupEdge() {
|
|
4063
|
+
// When the strand's target_spec (media:pdf;list) differs from the
|
|
4064
|
+
// last cap's to_spec (media:pdf), the builder emits an explicit
|
|
4065
|
+
// fix-up edge lastCap.to → target labeled "collect".
|
|
4066
|
+
const payload = {
|
|
4067
|
+
source_spec: 'media:a',
|
|
4068
|
+
target_spec: 'media:pdf;list',
|
|
4069
|
+
steps: [
|
|
4070
|
+
makeCapStep('cap:in="media:a";op=x;out="media:pdf"', 'x', 'media:a', 'media:pdf', false, false),
|
|
4071
|
+
makeCollectStep('media:pdf'),
|
|
4072
|
+
],
|
|
4073
|
+
};
|
|
4074
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4075
|
+
assertEqual(built.edges.length, 2, 'one cap edge + one collect fix-up');
|
|
4076
|
+
const capEdge = built.edges[0];
|
|
4077
|
+
assertEqual(capEdge.label, 'x', 'cap edge plain title');
|
|
4078
|
+
const collectEdge = built.edges[1];
|
|
4079
|
+
assertEqual(collectEdge.label, 'collect', 'collect fix-up labeled "collect"');
|
|
4080
|
+
assertEqual(collectEdge.source, 'media:pdf', 'collect sources from last cap.to_spec');
|
|
4081
|
+
assertEqual(collectEdge.target, 'media:pdf;list', 'collect targets target_spec');
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
function testRenderer_buildStrandGraphData_plainCapNoMarker() {
|
|
4085
|
+
// A plain 1→1 cap step (no ForEach/Collect adjacency, no sequence)
|
|
4086
|
+
// must not emit any cardinality marker in the label. This catches a
|
|
4087
|
+
// regression where "1→1" was accidentally appended to every edge.
|
|
4088
|
+
const payload = {
|
|
4089
|
+
source_spec: 'media:a',
|
|
4090
|
+
target_spec: 'media:b',
|
|
4091
|
+
steps: [
|
|
4092
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4093
|
+
],
|
|
4094
|
+
};
|
|
4095
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4096
|
+
assertEqual(built.edges.length, 1, 'one edge');
|
|
4097
|
+
assertEqual(built.edges[0].label, 'x', 'plain cap has no cardinality suffix');
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
function testRenderer_buildStrandGraphData_sequenceShowsCardinality() {
|
|
4101
|
+
// A cap with input_is_sequence=true MUST emit "(n→1)" on the label.
|
|
4102
|
+
const payload = {
|
|
4103
|
+
source_spec: 'media:a;list',
|
|
4104
|
+
target_spec: 'media:b',
|
|
4105
|
+
steps: [
|
|
4106
|
+
makeCapStep('cap:in="media:a;list";op=x;out="media:b"', 'x', 'media:a;list', 'media:b', true, false),
|
|
4107
|
+
],
|
|
4108
|
+
};
|
|
4109
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4110
|
+
assertEqual(built.edges.length, 1, 'one edge');
|
|
4111
|
+
assert(built.edges[0].label.includes('(n\u21921)'),
|
|
4112
|
+
`edge label must include (n\u21921) marker; got: ${built.edges[0].label}`);
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
function testRenderer_validateStrandPayload_missingSourceSpec() {
|
|
4116
|
+
let threw = false;
|
|
4117
|
+
try {
|
|
4118
|
+
rendererValidateStrandPayload({ target_spec: 'media:b', steps: [] });
|
|
4119
|
+
} catch (e) {
|
|
4120
|
+
threw = true;
|
|
4121
|
+
assert(e.message.includes('source_spec'), 'error must name source_spec');
|
|
4122
|
+
}
|
|
4123
|
+
assert(threw, 'missing source_spec must throw');
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
// ---------------- run builder ----------------
|
|
4127
|
+
|
|
4128
|
+
function testRenderer_validateBodyOutcome_rejectsNegativeIndex() {
|
|
4129
|
+
let threw = false;
|
|
4130
|
+
try {
|
|
4131
|
+
rendererValidateBodyOutcome({ body_index: -1, success: true, cap_urns: [] }, 'test');
|
|
4132
|
+
} catch (e) {
|
|
4133
|
+
threw = true;
|
|
4134
|
+
}
|
|
4135
|
+
assert(threw, 'negative body_index must throw');
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
4139
|
+
// 6 successes, 4 failures. visible_success_count=3, visible_failure_count=2,
|
|
4140
|
+
// total_body_count=10. The builder must:
|
|
4141
|
+
// - render exactly 3 success replicas (one node per cap step per body)
|
|
4142
|
+
// - render exactly 2 failure replicas
|
|
4143
|
+
// - emit a success show-more node with hidden count 3
|
|
4144
|
+
// - emit a failure show-more node with hidden count 2
|
|
4145
|
+
const strand = {
|
|
4146
|
+
source_spec: 'media:pdf;list',
|
|
4147
|
+
target_spec: 'media:txt',
|
|
4148
|
+
steps: [
|
|
4149
|
+
makeForEachStep('media:pdf;list'),
|
|
4150
|
+
makeCapStep('cap:in="media:pdf";op=a;out="media:png"', 'a', 'media:pdf', 'media:png', false, false),
|
|
4151
|
+
makeCapStep('cap:in="media:png";op=b;out="media:txt"', 'b', 'media:png', 'media:txt', false, false),
|
|
4152
|
+
makeCollectStep('media:txt'),
|
|
4153
|
+
],
|
|
4154
|
+
};
|
|
4155
|
+
const outcomes = [];
|
|
4156
|
+
for (let i = 0; i < 6; i++) {
|
|
4157
|
+
outcomes.push({ body_index: i, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 });
|
|
4158
|
+
}
|
|
4159
|
+
for (let i = 6; i < 10; i++) {
|
|
4160
|
+
outcomes.push({
|
|
4161
|
+
body_index: i,
|
|
4162
|
+
success: false,
|
|
4163
|
+
cap_urns: [],
|
|
4164
|
+
saved_paths: [],
|
|
4165
|
+
total_bytes: 0,
|
|
4166
|
+
duration_ms: 0,
|
|
4167
|
+
failed_cap: 'cap:in="media:png";op=b;out="media:txt"',
|
|
4168
|
+
error: 'oom',
|
|
4169
|
+
});
|
|
4170
|
+
}
|
|
4171
|
+
const payload = {
|
|
4172
|
+
resolved_strand: strand,
|
|
4173
|
+
body_outcomes: outcomes,
|
|
4174
|
+
visible_success_count: 3,
|
|
4175
|
+
visible_failure_count: 2,
|
|
4176
|
+
total_body_count: 10,
|
|
4177
|
+
};
|
|
4178
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4179
|
+
|
|
4180
|
+
// Count replica nodes by classes.
|
|
4181
|
+
let successNodes = 0;
|
|
4182
|
+
let failureNodes = 0;
|
|
4183
|
+
for (const n of built.replicaNodes) {
|
|
4184
|
+
if (n.classes === 'body-success') successNodes++;
|
|
4185
|
+
if (n.classes === 'body-failure') failureNodes++;
|
|
4186
|
+
}
|
|
4187
|
+
assertEqual(successNodes, 3 * 2, 'three successful bodies × two cap steps each = 6 success nodes');
|
|
4188
|
+
// Failed bodies truncate at failed_cap (cap b = second cap), so trace
|
|
4189
|
+
// length includes both cap a and cap b → 2 nodes per failed body.
|
|
4190
|
+
assertEqual(failureNodes, 2 * 2, 'two failed bodies × two nodes each (trace truncated at failed_cap)');
|
|
4191
|
+
|
|
4192
|
+
// Show-more nodes: one for success (hidden 3), one for failure (hidden 2).
|
|
4193
|
+
const successShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'success');
|
|
4194
|
+
const failureShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'failure');
|
|
4195
|
+
assert(successShowMore !== undefined, 'success show-more present');
|
|
4196
|
+
assert(failureShowMore !== undefined, 'failure show-more present');
|
|
4197
|
+
assertEqual(successShowMore.data.hiddenCount, 3, 'success hidden count = 3');
|
|
4198
|
+
assertEqual(failureShowMore.data.hiddenCount, 2, 'failure hidden count = 2');
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace() {
|
|
4202
|
+
// A failure without failed_cap (e.g. infrastructure failure) must still
|
|
4203
|
+
// render the full body trace — the builder must not crash or produce
|
|
4204
|
+
// zero replicas.
|
|
4205
|
+
const strand = {
|
|
4206
|
+
source_spec: 'media:pdf;list',
|
|
4207
|
+
target_spec: 'media:txt',
|
|
4208
|
+
steps: [
|
|
4209
|
+
makeForEachStep('media:pdf;list'),
|
|
4210
|
+
makeCapStep('cap:in="media:pdf";op=a;out="media:txt"', 'a', 'media:pdf', 'media:txt', false, false),
|
|
4211
|
+
makeCollectStep('media:txt'),
|
|
4212
|
+
],
|
|
4213
|
+
};
|
|
4214
|
+
const payload = {
|
|
4215
|
+
resolved_strand: strand,
|
|
4216
|
+
body_outcomes: [
|
|
4217
|
+
{ body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, error: 'unknown' },
|
|
4218
|
+
],
|
|
4219
|
+
visible_success_count: 0,
|
|
4220
|
+
visible_failure_count: 5,
|
|
4221
|
+
total_body_count: 1,
|
|
4222
|
+
};
|
|
4223
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4224
|
+
let failureNodes = 0;
|
|
4225
|
+
for (const n of built.replicaNodes) {
|
|
4226
|
+
if (n.classes === 'body-failure') failureNodes++;
|
|
4227
|
+
}
|
|
4228
|
+
assertEqual(failureNodes, 1, 'full trace (one cap) renders as one failure node');
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
4232
|
+
// The renderer matches failed_cap against step cap URNs via
|
|
4233
|
+
// CapUrn.isEquivalent, NOT string equality. Feed a payload where
|
|
4234
|
+
// failed_cap and the step's cap_urn differ only in tag order — they
|
|
4235
|
+
// should still match, proving URNs are not treated as strings.
|
|
4236
|
+
const strand = {
|
|
4237
|
+
source_spec: 'media:a',
|
|
4238
|
+
target_spec: 'media:c',
|
|
4239
|
+
steps: [
|
|
4240
|
+
makeForEachStep('media:a;list'),
|
|
4241
|
+
// Canonical form places tags alphabetically: op after in/out.
|
|
4242
|
+
makeCapStep(
|
|
4243
|
+
'cap:in="media:a";op=x;out="media:b"',
|
|
4244
|
+
'x', 'media:a', 'media:b', false, false
|
|
4245
|
+
),
|
|
4246
|
+
makeCapStep(
|
|
4247
|
+
'cap:in="media:b";op=y;out="media:c"',
|
|
4248
|
+
'y', 'media:b', 'media:c', false, false
|
|
4249
|
+
),
|
|
4250
|
+
makeCollectStep('media:c'),
|
|
4251
|
+
],
|
|
4252
|
+
};
|
|
4253
|
+
// The failed_cap URN is semantically the same as step 1 (cap y). If
|
|
4254
|
+
// CapUrn.fromString canonicalizes (it should), any equivalent form
|
|
4255
|
+
// will match. Feed a fully-specified form that is equivalent.
|
|
4256
|
+
const payload = {
|
|
4257
|
+
resolved_strand: strand,
|
|
4258
|
+
body_outcomes: [
|
|
4259
|
+
{
|
|
4260
|
+
body_index: 0,
|
|
4261
|
+
success: false,
|
|
4262
|
+
cap_urns: [],
|
|
4263
|
+
saved_paths: [],
|
|
4264
|
+
total_bytes: 0,
|
|
4265
|
+
duration_ms: 0,
|
|
4266
|
+
failed_cap: 'cap:in="media:b";out="media:c";op=y', // different tag order
|
|
4267
|
+
error: 'fail',
|
|
4268
|
+
},
|
|
4269
|
+
],
|
|
4270
|
+
visible_success_count: 0,
|
|
4271
|
+
visible_failure_count: 1,
|
|
4272
|
+
total_body_count: 1,
|
|
4273
|
+
};
|
|
4274
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4275
|
+
let failureNodes = 0;
|
|
4276
|
+
for (const n of built.replicaNodes) {
|
|
4277
|
+
if (n.classes === 'body-failure') failureNodes++;
|
|
4278
|
+
}
|
|
4279
|
+
// cap x + cap y = 2 nodes for a body trace that terminates at cap y.
|
|
4280
|
+
assertEqual(failureNodes, 2, 'trace truncates at cap y via isEquivalent, yielding 2 failure nodes');
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
// ---------------- machine builder ----------------
|
|
4284
|
+
|
|
4285
|
+
function testRenderer_validateMachinePayload_rejectsUnknownKind() {
|
|
4286
|
+
let threw = false;
|
|
4287
|
+
try {
|
|
4288
|
+
rendererValidateMachinePayload({
|
|
4289
|
+
elements: [{ kind: 'widget', graph_id: 'w1' }],
|
|
4290
|
+
});
|
|
4291
|
+
} catch (e) {
|
|
4292
|
+
threw = true;
|
|
4293
|
+
assert(e.message.includes('widget') || e.message.includes('kind'),
|
|
4294
|
+
'error must name the bad kind');
|
|
4295
|
+
}
|
|
4296
|
+
assert(threw, 'unknown element kind must throw');
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
function testRenderer_buildMachineGraphData_preservesTokenIds() {
|
|
4300
|
+
// Token IDs are the bridge for editor cross-highlighting. Every
|
|
4301
|
+
// element MUST carry its tokenId through into the cytoscape data.
|
|
4302
|
+
const data = {
|
|
4303
|
+
elements: [
|
|
4304
|
+
{ kind: 'node', graph_id: 'n1', label: 'a', token_id: 't-node-a' },
|
|
4305
|
+
{ kind: 'cap', graph_id: 'c1', label: 'fn', token_id: 't-cap-fn' },
|
|
4306
|
+
{ kind: 'edge', graph_id: 'e1', source_graph_id: 'n1', target_graph_id: 'c1', label: '', token_id: 't-edge-1' },
|
|
4307
|
+
],
|
|
4308
|
+
};
|
|
4309
|
+
const built = rendererBuildMachineGraphData(data);
|
|
4310
|
+
const nodeTokens = built.nodes.map(n => n.data.tokenId).sort();
|
|
4311
|
+
assertEqual(JSON.stringify(nodeTokens), JSON.stringify(['t-cap-fn', 't-node-a']),
|
|
4312
|
+
'node tokenIds must round-trip');
|
|
4313
|
+
assertEqual(built.edges.length, 1, 'one edge');
|
|
4314
|
+
assertEqual(built.edges[0].data.tokenId, 't-edge-1', 'edge tokenId must round-trip');
|
|
4315
|
+
// Kinds are carried as element data for editor-side filtering.
|
|
4316
|
+
const kinds = built.nodes.map(n => n.data.kind).sort();
|
|
4317
|
+
assertEqual(JSON.stringify(kinds), JSON.stringify(['cap', 'node']),
|
|
4318
|
+
'element kinds must be preserved');
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
|
|
4322
|
+
let threw = false;
|
|
4323
|
+
try {
|
|
4324
|
+
rendererBuildMachineGraphData({
|
|
4325
|
+
elements: [
|
|
4326
|
+
{ kind: 'edge', graph_id: 'e1', target_graph_id: 't' },
|
|
4327
|
+
],
|
|
4328
|
+
});
|
|
4329
|
+
} catch (e) {
|
|
4330
|
+
threw = true;
|
|
4331
|
+
}
|
|
4332
|
+
assert(threw, 'edge without source_graph_id must throw');
|
|
4333
|
+
}
|
|
4334
|
+
|
|
3555
4335
|
// ============================================================================
|
|
3556
4336
|
// Test runner
|
|
3557
4337
|
// ============================================================================
|
|
@@ -3719,6 +4499,9 @@ async function runTests() {
|
|
|
3719
4499
|
runTest('JS: get_extension_mappings', testJS_getExtensionMappings);
|
|
3720
4500
|
runTest('JS: cap_with_media_specs', testJS_capWithMediaSpecs);
|
|
3721
4501
|
runTest('JS: cap_json_serialization', testJS_capJSONSerialization);
|
|
4502
|
+
runTest('JS: cap_documentation_round_trip', testJS_capDocumentationRoundTrip);
|
|
4503
|
+
runTest('JS: cap_documentation_omitted_when_null', testJS_capDocumentationOmittedWhenNull);
|
|
4504
|
+
runTest('JS: media_spec_documentation_propagates_through_resolve', testJS_mediaSpecDocumentationPropagatesThroughResolve);
|
|
3722
4505
|
runTest('JS: stdin_source_kind_constants', testJS_stdinSourceKindConstants);
|
|
3723
4506
|
runTest('JS: stdin_source_null_data', testJS_stdinSourceNullData);
|
|
3724
4507
|
const p1 = runTest('JS: args_passed_to_executeCap', testJS_argsPassedToExecuteCap);
|
|
@@ -3808,6 +4591,17 @@ async function runTests() {
|
|
|
3808
4591
|
runTest('MACHINE:malformed_input_fails', testMachine_malformedInputFails);
|
|
3809
4592
|
runTest('MACHINE:unterminated_bracket_fails', testMachine_unterminatedBracketFails);
|
|
3810
4593
|
|
|
4594
|
+
// machine module: line-based mode tests
|
|
4595
|
+
console.log('\n--- machine/parser.rs (line-based) ---');
|
|
4596
|
+
runTest('MACHINE:line_based_simple_chain', testMachine_lineBasedSimpleChain);
|
|
4597
|
+
runTest('MACHINE:line_based_two_step_chain', testMachine_lineBasedTwoStepChain);
|
|
4598
|
+
runTest('MACHINE:line_based_loop', testMachine_lineBasedLoop);
|
|
4599
|
+
runTest('MACHINE:line_based_fan_in', testMachine_lineBasedFanIn);
|
|
4600
|
+
runTest('MACHINE:mixed_bracketed_and_line_based', testMachine_mixedBracketedAndLineBased);
|
|
4601
|
+
runTest('MACHINE:line_based_equivalent_to_bracketed', testMachine_lineBasedEquivalentToBracketed);
|
|
4602
|
+
runTest('MACHINE:line_based_format_serialization', testMachine_lineBasedFormatSerialization);
|
|
4603
|
+
runTest('MACHINE:line_based_and_bracketed_parse_same_graph', testMachine_lineBasedAndBracketedParseSameGraph);
|
|
4604
|
+
|
|
3811
4605
|
// machine module: graph tests (mirrors graph.rs)
|
|
3812
4606
|
console.log('\n--- machine/graph.rs ---');
|
|
3813
4607
|
runTest('MACHINE:edge_equivalence_same_urns', testMachine_edgeEquivalenceSameUrns);
|
|
@@ -3888,6 +4682,43 @@ async function runTests() {
|
|
|
3888
4682
|
runTest('REGISTRY: capRegistryClient_construction', testMachine_capRegistryClient_construction);
|
|
3889
4683
|
runTest('REGISTRY: capRegistryEntry_defaults', testMachine_capRegistryEntry_defaults);
|
|
3890
4684
|
|
|
4685
|
+
// cap-graph-renderer pure helpers (no DOM dependency)
|
|
4686
|
+
console.log('\n--- cap-graph-renderer helpers ---');
|
|
4687
|
+
runTest('RENDERER: cardinalityLabel_allFourCases', testRenderer_cardinalityLabel_allFourCases);
|
|
4688
|
+
runTest('RENDERER: cardinalityLabel_usesUnicodeArrow', testRenderer_cardinalityLabel_usesUnicodeArrow);
|
|
4689
|
+
runTest('RENDERER: cardinalityFromCap_findsStdinArg', testRenderer_cardinalityFromCap_findsStdinArgNotFirstArg);
|
|
4690
|
+
runTest('RENDERER: cardinalityFromCap_scalarDefaults', testRenderer_cardinalityFromCap_scalarDefaultsWhenFieldsMissing);
|
|
4691
|
+
runTest('RENDERER: cardinalityFromCap_outputOnlySequence', testRenderer_cardinalityFromCap_outputOnlySequence);
|
|
4692
|
+
runTest('RENDERER: cardinalityFromCap_rejectsStringBool', testRenderer_cardinalityFromCap_rejectsStringIsSequence);
|
|
4693
|
+
runTest('RENDERER: cardinalityFromCap_throwsOnNonObject', testRenderer_cardinalityFromCap_throwsOnNonObject);
|
|
4694
|
+
runTest('RENDERER: canonicalMediaUrn_normalizesTagOrder', testRenderer_canonicalMediaUrn_normalizesTagOrder);
|
|
4695
|
+
runTest('RENDERER: canonicalMediaUrn_preservesValueTags', testRenderer_canonicalMediaUrn_preservesValueTags);
|
|
4696
|
+
runTest('RENDERER: canonicalMediaUrn_rejectsCapUrn', testRenderer_canonicalMediaUrn_rejectsCapUrn);
|
|
4697
|
+
runTest('RENDERER: mediaNodeLabel_valueAndMarker', testRenderer_mediaNodeLabel_oneLinePerTag_valueAndMarker);
|
|
4698
|
+
runTest('RENDERER: mediaNodeLabel_stableAcrossTagOrder', testRenderer_mediaNodeLabel_stableAcrossTagOrder);
|
|
4699
|
+
|
|
4700
|
+
console.log('\n--- cap-graph-renderer strand builder ---');
|
|
4701
|
+
runTest('RENDERER: validateStrandStep_unknownVariant', testRenderer_validateStrandStep_rejectsUnknownVariant);
|
|
4702
|
+
runTest('RENDERER: validateStrandStep_booleanIsSequence', testRenderer_validateStrandStep_requiresBooleanIsSequence);
|
|
4703
|
+
runTest('RENDERER: classifyStrandCapSteps_simple', testRenderer_classifyStrandCapSteps_capFlags);
|
|
4704
|
+
runTest('RENDERER: classifyStrandCapSteps_nested', testRenderer_classifyStrandCapSteps_nestedForks);
|
|
4705
|
+
runTest('RENDERER: buildStrand_labelsForkBoundaries', testRenderer_buildStrandGraphData_labelsForkBoundaries);
|
|
4706
|
+
runTest('RENDERER: buildStrand_collectFixupEdge', testRenderer_buildStrandGraphData_collectFixupEdge);
|
|
4707
|
+
runTest('RENDERER: buildStrand_plainCapNoMarker', testRenderer_buildStrandGraphData_plainCapNoMarker);
|
|
4708
|
+
runTest('RENDERER: buildStrand_sequenceShowsCardinality', testRenderer_buildStrandGraphData_sequenceShowsCardinality);
|
|
4709
|
+
runTest('RENDERER: validateStrand_missingSourceSpec', testRenderer_validateStrandPayload_missingSourceSpec);
|
|
4710
|
+
|
|
4711
|
+
console.log('\n--- cap-graph-renderer run builder ---');
|
|
4712
|
+
runTest('RENDERER: validateBodyOutcome_negativeIndex', testRenderer_validateBodyOutcome_rejectsNegativeIndex);
|
|
4713
|
+
runTest('RENDERER: buildRun_pagesSuccessesAndFailures', testRenderer_buildRunGraphData_pagesSuccessesAndFailures);
|
|
4714
|
+
runTest('RENDERER: buildRun_failureWithoutFailedCap', testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
|
|
4715
|
+
runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
|
|
4716
|
+
|
|
4717
|
+
console.log('\n--- cap-graph-renderer machine builder ---');
|
|
4718
|
+
runTest('RENDERER: validateMachine_unknownKind', testRenderer_validateMachinePayload_rejectsUnknownKind);
|
|
4719
|
+
runTest('RENDERER: buildMachine_preservesTokenIds', testRenderer_buildMachineGraphData_preservesTokenIds);
|
|
4720
|
+
runTest('RENDERER: buildMachine_rejectsEdgeMissingSource', testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource);
|
|
4721
|
+
|
|
3891
4722
|
// Summary
|
|
3892
4723
|
console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
|
|
3893
4724
|
if (failCount > 0) {
|
package/machine.pegjs
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Machine notation grammar for Peggy.
|
|
2
2
|
//
|
|
3
3
|
// This grammar mirrors the Rust pest grammar in machine.pest exactly.
|
|
4
4
|
// All actions return location() for LSP position tracking.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// Two equally valid statement forms:
|
|
7
|
+
//
|
|
8
|
+
// Bracketed (one or more statements per line, any layout):
|
|
7
9
|
// [extract cap:in="media:pdf";op=extract;out="media:txt;textable"]
|
|
8
10
|
// [doc -> extract -> text]
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
+
//
|
|
12
|
+
// Line-based (one statement per line, no brackets):
|
|
13
|
+
// extract cap:in="media:pdf";op=extract;out="media:txt;textable"
|
|
14
|
+
// doc -> extract -> text
|
|
15
|
+
// (thumbnail, model_spec) -> describe -> description
|
|
16
|
+
// pages -> LOOP p2t -> texts
|
|
17
|
+
//
|
|
18
|
+
// Both forms can be freely mixed in the same program.
|
|
11
19
|
|
|
12
20
|
program = _ stmts:stmt* _ { return stmts; }
|
|
13
21
|
|
|
14
22
|
stmt = "[" _ inner:inner _ "]" _ { return inner; }
|
|
23
|
+
/ inner:inner _ { return inner; }
|
|
15
24
|
|
|
16
25
|
inner = wiring / header
|
|
17
26
|
|
|
@@ -48,11 +57,14 @@ alias = $( [a-zA-Z_] [a-zA-Z0-9_-]* )
|
|
|
48
57
|
// Cap URN with location tracking
|
|
49
58
|
cap_urn_loc = c:cap_urn { return { value: c, location: location() }; }
|
|
50
59
|
|
|
51
|
-
// Cap URN: starts with "cap:", reads until
|
|
52
|
-
//
|
|
60
|
+
// Cap URN: starts with "cap:", reads until a statement terminator:
|
|
61
|
+
// "]" for bracketed mode, or newline for line-based mode.
|
|
62
|
+
// Quoted strings can contain "]" and newlines.
|
|
53
63
|
cap_urn = $( "cap:" cap_urn_body* )
|
|
54
64
|
|
|
55
|
-
cap_urn_body = quoted_value / ( !"]" . )
|
|
65
|
+
cap_urn_body = quoted_value / ( !"]" !NL . )
|
|
66
|
+
|
|
67
|
+
NL "newline" = "\r\n" / "\n" / "\r"
|
|
56
68
|
|
|
57
69
|
// Quoted strings support escaped quotes.
|
|
58
70
|
quoted_value = '"' ( '\\"' / '\\\\' / (!'"' .) )* '"'
|
package/package.json
CHANGED