capdag 1.222.603 → 1.233.649
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/cap-fab-renderer.js +101 -2
- package/capdag.js +156 -75
- package/capdag.test.js +251 -49
- package/machine-parser.js +73 -120
- package/machine.pegjs +6 -7
- package/package.json +1 -1
package/capdag.test.js
CHANGED
|
@@ -126,14 +126,14 @@ function test6204_Urn(tags) {
|
|
|
126
126
|
// Helper to create a Cap for testing
|
|
127
127
|
function makeCap(urnString, title) {
|
|
128
128
|
const capUrn = CapUrn.fromString(urnString);
|
|
129
|
-
return new Cap(capUrn, title, 'test', title);
|
|
129
|
+
return new Cap(capUrn, title, ['test'], title);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// Helper to create caps with specific in/out media URNs for graph testing
|
|
133
133
|
function makeGraphCap(inUrn, outUrn, title) {
|
|
134
134
|
const urnString = `cap:in="${inUrn}";convert;out="${outUrn}"`;
|
|
135
135
|
const capUrn = CapUrn.fromString(urnString);
|
|
136
|
-
return new Cap(capUrn, title, 'convert', title);
|
|
136
|
+
return new Cap(capUrn, title, ['convert'], title);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// ============================================================================
|
|
@@ -1271,7 +1271,7 @@ function test116_capArgConstructors() {
|
|
|
1271
1271
|
// TEST150: JSON roundtrip
|
|
1272
1272
|
function test150_capManifestJsonSerialization() {
|
|
1273
1273
|
const capUrn = CapUrn.fromString(test6204_Urn('extract;target=metadata'));
|
|
1274
|
-
const cap = new Cap(capUrn, 'Extract Metadata', 'extract-metadata');
|
|
1274
|
+
const cap = new Cap(capUrn, 'Extract Metadata', ['extract-metadata']);
|
|
1275
1275
|
cap.addArg(new CapArg('media:ext=pdf', true, [new ArgSource({ stdin: 'media:ext=pdf' })]));
|
|
1276
1276
|
cap.addArg(new CapArg(
|
|
1277
1277
|
'media:chunk-size;enc=utf-8;numeric',
|
|
@@ -1719,7 +1719,7 @@ function test6242_JS_resolveMediaUrnFromSpecs() {
|
|
|
1719
1719
|
// TEST6246: J s cap j s o n serialization
|
|
1720
1720
|
function test6246_JS_capJSONSerialization() {
|
|
1721
1721
|
const urn = CapUrn.fromString(test6204_Urn('test'));
|
|
1722
|
-
const cap = new Cap(urn, 'Test Cap', 'test_command');
|
|
1722
|
+
const cap = new Cap(urn, 'Test Cap', ['test_command']);
|
|
1723
1723
|
cap.arguments = {
|
|
1724
1724
|
required: [{ name: 'input', media_urn: MEDIA_STRING }],
|
|
1725
1725
|
optional: []
|
|
@@ -1742,7 +1742,7 @@ function test6246_JS_capJSONSerialization() {
|
|
|
1742
1742
|
// surface as failures here.
|
|
1743
1743
|
function test6249_JS_capDocumentationRoundTrip() {
|
|
1744
1744
|
const urn = CapUrn.fromString(test6204_Urn('documented'));
|
|
1745
|
-
const cap = new Cap(urn, 'Documented Cap', 'documented');
|
|
1745
|
+
const cap = new Cap(urn, 'Documented Cap', ['documented']);
|
|
1746
1746
|
const body = '# Documented Cap\r\n\nDoes the thing.\n\n```bash\necho "hi"\n```\n\nSee also: \u2605\n';
|
|
1747
1747
|
cap.setDocumentation(body);
|
|
1748
1748
|
assertEqual(cap.getDocumentation(), body, 'Setter must store the body verbatim');
|
|
@@ -1764,7 +1764,7 @@ function test6249_JS_capDocumentationRoundTrip() {
|
|
|
1764
1764
|
// (which has no null sentinel) and pollute generated JSON.
|
|
1765
1765
|
function test6253_JS_capDocumentationOmittedWhenNull() {
|
|
1766
1766
|
const urn = CapUrn.fromString(test6204_Urn('undocumented'));
|
|
1767
|
-
const cap = new Cap(urn, 'Undocumented Cap', 'undocumented');
|
|
1767
|
+
const cap = new Cap(urn, 'Undocumented Cap', ['undocumented']);
|
|
1768
1768
|
assertEqual(cap.getDocumentation(), null, 'Default documentation must be null');
|
|
1769
1769
|
|
|
1770
1770
|
const json = cap.toJSON();
|
|
@@ -1854,8 +1854,10 @@ function test6269_JS_mediaDefConstruction() {
|
|
|
1854
1854
|
// to assert on. Tests that need an empty channel use literals inline.
|
|
1855
1855
|
const sampleRegistry = {
|
|
1856
1856
|
schemaVersion: '5.0',
|
|
1857
|
+
registryVersion: 1,
|
|
1857
1858
|
lastUpdated: '2026-02-07T16:48:28Z',
|
|
1858
1859
|
registryUrl: 'https://test.example/manifest',
|
|
1860
|
+
fabricRegistryUrl: 'https://fabric.test',
|
|
1859
1861
|
channels: {
|
|
1860
1862
|
release: { cartridges: {
|
|
1861
1863
|
pdfcartridge: {
|
|
@@ -2089,7 +2091,7 @@ function test323_cartridgeRepoServerValidateRegistry() {
|
|
|
2089
2091
|
// Missing channels object
|
|
2090
2092
|
threw = false;
|
|
2091
2093
|
try {
|
|
2092
|
-
new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest'});
|
|
2094
|
+
new CartridgeRepoServer({schemaVersion: '5.0', registryVersion: 1, fabricRegistryUrl: 'https://fabric.test', registryUrl: 'https://test.example/manifest'});
|
|
2093
2095
|
} catch (e) {
|
|
2094
2096
|
threw = true;
|
|
2095
2097
|
assert(e.message.includes('channels'), 'Should reject missing channels');
|
|
@@ -2099,7 +2101,7 @@ function test323_cartridgeRepoServerValidateRegistry() {
|
|
|
2099
2101
|
// Missing one of the two required channels
|
|
2100
2102
|
threw = false;
|
|
2101
2103
|
try {
|
|
2102
|
-
new CartridgeRepoServer({schemaVersion: '5.0', registryUrl: 'https://test.example/manifest', channels: {release: {cartridges: {}}}});
|
|
2104
|
+
new CartridgeRepoServer({schemaVersion: '5.0', registryVersion: 1, fabricRegistryUrl: 'https://fabric.test', registryUrl: 'https://test.example/manifest', channels: {release: {cartridges: {}}}});
|
|
2103
2105
|
} catch (e) {
|
|
2104
2106
|
threw = true;
|
|
2105
2107
|
assert(e.message.includes('nightly'), 'Should require nightly channel');
|
|
@@ -2581,7 +2583,9 @@ function test1874_registryUrlFromBuildEnvRejectsEmptyString() {
|
|
|
2581
2583
|
function discoveryInstallFixture(root, slug, channelFolder, name, version, cartridgeJson, entry) {
|
|
2582
2584
|
const fs = require('fs');
|
|
2583
2585
|
const path = require('path');
|
|
2584
|
-
|
|
2586
|
+
// {slug}/v1/{channel}/{name}/{version} — the version level pins to the host
|
|
2587
|
+
// registry version (the tests construct identities at cartridgeRegistryVersion 1).
|
|
2588
|
+
const dir = path.join(root, slug, 'v1', channelFolder, name, version);
|
|
2585
2589
|
fs.mkdirSync(dir, { recursive: true });
|
|
2586
2590
|
if (cartridgeJson !== null && cartridgeJson !== undefined) {
|
|
2587
2591
|
fs.writeFileSync(path.join(dir, 'cartridge.json'), cartridgeJson);
|
|
@@ -2634,7 +2638,7 @@ async function test1875_scanAllReachesBothDevAndRegistrySlugs() {
|
|
|
2634
2638
|
const url = 'https://cartridges.example.com/manifest';
|
|
2635
2639
|
const rslug = slugForSync(url);
|
|
2636
2640
|
// Host baked for a DIFFERENT registry than the on-disk registry cartridge.
|
|
2637
|
-
const host = new DiscoveryIdentity({ channel: 'nightly', registryUrl: 'https://other.example.com/manifest', fabricManifestVersion: 1 });
|
|
2641
|
+
const host = new DiscoveryIdentity({ channel: 'nightly', registryUrl: 'https://other.example.com/manifest', fabricManifestVersion: 1, cartridgeRegistryVersion: 1 });
|
|
2638
2642
|
discoveryInstallFixture(root, 'dev', 'nightly', 'devcart', '1.0.0', discoveryDevCartridgeJson('nightly', 1), 'cart');
|
|
2639
2643
|
discoveryInstallFixture(root, rslug, 'nightly', 'regcart', '1.0.0', discoveryRegistryCartridgeJson(url, 'nightly', 1), 'cart');
|
|
2640
2644
|
const out = await discoverCartridges(root, host);
|
|
@@ -2658,7 +2662,7 @@ async function test1876_otherChannelSubtreeIsSkipped() {
|
|
|
2658
2662
|
const url = 'https://cartridges.example.com/manifest';
|
|
2659
2663
|
const rslug = slugForSync(url);
|
|
2660
2664
|
discoveryInstallFixture(root, rslug, 'release', 'regcart', '1.0.0', discoveryRegistryCartridgeJson(url, 'release', 1), 'cart');
|
|
2661
|
-
const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
|
|
2665
|
+
const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
|
|
2662
2666
|
assertEqual(out.length, 0, 'a release-only slug must be invisible to a nightly host');
|
|
2663
2667
|
} finally {
|
|
2664
2668
|
discoveryRemoveRoot(root);
|
|
@@ -2675,7 +2679,7 @@ async function test1877_registryCartridgeUnderWrongSlugIsBadInstall() {
|
|
|
2675
2679
|
const wrongSlug = slugForSync('https://somewhere-else.example.com/manifest');
|
|
2676
2680
|
const json = discoveryRegistryCartridgeJson(url, 'nightly', 1);
|
|
2677
2681
|
discoveryInstallFixture(root, wrongSlug, 'nightly', 'cart', '1.0.0', json, 'cart');
|
|
2678
|
-
const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
|
|
2682
|
+
const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
|
|
2679
2683
|
discoveryExpectIncompatible(out, CartridgeAttachmentErrorKind.BAD_INSTALLATION);
|
|
2680
2684
|
} finally {
|
|
2681
2685
|
discoveryRemoveRoot(root);
|
|
@@ -2703,7 +2707,7 @@ async function test1878_bundledProviderWithoutBakedHashIsRejected() {
|
|
|
2703
2707
|
installed_at: '2024-01-01T00:00:00Z', installed_from: 'bundle', fabric_manifest_version: 1,
|
|
2704
2708
|
});
|
|
2705
2709
|
discoveryInstallFixture(root, 'dev', 'nightly', 'cart', '1.0.0', json, 'cart');
|
|
2706
|
-
const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1 }));
|
|
2710
|
+
const out = await discoverCartridges(root, new DiscoveryIdentity({ channel: 'nightly', registryUrl: null, fabricManifestVersion: 1, cartridgeRegistryVersion: 1 }));
|
|
2707
2711
|
discoveryExpectIncompatible(out, CartridgeAttachmentErrorKind.BAD_INSTALLATION);
|
|
2708
2712
|
assert(out[0].error.message.includes('bundled provider integrity'),
|
|
2709
2713
|
`message should name the bundled-integrity failure: ${out[0].error.message}`);
|
|
@@ -2954,7 +2958,7 @@ function test6544_builderRejectsStructuralKeys() {
|
|
|
2954
2958
|
// TEST1294: RULE11 - void-input cap with stdin source rejected
|
|
2955
2959
|
function test1294_rule11VoidInputWithStdinRejected() {
|
|
2956
2960
|
const urn = CapUrn.fromString('cap:in="media:void";test;out="media:string"');
|
|
2957
|
-
const cap = new Cap(urn, 'Test', 'test-cmd');
|
|
2961
|
+
const cap = new Cap(urn, 'Test', ['test-cmd']);
|
|
2958
2962
|
const stdinSource = ArgSource.fromJSON({ stdin: 'media:string' });
|
|
2959
2963
|
cap.args = [new CapArg('media:string', true, [stdinSource])];
|
|
2960
2964
|
try {
|
|
@@ -2969,7 +2973,7 @@ function test1294_rule11VoidInputWithStdinRejected() {
|
|
|
2969
2973
|
// TEST1295: RULE11 - non-void-input cap without stdin source rejected
|
|
2970
2974
|
function test1295_rule11NonVoidInputWithoutStdinRejected() {
|
|
2971
2975
|
const urn = CapUrn.fromString('cap:in="media:string";test;out="media:string"');
|
|
2972
|
-
const cap = new Cap(urn, 'Test', 'test-cmd');
|
|
2976
|
+
const cap = new Cap(urn, 'Test', ['test-cmd']);
|
|
2973
2977
|
const posSource = ArgSource.fromJSON({ cli_flag: '--name' });
|
|
2974
2978
|
cap.args = [new CapArg('media:string', true, [posSource])];
|
|
2975
2979
|
try {
|
|
@@ -2984,7 +2988,7 @@ function test1295_rule11NonVoidInputWithoutStdinRejected() {
|
|
|
2984
2988
|
// TEST1296: RULE11 - void-input cap with only cli_flag sources passes
|
|
2985
2989
|
function test1296_rule11VoidInputCliFlagOnly() {
|
|
2986
2990
|
const urn = CapUrn.fromString('cap:in="media:void";test;out="media:string"');
|
|
2987
|
-
const cap = new Cap(urn, 'Test', 'test-cmd');
|
|
2991
|
+
const cap = new Cap(urn, 'Test', ['test-cmd']);
|
|
2988
2992
|
const flagSource = ArgSource.fromJSON({ cli_flag: '--name' });
|
|
2989
2993
|
cap.args = [new CapArg('media:string', true, [flagSource])];
|
|
2990
2994
|
// Should not throw
|
|
@@ -2994,7 +2998,7 @@ function test1296_rule11VoidInputCliFlagOnly() {
|
|
|
2994
2998
|
// TEST1297: RULE11 - non-void-input cap with stdin source passes
|
|
2995
2999
|
function test1297_rule11NonVoidInputWithStdin() {
|
|
2996
3000
|
const urn = CapUrn.fromString('cap:in="media:string";test;out="media:string"');
|
|
2997
|
-
const cap = new Cap(urn, 'Test', 'test-cmd');
|
|
3001
|
+
const cap = new Cap(urn, 'Test', ['test-cmd']);
|
|
2998
3002
|
const stdinSource = ArgSource.fromJSON({ stdin: 'media:string' });
|
|
2999
3003
|
cap.args = [new CapArg('media:string', true, [stdinSource])];
|
|
3000
3004
|
// Should not throw
|
|
@@ -3308,14 +3312,25 @@ function test6294_Machine_fanInSecondaryUnassignedGetsWildcard() {
|
|
|
3308
3312
|
assertEqual(g.edges()[0].sources[1].toString(), 'media:');
|
|
3309
3313
|
}
|
|
3310
3314
|
|
|
3311
|
-
// TEST6306:
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3315
|
+
// TEST6306: The retired LOOP keyword is no longer grammar. A wiring that still
|
|
3316
|
+
// writes `LOOP <cap>` before the cap alias no longer parses — `LOOP` is now an
|
|
3317
|
+
// ordinary alias, so `pages -> LOOP p2t -> texts` is two aliases in the cap
|
|
3318
|
+
// position with no arrow between them, which is a syntax error.
|
|
3319
|
+
function test6306_Machine_loopKeywordIsNotGrammar() {
|
|
3320
|
+
assertThrowsWithCode(
|
|
3321
|
+
() => Machine.fromString(
|
|
3322
|
+
'[p2t cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"]' +
|
|
3323
|
+
'[pages -> LOOP p2t -> texts]'
|
|
3324
|
+
),
|
|
3325
|
+
MachineSyntaxErrorCodes.PARSE_ERROR
|
|
3326
|
+
);
|
|
3327
|
+
|
|
3328
|
+
// `LOOP` on its own in the cap position parses fine — it is just an alias —
|
|
3329
|
+
// but resolves to nothing, proving it carries no special meaning.
|
|
3330
|
+
assertThrowsWithCode(
|
|
3331
|
+
() => Machine.fromString('[pages -> LOOP -> texts]'),
|
|
3332
|
+
MachineSyntaxErrorCodes.UNDEFINED_ALIAS
|
|
3316
3333
|
);
|
|
3317
|
-
assertEqual(g.edgeCount(), 1);
|
|
3318
|
-
assertEqual(g.edges()[0].isLoop, true);
|
|
3319
3334
|
}
|
|
3320
3335
|
|
|
3321
3336
|
// TEST6308: Machine undefined alias fails
|
|
@@ -3416,14 +3431,16 @@ function test6331_Machine_lineBasedTwoStepChain() {
|
|
|
3416
3431
|
assertEqual(g.edgeCount(), 2);
|
|
3417
3432
|
}
|
|
3418
3433
|
|
|
3419
|
-
// TEST6334:
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3434
|
+
// TEST6334: The retired LOOP keyword is not grammar in line-based mode either —
|
|
3435
|
+
// `pages -> LOOP p2t -> texts` is a syntax error, same as the bracketed form.
|
|
3436
|
+
function test6334_Machine_lineBasedLoopKeywordIsNotGrammar() {
|
|
3437
|
+
assertThrowsWithCode(
|
|
3438
|
+
() => Machine.fromString(
|
|
3439
|
+
'p2t cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"\n' +
|
|
3440
|
+
'pages -> LOOP p2t -> texts'
|
|
3441
|
+
),
|
|
3442
|
+
MachineSyntaxErrorCodes.PARSE_ERROR
|
|
3424
3443
|
);
|
|
3425
|
-
assertEqual(g.edgeCount(), 1);
|
|
3426
|
-
assertEqual(g.edges()[0].isLoop, true);
|
|
3427
3444
|
}
|
|
3428
3445
|
|
|
3429
3446
|
// TEST6337: Machine line based fan in
|
|
@@ -3835,8 +3852,12 @@ function test6415_Machine_roundtripFanOut() {
|
|
|
3835
3852
|
'Fan-out round-trip failed: ' + notation);
|
|
3836
3853
|
}
|
|
3837
3854
|
|
|
3838
|
-
// TEST6417:
|
|
3839
|
-
|
|
3855
|
+
// TEST6417: A per-item map (`is_loop`) edge serializes WITHOUT any LOOP marker —
|
|
3856
|
+
// `is_loop` is a derived cardinality property, not authored notation text. The
|
|
3857
|
+
// pure-JS parse path has no cap definitions to re-derive cardinality, so the
|
|
3858
|
+
// reparsed edge has `isLoop === false`; editors get the derived value from the
|
|
3859
|
+
// engine, not from re-parsing.
|
|
3860
|
+
function test6417_Machine_loopEdgeSerializesWithoutLoopText() {
|
|
3840
3861
|
const original = new Machine([new MachineEdge(
|
|
3841
3862
|
[MediaUrn.fromString('media:disbound-page;enc=utf-8')],
|
|
3842
3863
|
CapUrn.fromString('cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"'),
|
|
@@ -3844,9 +3865,17 @@ function test6417_Machine_roundtripLoopEdge() {
|
|
|
3844
3865
|
true
|
|
3845
3866
|
)]);
|
|
3846
3867
|
const notation = original.toMachineNotation();
|
|
3868
|
+
assert(!notation.includes('LOOP'), 'serialized notation must not contain the retired LOOP keyword');
|
|
3869
|
+
|
|
3847
3870
|
const reparsed = Machine.fromString(notation);
|
|
3848
|
-
|
|
3849
|
-
assertEqual(reparsed.edges()[0].isLoop,
|
|
3871
|
+
assertEqual(reparsed.edgeCount(), 1);
|
|
3872
|
+
assertEqual(reparsed.edges()[0].isLoop, false,
|
|
3873
|
+
'pure-JS parse path cannot derive cardinality — isLoop defaults to false');
|
|
3874
|
+
// The structural payload (sources, cap, target) round-trips intact.
|
|
3875
|
+
assert(reparsed.edges()[0].capUrn.isEquivalent(original.edges()[0].capUrn),
|
|
3876
|
+
'cap URN must round-trip');
|
|
3877
|
+
assert(reparsed.edges()[0].target.isEquivalent(original.edges()[0].target),
|
|
3878
|
+
'target media must round-trip');
|
|
3850
3879
|
}
|
|
3851
3880
|
|
|
3852
3881
|
// TEST6419: Machine serialization is deterministic
|
|
@@ -4203,16 +4232,21 @@ function test6462_Machine_toMermaid_linearChain() {
|
|
|
4203
4232
|
assert(mermaid.includes('(['), 'Should have stadium shape nodes');
|
|
4204
4233
|
}
|
|
4205
4234
|
|
|
4206
|
-
// TEST6463:
|
|
4235
|
+
// TEST6463: Mermaid renders a per-item map (`is_loop`) edge with a dotted line
|
|
4236
|
+
// — `is_loop` is a kept render property — but emits NO "LOOP" text, since that
|
|
4237
|
+
// keyword is retired. The loop edge is built programmatically because the
|
|
4238
|
+
// grammar no longer has any way to author one.
|
|
4207
4239
|
function test6463_Machine_toMermaid_loopEdge() {
|
|
4208
|
-
const machine = Machine
|
|
4209
|
-
'
|
|
4210
|
-
'
|
|
4211
|
-
|
|
4240
|
+
const machine = new Machine([new MachineEdge(
|
|
4241
|
+
[MediaUrn.fromString('media:disbound-page;enc=utf-8')],
|
|
4242
|
+
CapUrn.fromString('cap:in="media:disbound-page;enc=utf-8";page-to-text;out="media:enc=utf-8;ext=txt"'),
|
|
4243
|
+
MediaUrn.fromString('media:enc=utf-8;ext=txt'),
|
|
4244
|
+
true
|
|
4245
|
+
)]);
|
|
4212
4246
|
const mermaid = machine.toMermaid();
|
|
4213
|
-
assert(mermaid.includes('LOOP'), '
|
|
4214
|
-
assert(mermaid.includes('-.'), 'Should use dotted line for
|
|
4215
|
-
assert(mermaid.includes('.->'), 'Should use dotted arrow for
|
|
4247
|
+
assert(!mermaid.includes('LOOP'), 'Must not emit the retired LOOP label');
|
|
4248
|
+
assert(mermaid.includes('-.'), 'Should use dotted line for the per-item map edge');
|
|
4249
|
+
assert(mermaid.includes('.->'), 'Should use dotted arrow for the per-item map edge');
|
|
4216
4250
|
}
|
|
4217
4251
|
|
|
4218
4252
|
// TEST6464: Machine to mermaid empty graph
|
|
@@ -4307,7 +4341,7 @@ function test6470_Machine_capRegistryEntry_defaults() {
|
|
|
4307
4341
|
assertEqual(entry.urn, 'cap:in=media:;test;out=media:', 'URN should match');
|
|
4308
4342
|
assertEqual(entry.title, '', 'Title should default to empty');
|
|
4309
4343
|
assertEqual(entry.description, '', 'Description should default to empty');
|
|
4310
|
-
|
|
4344
|
+
assert(Array.isArray(entry.aliases) && entry.aliases.length === 0, 'Aliases should default to an empty array');
|
|
4311
4345
|
assert(Array.isArray(entry.args), 'Args should default to array');
|
|
4312
4346
|
assertEqual(entry.args.length, 0, 'Args should be empty');
|
|
4313
4347
|
}
|
|
@@ -6307,7 +6341,7 @@ function test6736_axisWeightingDecodedLayout() {
|
|
|
6307
6341
|
// TEST6737: Cap with version=0 round-trips with no `version` key on wire
|
|
6308
6342
|
function test6737_capVersionZeroOmittedOnWire() {
|
|
6309
6343
|
const urn = CapUrn.fromString('cap:in="media:void";test-op;out="media:enc=utf-8;record"');
|
|
6310
|
-
const cap = new Cap(urn, 'Test Cap', 'test-op');
|
|
6344
|
+
const cap = new Cap(urn, 'Test Cap', ['test-op']);
|
|
6311
6345
|
// version defaults to 0
|
|
6312
6346
|
assertEqual(cap.version, 0, 'Default version should be 0');
|
|
6313
6347
|
const json = cap.toJSON();
|
|
@@ -6319,7 +6353,7 @@ function test6737_capVersionZeroOmittedOnWire() {
|
|
|
6319
6353
|
// TEST1848: Cap with version=N round-trips with `version: N` on wire
|
|
6320
6354
|
function test1848_capVersionNonZeroOnWire() {
|
|
6321
6355
|
const urn = CapUrn.fromString('cap:in="media:void";versioned-op;out="media:enc=utf-8;record"');
|
|
6322
|
-
const cap = new Cap(urn, 'Versioned Cap', 'versioned-op');
|
|
6356
|
+
const cap = new Cap(urn, 'Versioned Cap', ['versioned-op']);
|
|
6323
6357
|
cap.version = 42;
|
|
6324
6358
|
const json = cap.toJSON();
|
|
6325
6359
|
assert('version' in json, 'version!=0 must appear on wire');
|
|
@@ -6489,6 +6523,167 @@ function test1196_aliasedSerializationUsesAliasAndDropsHeader() {
|
|
|
6489
6523
|
// Test runner
|
|
6490
6524
|
// ============================================================================
|
|
6491
6525
|
|
|
6526
|
+
|
|
6527
|
+
// ============================================================================
|
|
6528
|
+
// Unified planner plan-space (planner.js) — TEST1450+
|
|
6529
|
+
// ============================================================================
|
|
6530
|
+
|
|
6531
|
+
const plannerNS = require('./planner.js');
|
|
6532
|
+
|
|
6533
|
+
/** Planner-specific throw assertion: any PlanStateError counts. */
|
|
6534
|
+
function assertThrowsPlanState(fn, message) {
|
|
6535
|
+
try {
|
|
6536
|
+
fn();
|
|
6537
|
+
} catch (error) {
|
|
6538
|
+
if (error instanceof plannerNS.PlanStateError) return;
|
|
6539
|
+
throw new Error(`Expected PlanStateError but got ${error.name}: ${error.message} (${message})`);
|
|
6540
|
+
}
|
|
6541
|
+
throw new Error(`Expected PlanStateError but function succeeded: ${message}`);
|
|
6542
|
+
}
|
|
6543
|
+
|
|
6544
|
+
// TEST1450: a defaults-only request is AUTO with every knob at its default,
|
|
6545
|
+
// and the proto JSON carries the documented wire shape.
|
|
6546
|
+
function test1450_planRequestDefaults() {
|
|
6547
|
+
const request = new plannerNS.PlanRequest({
|
|
6548
|
+
sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
|
|
6549
|
+
exactTargets: ['media:enc=utf-8;ext=txt'],
|
|
6550
|
+
});
|
|
6551
|
+
assertEqual(request.isConfigured, false, 'defaults are not configured');
|
|
6552
|
+
const wire = request.toProtoJSON();
|
|
6553
|
+
assertEqual(wire.mode, 0, 'AUTO mode');
|
|
6554
|
+
assertEqual(wire.ranking, 0, 'intent ranking');
|
|
6555
|
+
assertEqual(wire.convergence.presence, 0, 'auto presence');
|
|
6556
|
+
assertEqual(wire.convergence.mechanism, 0, 'any mechanism');
|
|
6557
|
+
assertEqual(wire.sources[0].media_urn, 'media:ext=pdf', 'snake_case source field');
|
|
6558
|
+
assertEqual(wire.sources[0].is_sequence, false, 'snake_case sequence field');
|
|
6559
|
+
assertEqual(wire.max_depth, 0, '0 = engine default depth');
|
|
6560
|
+
}
|
|
6561
|
+
|
|
6562
|
+
// TEST1451: at-depth REQUIRES a positive depth; a depth anywhere else is
|
|
6563
|
+
// invalid; ranking never flips the request to CONFIGURED but any
|
|
6564
|
+
// space-constraining knob does (mode is forced to CONFIGURED on the wire).
|
|
6565
|
+
function test1451_planRequestKnobValidation() {
|
|
6566
|
+
assertThrowsPlanState(() => new plannerNS.PlanRequest({
|
|
6567
|
+
sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
|
|
6568
|
+
exactTargets: ['media:enc=utf-8;ext=txt'],
|
|
6569
|
+
convergenceLocation: 'at-depth',
|
|
6570
|
+
}), 'at-depth without a depth must throw');
|
|
6571
|
+
|
|
6572
|
+
assertThrowsPlanState(() => new plannerNS.PlanRequest({
|
|
6573
|
+
sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
|
|
6574
|
+
exactTargets: ['media:enc=utf-8;ext=txt'],
|
|
6575
|
+
convergenceAtDepth: 2,
|
|
6576
|
+
}), 'a depth without at-depth must throw');
|
|
6577
|
+
|
|
6578
|
+
const ranked = new plannerNS.PlanRequest({
|
|
6579
|
+
sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
|
|
6580
|
+
exactTargets: ['media:enc=utf-8;ext=txt'],
|
|
6581
|
+
ranking: 'shortest',
|
|
6582
|
+
});
|
|
6583
|
+
assertEqual(ranked.isConfigured, false, 'ranking alone never configures');
|
|
6584
|
+
assertEqual(ranked.toProtoJSON().mode, 0, 'ranking alone stays AUTO');
|
|
6585
|
+
|
|
6586
|
+
const constrained = new plannerNS.PlanRequest({
|
|
6587
|
+
sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
|
|
6588
|
+
exactTargets: ['media:enc=utf-8;ext=txt'],
|
|
6589
|
+
convergenceMechanism: 'collect',
|
|
6590
|
+
});
|
|
6591
|
+
assertEqual(constrained.isConfigured, true, 'a mechanism constrains');
|
|
6592
|
+
assertEqual(constrained.toProtoJSON().mode, 1, 'constrained ⇒ CONFIGURED on the wire');
|
|
6593
|
+
|
|
6594
|
+
assertThrowsPlanState(() => new plannerNS.PlanRequest({
|
|
6595
|
+
sources: [],
|
|
6596
|
+
exactTargets: ['media:enc=utf-8;ext=txt'],
|
|
6597
|
+
}), 'no sources must throw');
|
|
6598
|
+
assertThrowsPlanState(() => new plannerNS.PlanRequest({
|
|
6599
|
+
sources: [{ mediaUrn: 'media:ext=pdf', isSequence: false }],
|
|
6600
|
+
exactTargets: [],
|
|
6601
|
+
}), 'no targets must throw');
|
|
6602
|
+
}
|
|
6603
|
+
|
|
6604
|
+
// TEST1452: every knob round-trips value → proto number → value, and an
|
|
6605
|
+
// unknown value/number fails hard in both directions.
|
|
6606
|
+
function test1452_knobProtoRoundTrip() {
|
|
6607
|
+
const knobs = [
|
|
6608
|
+
plannerNS.ConvergencePresence, plannerNS.ConvergenceLocation,
|
|
6609
|
+
plannerNS.ConvergenceMechanism, plannerNS.ConvergenceArity,
|
|
6610
|
+
plannerNS.DivergencePresence, plannerNS.DivergenceLocation,
|
|
6611
|
+
plannerNS.RankPolicy, plannerNS.PlanMode,
|
|
6612
|
+
];
|
|
6613
|
+
for (const knob of knobs) {
|
|
6614
|
+
for (const value of knob.values) {
|
|
6615
|
+
assertEqual(knob.fromProto(knob.toProto(value)), value, `${knob.name} round-trips ${value}`);
|
|
6616
|
+
}
|
|
6617
|
+
assertThrowsPlanState(() => knob.toProto('no-such-value'), `${knob.name} rejects unknown value`);
|
|
6618
|
+
assertThrowsPlanState(() => knob.fromProto(999), `${knob.name} rejects unknown number`);
|
|
6619
|
+
}
|
|
6620
|
+
}
|
|
6621
|
+
|
|
6622
|
+
// TEST1453: candidate/target parsers accept realistic proto-JSON and sort
|
|
6623
|
+
// candidates by rank; malformed payloads (missing field, wrong type) fail
|
|
6624
|
+
// hard with the offending path in the message.
|
|
6625
|
+
function test1453_planResponseParsers() {
|
|
6626
|
+
const candidates = plannerNS.parsePlanCandidates({
|
|
6627
|
+
candidates: [
|
|
6628
|
+
{
|
|
6629
|
+
notation: '[n0 -> edge_1 -> n1]',
|
|
6630
|
+
label: 'Convert each',
|
|
6631
|
+
rank: 1,
|
|
6632
|
+
profile: { source_media: ['media:ext=pdf'], target_media: ['media:enc=utf-8;ext=txt'], apexes: [], converged: false, diverged: false },
|
|
6633
|
+
cost: { cap_steps: 2, total_steps: 2, max_leg_depth: 0, intent_score: 0.5 },
|
|
6634
|
+
},
|
|
6635
|
+
{
|
|
6636
|
+
notation: '[(n0, n1) -> edge_0 -> n2]',
|
|
6637
|
+
label: 'Combine via text',
|
|
6638
|
+
rank: 0,
|
|
6639
|
+
profile: {
|
|
6640
|
+
source_media: ['media:ext=pdf', 'media:ext=md'],
|
|
6641
|
+
target_media: ['media:enc=utf-8;ext=txt'],
|
|
6642
|
+
apexes: [{ media_urn: 'media:enc=utf-8;page', mechanism: 2, depth: 1 }],
|
|
6643
|
+
converged: true,
|
|
6644
|
+
diverged: false,
|
|
6645
|
+
},
|
|
6646
|
+
cost: { cap_steps: 3, total_steps: 4, max_leg_depth: 1, intent_score: 0.8 },
|
|
6647
|
+
},
|
|
6648
|
+
],
|
|
6649
|
+
});
|
|
6650
|
+
assertEqual(candidates.length, 2, 'two candidates');
|
|
6651
|
+
assertEqual(candidates[0].rank, 0, 'sorted by rank');
|
|
6652
|
+
assertEqual(candidates[0].profile.apexes[0].mechanism, 'collect', 'proto mechanism decoded to knob value');
|
|
6653
|
+
assertEqual(candidates[0].profile.converged, true, 'converged carried');
|
|
6654
|
+
|
|
6655
|
+
assertThrowsPlanState(
|
|
6656
|
+
() => plannerNS.parsePlanCandidates({ candidates: [{ label: 'missing notation', rank: 0 }] }),
|
|
6657
|
+
'a candidate without notation must fail hard'
|
|
6658
|
+
);
|
|
6659
|
+
|
|
6660
|
+
const targets = plannerNS.parseConvergentTargets({
|
|
6661
|
+
targets: [
|
|
6662
|
+
{
|
|
6663
|
+
media_def: 'media:enc=utf-8;ext=txt',
|
|
6664
|
+
display_name: 'Plain Text',
|
|
6665
|
+
min_total_steps: 3,
|
|
6666
|
+
apex: { media_urn: 'media:enc=utf-8;page', mechanism: 2, depth: 1 },
|
|
6667
|
+
convergent: true,
|
|
6668
|
+
file_extension: 'txt',
|
|
6669
|
+
},
|
|
6670
|
+
{
|
|
6671
|
+
media_def: 'media:ext=png;image',
|
|
6672
|
+
display_name: 'PNG Image',
|
|
6673
|
+
min_total_steps: 2,
|
|
6674
|
+
convergent: false,
|
|
6675
|
+
file_extension: 'png',
|
|
6676
|
+
},
|
|
6677
|
+
],
|
|
6678
|
+
});
|
|
6679
|
+
assertEqual(targets[0].apex.mediaUrn, 'media:enc=utf-8;page', 'apex carried');
|
|
6680
|
+
assertEqual(targets[1].apex, null, 'independent target has no apex');
|
|
6681
|
+
assertThrowsPlanState(
|
|
6682
|
+
() => plannerNS.parseConvergentTargets({ targets: [{ display_name: 'no media_def' }] }),
|
|
6683
|
+
'a target without media_def must fail hard'
|
|
6684
|
+
);
|
|
6685
|
+
}
|
|
6686
|
+
|
|
6492
6687
|
async function runTests() {
|
|
6493
6688
|
console.log('Running capdag-js tests...\n');
|
|
6494
6689
|
|
|
@@ -6749,7 +6944,7 @@ async function runTests() {
|
|
|
6749
6944
|
runTest('MACHINE:fan_out', test6290_Machine_fanOut);
|
|
6750
6945
|
runTest('MACHINE:fan_in_secondary_assigned_by_prior_wiring', test6292_Machine_fanInSecondaryAssignedByPriorWiring);
|
|
6751
6946
|
runTest('MACHINE:fan_in_secondary_unassigned_gets_wildcard', test6294_Machine_fanInSecondaryUnassignedGetsWildcard);
|
|
6752
|
-
runTest('MACHINE:
|
|
6947
|
+
runTest('MACHINE:loop_keyword_is_not_grammar', test6306_Machine_loopKeywordIsNotGrammar);
|
|
6753
6948
|
runTest('MACHINE:undefined_alias_fails', test6308_Machine_undefinedAliasFails);
|
|
6754
6949
|
runTest('MACHINE:node_alias_collision', test6310_Machine_nodeAliasCollision);
|
|
6755
6950
|
runTest('MACHINE:conflicting_media_types_fail', test6312_Machine_conflictingMediaTypesFail);
|
|
@@ -6762,7 +6957,7 @@ async function runTests() {
|
|
|
6762
6957
|
console.log('\n--- machine/parser.rs (line-based) ---');
|
|
6763
6958
|
runTest('MACHINE:line_based_simple_chain', test6327_Machine_lineBasedSimpleChain);
|
|
6764
6959
|
runTest('MACHINE:line_based_two_step_chain', test6331_Machine_lineBasedTwoStepChain);
|
|
6765
|
-
runTest('MACHINE:
|
|
6960
|
+
runTest('MACHINE:line_based_loop_keyword_is_not_grammar', test6334_Machine_lineBasedLoopKeywordIsNotGrammar);
|
|
6766
6961
|
runTest('MACHINE:line_based_fan_in', test6337_Machine_lineBasedFanIn);
|
|
6767
6962
|
runTest('MACHINE:mixed_bracketed_and_line_based', test6341_Machine_mixedBracketedAndLineBased);
|
|
6768
6963
|
runTest('MACHINE:line_based_equivalent_to_bracketed', test6345_Machine_lineBasedEquivalentToBracketed);
|
|
@@ -6797,7 +6992,7 @@ async function runTests() {
|
|
|
6797
6992
|
runTest('MACHINE:roundtrip_single_edge', test6410_Machine_roundtripSingleEdge);
|
|
6798
6993
|
runTest('MACHINE:roundtrip_two_edge_chain', test6413_Machine_roundtripTwoEdgeChain);
|
|
6799
6994
|
runTest('MACHINE:roundtrip_fan_out', test6415_Machine_roundtripFanOut);
|
|
6800
|
-
runTest('MACHINE:
|
|
6995
|
+
runTest('MACHINE:loop_edge_serializes_without_loop_text', test6417_Machine_loopEdgeSerializesWithoutLoopText);
|
|
6801
6996
|
runTest('MACHINE:serialization_is_deterministic', test6419_Machine_serializationIsDeterministic);
|
|
6802
6997
|
runTest('MACHINE:reordered_edges_produce_same_notation', test6421_Machine_reorderedEdgesProduceSameNotation);
|
|
6803
6998
|
runTest('MACHINE:multiline_serialize_format', test6429_Machine_multilineSerializeFormat);
|
|
@@ -6957,6 +7152,13 @@ async function runTests() {
|
|
|
6957
7152
|
runTest('TEST1896: cached_cap_aliases_filters_to_cap', test1896_cachedCapAliasesFiltersToCapTargets);
|
|
6958
7153
|
runTest('TEST1196: aliased_serialization_uses_alias', test1196_aliasedSerializationUsesAliasAndDropsHeader);
|
|
6959
7154
|
|
|
7155
|
+
// planner.js: unified plan-space vocabulary
|
|
7156
|
+
console.log('--- planner.js ---');
|
|
7157
|
+
runTest('TEST1450: plan_request_defaults', test1450_planRequestDefaults);
|
|
7158
|
+
runTest('TEST1451: plan_request_knob_validation', test1451_planRequestKnobValidation);
|
|
7159
|
+
runTest('TEST1452: knob_proto_round_trip', test1452_knobProtoRoundTrip);
|
|
7160
|
+
runTest('TEST1453: plan_response_parsers', test1453_planResponseParsers);
|
|
7161
|
+
|
|
6960
7162
|
// Summary
|
|
6961
7163
|
console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
|
|
6962
7164
|
if (failCount > 0) {
|