capdag 0.109.248 → 0.124.274
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-graph-renderer.js +904 -164
- package/capdag.js +123 -132
- package/capdag.test.js +1048 -238
- package/package.json +1 -1
package/capdag.test.js
CHANGED
|
@@ -8,7 +8,7 @@ const {
|
|
|
8
8
|
Cap, MediaSpec, MediaSpecError, MediaSpecErrorCodes,
|
|
9
9
|
resolveMediaUrn, buildExtensionIndex, mediaUrnsForExtension, getExtensionMappings,
|
|
10
10
|
CapMatrixError, CapMatrix, BestCapSetMatch, CompositeCapSet, CapBlock,
|
|
11
|
-
|
|
11
|
+
CartridgeInfo, CartridgeCapSummary, CartridgeSuggestion, CartridgeRepoClient, CartridgeRepoServer,
|
|
12
12
|
CapGraphEdge, CapGraphStats, CapGraph,
|
|
13
13
|
StdinSource, StdinSourceKind,
|
|
14
14
|
validateNoMediaSpecRedefinitionSync,
|
|
@@ -1176,7 +1176,7 @@ function test110_multipleExtensions() {
|
|
|
1176
1176
|
// TEST117: CapBlock finds more specific cap across registries
|
|
1177
1177
|
function test117_capBlockMoreSpecificWins() {
|
|
1178
1178
|
const providerRegistry = new CapMatrix();
|
|
1179
|
-
const
|
|
1179
|
+
const cartridgeRegistry = new CapMatrix();
|
|
1180
1180
|
|
|
1181
1181
|
const providerHost = new MockCapSet('provider');
|
|
1182
1182
|
const providerCap = makeCap(
|
|
@@ -1185,22 +1185,22 @@ function test117_capBlockMoreSpecificWins() {
|
|
|
1185
1185
|
);
|
|
1186
1186
|
providerRegistry.registerCapSet('provider', providerHost, [providerCap]);
|
|
1187
1187
|
|
|
1188
|
-
const
|
|
1189
|
-
const
|
|
1188
|
+
const cartridgeHost = new MockCapSet('cartridge');
|
|
1189
|
+
const cartridgeCap = makeCap(
|
|
1190
1190
|
'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"',
|
|
1191
|
-
'
|
|
1191
|
+
'Cartridge PDF Thumbnail Generator (specific)'
|
|
1192
1192
|
);
|
|
1193
|
-
|
|
1193
|
+
cartridgeRegistry.registerCapSet('cartridge', cartridgeHost, [cartridgeCap]);
|
|
1194
1194
|
|
|
1195
1195
|
const composite = new CapBlock();
|
|
1196
1196
|
composite.addRegistry('providers', providerRegistry);
|
|
1197
|
-
composite.addRegistry('
|
|
1197
|
+
composite.addRegistry('cartridges', cartridgeRegistry);
|
|
1198
1198
|
|
|
1199
1199
|
const request = 'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"';
|
|
1200
1200
|
const best = composite.findBestCapSet(request);
|
|
1201
1201
|
|
|
1202
|
-
assertEqual(best.registryName, '
|
|
1203
|
-
assertEqual(best.cap.title, '
|
|
1202
|
+
assertEqual(best.registryName, 'cartridges', 'More specific cartridge should win');
|
|
1203
|
+
assertEqual(best.cap.title, 'Cartridge PDF Thumbnail Generator (specific)', 'Should get cartridge cap');
|
|
1204
1204
|
}
|
|
1205
1205
|
|
|
1206
1206
|
// TEST118: CapBlock tie-breaking prefers first registry in order
|
|
@@ -1269,7 +1269,7 @@ function test120_capBlockNoMatch() {
|
|
|
1269
1269
|
// TEST121: CapBlock fallback scenario where generic cap handles unknown file types
|
|
1270
1270
|
function test121_capBlockFallbackScenario() {
|
|
1271
1271
|
const providerRegistry = new CapMatrix();
|
|
1272
|
-
const
|
|
1272
|
+
const cartridgeRegistry = new CapMatrix();
|
|
1273
1273
|
|
|
1274
1274
|
const providerHost = new MockCapSet('provider_fallback');
|
|
1275
1275
|
const providerCap = makeCap(
|
|
@@ -1278,20 +1278,20 @@ function test121_capBlockFallbackScenario() {
|
|
|
1278
1278
|
);
|
|
1279
1279
|
providerRegistry.registerCapSet('provider_fallback', providerHost, [providerCap]);
|
|
1280
1280
|
|
|
1281
|
-
const
|
|
1282
|
-
const
|
|
1281
|
+
const cartridgeHost = new MockCapSet('pdf_cartridge');
|
|
1282
|
+
const cartridgeCap = makeCap(
|
|
1283
1283
|
'cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"',
|
|
1284
|
-
'PDF Thumbnail
|
|
1284
|
+
'PDF Thumbnail Cartridge'
|
|
1285
1285
|
);
|
|
1286
|
-
|
|
1286
|
+
cartridgeRegistry.registerCapSet('pdf_cartridge', cartridgeHost, [cartridgeCap]);
|
|
1287
1287
|
|
|
1288
1288
|
const composite = new CapBlock();
|
|
1289
1289
|
composite.addRegistry('providers', providerRegistry);
|
|
1290
|
-
composite.addRegistry('
|
|
1290
|
+
composite.addRegistry('cartridges', cartridgeRegistry);
|
|
1291
1291
|
|
|
1292
|
-
// PDF request ->
|
|
1292
|
+
// PDF request -> cartridge wins
|
|
1293
1293
|
const best = composite.findBestCapSet('cap:ext=pdf;in="media:binary";op=generate_thumbnail;out="media:binary"');
|
|
1294
|
-
assertEqual(best.registryName, '
|
|
1294
|
+
assertEqual(best.registryName, 'cartridges', 'Cartridge should win for PDF');
|
|
1295
1295
|
|
|
1296
1296
|
// WAV request -> provider wins (fallback)
|
|
1297
1297
|
const bestWav = composite.findBestCapSet('cap:ext=wav;in="media:binary";op=generate_thumbnail;out="media:binary"');
|
|
@@ -1494,19 +1494,19 @@ function test130_capGraphStats() {
|
|
|
1494
1494
|
// TEST131: CapGraph with CapBlock builds graph from multiple registries
|
|
1495
1495
|
function test131_capGraphWithCapBlock() {
|
|
1496
1496
|
const providerRegistry = new CapMatrix();
|
|
1497
|
-
const
|
|
1497
|
+
const cartridgeRegistry = new CapMatrix();
|
|
1498
1498
|
const providerHost = { executeCap: async () => ({ textOutput: 'provider' }) };
|
|
1499
|
-
const
|
|
1499
|
+
const cartridgeHost = { executeCap: async () => ({ textOutput: 'cartridge' }) };
|
|
1500
1500
|
|
|
1501
1501
|
const providerCap = makeGraphCap('media:binary', 'media:string', 'Provider Binary to String');
|
|
1502
1502
|
providerRegistry.registerCapSet('provider', providerHost, [providerCap]);
|
|
1503
1503
|
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1504
|
+
const cartridgeCap = makeGraphCap('media:string', 'media:object', 'Cartridge String to Object');
|
|
1505
|
+
cartridgeRegistry.registerCapSet('cartridge', cartridgeHost, [cartridgeCap]);
|
|
1506
1506
|
|
|
1507
1507
|
const cube = new CapBlock();
|
|
1508
1508
|
cube.addRegistry('providers', providerRegistry);
|
|
1509
|
-
cube.addRegistry('
|
|
1509
|
+
cube.addRegistry('cartridges', cartridgeRegistry);
|
|
1510
1510
|
const graph = cube.graph();
|
|
1511
1511
|
|
|
1512
1512
|
assert(graph.canConvert('media:binary', 'media:object'), 'Should convert across registries');
|
|
@@ -1514,7 +1514,7 @@ function test131_capGraphWithCapBlock() {
|
|
|
1514
1514
|
assert(path !== null, 'Should find path');
|
|
1515
1515
|
assertEqual(path.length, 2, 'Path through 2 registries');
|
|
1516
1516
|
assertEqual(path[0].registryName, 'providers', 'First edge from providers');
|
|
1517
|
-
assertEqual(path[1].registryName, '
|
|
1517
|
+
assertEqual(path[1].registryName, 'cartridges', 'Second edge from cartridges');
|
|
1518
1518
|
}
|
|
1519
1519
|
|
|
1520
1520
|
// TEST132: N/A (already covered by TEST129)
|
|
@@ -2008,14 +2008,14 @@ function testJS_mediaSpecConstruction() {
|
|
|
2008
2008
|
}
|
|
2009
2009
|
|
|
2010
2010
|
// =============================================================================
|
|
2011
|
-
//
|
|
2011
|
+
// Cartridge Repository Tests (TEST320-TEST335)
|
|
2012
2012
|
// =============================================================================
|
|
2013
2013
|
|
|
2014
2014
|
// Sample registry for testing
|
|
2015
2015
|
const sampleRegistry = {
|
|
2016
2016
|
schemaVersion: '3.0',
|
|
2017
2017
|
lastUpdated: '2026-02-07T16:48:28Z',
|
|
2018
|
-
|
|
2018
|
+
cartridges: {
|
|
2019
2019
|
pdfcartridge: {
|
|
2020
2020
|
name: 'pdfcartridge',
|
|
2021
2021
|
description: 'PDF document processor',
|
|
@@ -2048,11 +2048,6 @@ const sampleRegistry = {
|
|
|
2048
2048
|
name: 'pdfcartridge-0.81.5325.pkg',
|
|
2049
2049
|
sha256: '9b68724eb9220ecf01e8ed4f5f80c594fbac2239bc5bf675005ec882ecc5eba0',
|
|
2050
2050
|
size: 5187485
|
|
2051
|
-
},
|
|
2052
|
-
binary: {
|
|
2053
|
-
name: 'pdfcartridge-0.81.5325-darwin-arm64',
|
|
2054
|
-
sha256: '908187ec35632758f1a00452ff4755ba01020ea288619098b6998d5d33851d19',
|
|
2055
|
-
size: 12980288
|
|
2056
2051
|
}
|
|
2057
2052
|
}
|
|
2058
2053
|
}
|
|
@@ -2084,11 +2079,6 @@ const sampleRegistry = {
|
|
|
2084
2079
|
name: 'txtcartridge-0.54.6408.pkg',
|
|
2085
2080
|
sha256: 'abc123',
|
|
2086
2081
|
size: 821000
|
|
2087
|
-
},
|
|
2088
|
-
binary: {
|
|
2089
|
-
name: 'txtcartridge-0.54.6408-darwin-arm64',
|
|
2090
|
-
sha256: 'def456',
|
|
2091
|
-
size: 1700000
|
|
2092
2082
|
}
|
|
2093
2083
|
}
|
|
2094
2084
|
}
|
|
@@ -2096,215 +2086,215 @@ const sampleRegistry = {
|
|
|
2096
2086
|
}
|
|
2097
2087
|
};
|
|
2098
2088
|
|
|
2099
|
-
// TEST320:
|
|
2100
|
-
function
|
|
2089
|
+
// TEST320: Cartridge info construction
|
|
2090
|
+
function test320_cartridgeInfoConstruction() {
|
|
2101
2091
|
const data = {
|
|
2102
|
-
id: '
|
|
2103
|
-
name: 'Test
|
|
2092
|
+
id: 'testcartridge',
|
|
2093
|
+
name: 'Test Cartridge',
|
|
2104
2094
|
version: '1.0.0',
|
|
2105
2095
|
description: 'A test',
|
|
2106
2096
|
teamId: 'TEAM123',
|
|
2107
2097
|
signedAt: '2026-01-01',
|
|
2108
|
-
|
|
2109
|
-
|
|
2098
|
+
packageName: 'test-1.0.0.pkg',
|
|
2099
|
+
packageSha256: 'abc123',
|
|
2110
2100
|
caps: [{urn: 'cap:in="media:void";op=test;out="media:void"', title: 'Test', description: ''}]
|
|
2111
2101
|
};
|
|
2112
|
-
const
|
|
2113
|
-
assert(
|
|
2114
|
-
assert(
|
|
2115
|
-
assert(
|
|
2116
|
-
assert(
|
|
2102
|
+
const cartridge = new CartridgeInfo(data);
|
|
2103
|
+
assert(cartridge.id === 'testcartridge', 'ID should match');
|
|
2104
|
+
assert(cartridge.teamId === 'TEAM123', 'Team ID should match');
|
|
2105
|
+
assert(cartridge.caps.length === 1, 'Should have 1 cap');
|
|
2106
|
+
assert(cartridge.caps[0].urn === 'cap:in="media:void";op=test;out="media:void"', 'Cap URN should match');
|
|
2117
2107
|
}
|
|
2118
2108
|
|
|
2119
|
-
// TEST321:
|
|
2120
|
-
function
|
|
2121
|
-
const signed = new
|
|
2122
|
-
assert(signed.isSigned() === true, '
|
|
2109
|
+
// TEST321: Cartridge info is signed check
|
|
2110
|
+
function test321_cartridgeInfoIsSigned() {
|
|
2111
|
+
const signed = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '2026-01-01', caps: []});
|
|
2112
|
+
assert(signed.isSigned() === true, 'Cartridge with teamId and signedAt should be signed');
|
|
2123
2113
|
|
|
2124
|
-
const unsigned1 = new
|
|
2125
|
-
assert(unsigned1.isSigned() === false, '
|
|
2114
|
+
const unsigned1 = new CartridgeInfo({id: 'test', teamId: '', signedAt: '2026-01-01', caps: []});
|
|
2115
|
+
assert(unsigned1.isSigned() === false, 'Cartridge without teamId should not be signed');
|
|
2126
2116
|
|
|
2127
|
-
const unsigned2 = new
|
|
2128
|
-
assert(unsigned2.isSigned() === false, '
|
|
2117
|
+
const unsigned2 = new CartridgeInfo({id: 'test', teamId: 'TEAM', signedAt: '', caps: []});
|
|
2118
|
+
assert(unsigned2.isSigned() === false, 'Cartridge without signedAt should not be signed');
|
|
2129
2119
|
}
|
|
2130
2120
|
|
|
2131
|
-
// TEST322:
|
|
2132
|
-
function
|
|
2133
|
-
const
|
|
2134
|
-
assert(
|
|
2121
|
+
// TEST322: Cartridge info has package check
|
|
2122
|
+
function test322_cartridgeInfoHasPackage() {
|
|
2123
|
+
const withPkg = new CartridgeInfo({id: 'test', packageName: 'test.pkg', packageSha256: 'abc', caps: []});
|
|
2124
|
+
assert(withPkg.hasPackage() === true, 'Cartridge with package info should return true');
|
|
2135
2125
|
|
|
2136
|
-
const
|
|
2137
|
-
assert(
|
|
2126
|
+
const noPkg1 = new CartridgeInfo({id: 'test', packageName: '', packageSha256: 'abc', caps: []});
|
|
2127
|
+
assert(noPkg1.hasPackage() === false, 'Cartridge without packageName should return false');
|
|
2138
2128
|
|
|
2139
|
-
const
|
|
2140
|
-
assert(
|
|
2129
|
+
const noPkg2 = new CartridgeInfo({id: 'test', packageName: 'test.pkg', packageSha256: '', caps: []});
|
|
2130
|
+
assert(noPkg2.hasPackage() === false, 'Cartridge without packageSha256 should return false');
|
|
2141
2131
|
}
|
|
2142
2132
|
|
|
2143
|
-
// TEST323:
|
|
2144
|
-
function
|
|
2133
|
+
// TEST323: CartridgeRepoServer validate registry
|
|
2134
|
+
function test323_cartridgeRepoServerValidateRegistry() {
|
|
2145
2135
|
// Valid registry
|
|
2146
|
-
const server = new
|
|
2136
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2147
2137
|
assert(server.registry.schemaVersion === '3.0', 'Should accept valid registry');
|
|
2148
2138
|
|
|
2149
2139
|
// Invalid schema version
|
|
2150
2140
|
let threw = false;
|
|
2151
2141
|
try {
|
|
2152
|
-
new
|
|
2142
|
+
new CartridgeRepoServer({schemaVersion: '2.0', cartridges: {}});
|
|
2153
2143
|
} catch (e) {
|
|
2154
2144
|
threw = true;
|
|
2155
2145
|
assert(e.message.includes('schema version'), 'Should reject wrong schema version');
|
|
2156
2146
|
}
|
|
2157
2147
|
assert(threw, 'Should throw for invalid schema');
|
|
2158
2148
|
|
|
2159
|
-
// Missing
|
|
2149
|
+
// Missing cartridges
|
|
2160
2150
|
threw = false;
|
|
2161
2151
|
try {
|
|
2162
|
-
new
|
|
2152
|
+
new CartridgeRepoServer({schemaVersion: '3.0'});
|
|
2163
2153
|
} catch (e) {
|
|
2164
2154
|
threw = true;
|
|
2165
|
-
assert(e.message.includes('
|
|
2155
|
+
assert(e.message.includes('cartridges'), 'Should reject missing cartridges');
|
|
2166
2156
|
}
|
|
2167
|
-
assert(threw, 'Should throw for missing
|
|
2157
|
+
assert(threw, 'Should throw for missing cartridges');
|
|
2168
2158
|
}
|
|
2169
2159
|
|
|
2170
|
-
// TEST324:
|
|
2171
|
-
function
|
|
2172
|
-
const server = new
|
|
2173
|
-
const
|
|
2160
|
+
// TEST324: CartridgeRepoServer transform to array
|
|
2161
|
+
function test324_cartridgeRepoServerTransformToArray() {
|
|
2162
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2163
|
+
const cartridges = server.transformToCartridgeArray();
|
|
2174
2164
|
|
|
2175
|
-
assert(Array.isArray(
|
|
2176
|
-
assert(
|
|
2177
|
-
|
|
2178
|
-
const pdf =
|
|
2165
|
+
assert(Array.isArray(cartridges), 'Should return array');
|
|
2166
|
+
assert(cartridges.length === 2, 'Should have 2 cartridges');
|
|
2167
|
+
|
|
2168
|
+
const pdf = cartridges.find(p => p.id === 'pdfcartridge');
|
|
2179
2169
|
assert(pdf !== undefined, 'Should include pdfcartridge');
|
|
2180
2170
|
assert(pdf.version === '0.81.5325', 'Should have latest version');
|
|
2181
2171
|
assert(pdf.teamId === 'P336JK947M', 'Should have teamId');
|
|
2182
2172
|
assert(pdf.signedAt === '2026-02-07T16:40:28Z', 'Should have signedAt from releaseDate');
|
|
2183
|
-
assert(pdf.
|
|
2184
|
-
assert(pdf.
|
|
2173
|
+
assert(pdf.packageName === 'pdfcartridge-0.81.5325.pkg', 'Should have package name');
|
|
2174
|
+
assert(pdf.packageSha256 === '9b68724eb9220ecf01e8ed4f5f80c594fbac2239bc5bf675005ec882ecc5eba0', 'Should have package SHA256');
|
|
2185
2175
|
assert(Array.isArray(pdf.caps), 'Should have caps array');
|
|
2186
2176
|
assert(pdf.caps.length === 2, 'Should have 2 caps');
|
|
2187
2177
|
}
|
|
2188
2178
|
|
|
2189
|
-
// TEST325:
|
|
2190
|
-
function
|
|
2191
|
-
const server = new
|
|
2192
|
-
const response = server.
|
|
2179
|
+
// TEST325: CartridgeRepoServer get cartridges
|
|
2180
|
+
function test325_cartridgeRepoServerGetCartridges() {
|
|
2181
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2182
|
+
const response = server.getCartridges();
|
|
2193
2183
|
|
|
2194
|
-
assert(response.
|
|
2195
|
-
assert(Array.isArray(response.
|
|
2196
|
-
assert(response.
|
|
2184
|
+
assert(response.cartridges !== undefined, 'Should have cartridges field');
|
|
2185
|
+
assert(Array.isArray(response.cartridges), 'Cartridges should be array');
|
|
2186
|
+
assert(response.cartridges.length === 2, 'Should have 2 cartridges');
|
|
2197
2187
|
}
|
|
2198
2188
|
|
|
2199
|
-
// TEST326:
|
|
2200
|
-
function
|
|
2201
|
-
const server = new
|
|
2189
|
+
// TEST326: CartridgeRepoServer get cartridge by ID
|
|
2190
|
+
function test326_cartridgeRepoServerGetCartridgeById() {
|
|
2191
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2202
2192
|
|
|
2203
|
-
const pdf = server.
|
|
2193
|
+
const pdf = server.getCartridgeById('pdfcartridge');
|
|
2204
2194
|
assert(pdf !== undefined, 'Should find pdfcartridge');
|
|
2205
2195
|
assert(pdf.id === 'pdfcartridge', 'Should have correct ID');
|
|
2206
2196
|
|
|
2207
|
-
const notFound = server.
|
|
2208
|
-
assert(notFound === undefined, 'Should return undefined for missing
|
|
2197
|
+
const notFound = server.getCartridgeById('nonexistent');
|
|
2198
|
+
assert(notFound === undefined, 'Should return undefined for missing cartridge');
|
|
2209
2199
|
}
|
|
2210
2200
|
|
|
2211
|
-
// TEST327:
|
|
2212
|
-
function
|
|
2213
|
-
const server = new
|
|
2201
|
+
// TEST327: CartridgeRepoServer search cartridges
|
|
2202
|
+
function test327_cartridgeRepoServerSearchCartridges() {
|
|
2203
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2214
2204
|
|
|
2215
|
-
const pdfResults = server.
|
|
2216
|
-
assert(pdfResults.length === 1, 'Should find 1 PDF
|
|
2205
|
+
const pdfResults = server.searchCartridges('pdf');
|
|
2206
|
+
assert(pdfResults.length === 1, 'Should find 1 PDF cartridge');
|
|
2217
2207
|
assert(pdfResults[0].id === 'pdfcartridge', 'Should find pdfcartridge');
|
|
2218
2208
|
|
|
2219
|
-
const metadataResults = server.
|
|
2220
|
-
assert(metadataResults.length === 1, 'Should find
|
|
2209
|
+
const metadataResults = server.searchCartridges('metadata');
|
|
2210
|
+
assert(metadataResults.length === 1, 'Should find cartridge by cap title');
|
|
2221
2211
|
|
|
2222
|
-
const noResults = server.
|
|
2212
|
+
const noResults = server.searchCartridges('nonexistent');
|
|
2223
2213
|
assert(noResults.length === 0, 'Should return empty for no matches');
|
|
2224
2214
|
}
|
|
2225
2215
|
|
|
2226
|
-
// TEST328:
|
|
2227
|
-
function
|
|
2228
|
-
const server = new
|
|
2216
|
+
// TEST328: CartridgeRepoServer get by category
|
|
2217
|
+
function test328_cartridgeRepoServerGetByCategory() {
|
|
2218
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2229
2219
|
|
|
2230
|
-
const
|
|
2231
|
-
assert(
|
|
2232
|
-
assert(
|
|
2220
|
+
const docCartridges = server.getCartridgesByCategory('document');
|
|
2221
|
+
assert(docCartridges.length === 1, 'Should find 1 document cartridge');
|
|
2222
|
+
assert(docCartridges[0].id === 'pdfcartridge', 'Should be pdfcartridge');
|
|
2233
2223
|
|
|
2234
|
-
const
|
|
2235
|
-
assert(
|
|
2236
|
-
assert(
|
|
2224
|
+
const textCartridges = server.getCartridgesByCategory('text');
|
|
2225
|
+
assert(textCartridges.length === 1, 'Should find 1 text cartridge');
|
|
2226
|
+
assert(textCartridges[0].id === 'txtcartridge', 'Should be txtcartridge');
|
|
2237
2227
|
}
|
|
2238
2228
|
|
|
2239
|
-
// TEST329:
|
|
2240
|
-
function
|
|
2241
|
-
const server = new
|
|
2229
|
+
// TEST329: CartridgeRepoServer get by cap
|
|
2230
|
+
function test329_cartridgeRepoServerGetByCap() {
|
|
2231
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2242
2232
|
|
|
2243
2233
|
const disbindCap = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
|
|
2244
|
-
const
|
|
2234
|
+
const cartridges = server.getCartridgesByCap(disbindCap);
|
|
2245
2235
|
|
|
2246
|
-
assert(
|
|
2247
|
-
assert(
|
|
2236
|
+
assert(cartridges.length === 1, 'Should find 1 cartridge with this cap');
|
|
2237
|
+
assert(cartridges[0].id === 'pdfcartridge', 'Should be pdfcartridge');
|
|
2248
2238
|
|
|
2249
2239
|
const metadataCap = 'cap:in="media:pdf";op=extract_metadata;out="media:file-metadata;textable;record"';
|
|
2250
|
-
const
|
|
2251
|
-
assert(
|
|
2240
|
+
const metadataCartridges = server.getCartridgesByCap(metadataCap);
|
|
2241
|
+
assert(metadataCartridges.length === 1, 'Should find metadata cap');
|
|
2252
2242
|
}
|
|
2253
2243
|
|
|
2254
|
-
// TEST330:
|
|
2255
|
-
function
|
|
2256
|
-
const client = new
|
|
2257
|
-
const server = new
|
|
2258
|
-
const
|
|
2244
|
+
// TEST330: CartridgeRepoClient update cache
|
|
2245
|
+
function test330_cartridgeRepoClientUpdateCache() {
|
|
2246
|
+
const client = new CartridgeRepoClient(3600);
|
|
2247
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2248
|
+
const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
|
|
2259
2249
|
|
|
2260
|
-
client.updateCache('https://example.com/api/
|
|
2250
|
+
client.updateCache('https://example.com/api/cartridges', cartridges);
|
|
2261
2251
|
|
|
2262
|
-
const cache = client.caches.get('https://example.com/api/
|
|
2252
|
+
const cache = client.caches.get('https://example.com/api/cartridges');
|
|
2263
2253
|
assert(cache !== undefined, 'Cache should exist');
|
|
2264
|
-
assert(cache.
|
|
2265
|
-
assert(cache.
|
|
2254
|
+
assert(cache.cartridges.size === 2, 'Should have 2 cartridges in cache');
|
|
2255
|
+
assert(cache.capToCartridges.size > 0, 'Should have cap mappings');
|
|
2266
2256
|
}
|
|
2267
2257
|
|
|
2268
|
-
// TEST331:
|
|
2269
|
-
function
|
|
2270
|
-
const client = new
|
|
2271
|
-
const server = new
|
|
2272
|
-
const
|
|
2258
|
+
// TEST331: CartridgeRepoClient get suggestions
|
|
2259
|
+
function test331_cartridgeRepoClientGetSuggestions() {
|
|
2260
|
+
const client = new CartridgeRepoClient(3600);
|
|
2261
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2262
|
+
const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
|
|
2273
2263
|
|
|
2274
|
-
client.updateCache('https://example.com/api/
|
|
2264
|
+
client.updateCache('https://example.com/api/cartridges', cartridges);
|
|
2275
2265
|
|
|
2276
2266
|
const disbindCap = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
|
|
2277
2267
|
const suggestions = client.getSuggestionsForCap(disbindCap);
|
|
2278
2268
|
|
|
2279
2269
|
assert(suggestions.length === 1, 'Should find 1 suggestion');
|
|
2280
|
-
assert(suggestions[0].
|
|
2270
|
+
assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest pdfcartridge');
|
|
2281
2271
|
assert(suggestions[0].capUrn === disbindCap, 'Should have correct cap URN');
|
|
2282
2272
|
assert(suggestions[0].capTitle === 'Disbind PDF', 'Should have cap title');
|
|
2283
2273
|
}
|
|
2284
2274
|
|
|
2285
|
-
// TEST332:
|
|
2286
|
-
function
|
|
2287
|
-
const client = new
|
|
2288
|
-
const server = new
|
|
2289
|
-
const
|
|
2275
|
+
// TEST332: CartridgeRepoClient get cartridge
|
|
2276
|
+
function test332_cartridgeRepoClientGetCartridge() {
|
|
2277
|
+
const client = new CartridgeRepoClient(3600);
|
|
2278
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2279
|
+
const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
|
|
2290
2280
|
|
|
2291
|
-
client.updateCache('https://example.com/api/
|
|
2281
|
+
client.updateCache('https://example.com/api/cartridges', cartridges);
|
|
2292
2282
|
|
|
2293
|
-
const
|
|
2294
|
-
assert(
|
|
2295
|
-
assert(
|
|
2283
|
+
const cartridge = client.getCartridge('pdfcartridge');
|
|
2284
|
+
assert(cartridge !== null, 'Should find cartridge');
|
|
2285
|
+
assert(cartridge.id === 'pdfcartridge', 'Should have correct ID');
|
|
2296
2286
|
|
|
2297
|
-
const notFound = client.
|
|
2298
|
-
assert(notFound === null, 'Should return null for missing
|
|
2287
|
+
const notFound = client.getCartridge('nonexistent');
|
|
2288
|
+
assert(notFound === null, 'Should return null for missing cartridge');
|
|
2299
2289
|
}
|
|
2300
2290
|
|
|
2301
|
-
// TEST333:
|
|
2302
|
-
function
|
|
2303
|
-
const client = new
|
|
2304
|
-
const server = new
|
|
2305
|
-
const
|
|
2291
|
+
// TEST333: CartridgeRepoClient get all caps
|
|
2292
|
+
function test333_cartridgeRepoClientGetAllCaps() {
|
|
2293
|
+
const client = new CartridgeRepoClient(3600);
|
|
2294
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2295
|
+
const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
|
|
2306
2296
|
|
|
2307
|
-
client.updateCache('https://example.com/api/
|
|
2297
|
+
client.updateCache('https://example.com/api/cartridges', cartridges);
|
|
2308
2298
|
|
|
2309
2299
|
const caps = client.getAllAvailableCaps();
|
|
2310
2300
|
assert(Array.isArray(caps), 'Should return array');
|
|
@@ -2312,19 +2302,19 @@ function test333_pluginRepoClientGetAllCaps() {
|
|
|
2312
2302
|
assert(caps.every(c => typeof c === 'string'), 'All caps should be strings');
|
|
2313
2303
|
}
|
|
2314
2304
|
|
|
2315
|
-
// TEST334:
|
|
2316
|
-
function
|
|
2317
|
-
const client = new
|
|
2318
|
-
const server = new
|
|
2319
|
-
const
|
|
2305
|
+
// TEST334: CartridgeRepoClient needs sync
|
|
2306
|
+
function test334_cartridgeRepoClientNeedsSync() {
|
|
2307
|
+
const client = new CartridgeRepoClient(1); // 1 second TTL
|
|
2308
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2309
|
+
const cartridges = server.transformToCartridgeArray().map(p => new CartridgeInfo(p));
|
|
2320
2310
|
|
|
2321
|
-
const urls = ['https://example.com/api/
|
|
2311
|
+
const urls = ['https://example.com/api/cartridges'];
|
|
2322
2312
|
|
|
2323
2313
|
// Should need sync initially
|
|
2324
2314
|
assert(client.needsSync(urls) === true, 'Should need sync with empty cache');
|
|
2325
2315
|
|
|
2326
2316
|
// Update cache
|
|
2327
|
-
client.updateCache(urls[0],
|
|
2317
|
+
client.updateCache(urls[0], cartridges);
|
|
2328
2318
|
|
|
2329
2319
|
// Should not need sync immediately
|
|
2330
2320
|
assert(client.needsSync(urls) === false, 'Should not need sync right after update');
|
|
@@ -2333,33 +2323,33 @@ function test334_pluginRepoClientNeedsSync() {
|
|
|
2333
2323
|
// Note: Can't test this synchronously, would need async test
|
|
2334
2324
|
}
|
|
2335
2325
|
|
|
2336
|
-
// TEST335:
|
|
2337
|
-
function
|
|
2326
|
+
// TEST335: CartridgeRepoServer and Client integration
|
|
2327
|
+
function test335_cartridgeRepoServerClientIntegration() {
|
|
2338
2328
|
// Server creates API response
|
|
2339
|
-
const server = new
|
|
2340
|
-
const apiResponse = server.
|
|
2329
|
+
const server = new CartridgeRepoServer(sampleRegistry);
|
|
2330
|
+
const apiResponse = server.getCartridges();
|
|
2341
2331
|
|
|
2342
2332
|
// Client consumes API response
|
|
2343
|
-
const client = new
|
|
2344
|
-
const
|
|
2345
|
-
client.updateCache('https://example.com/api/
|
|
2333
|
+
const client = new CartridgeRepoClient(3600);
|
|
2334
|
+
const cartridges = apiResponse.cartridges.map(p => new CartridgeInfo(p));
|
|
2335
|
+
client.updateCache('https://example.com/api/cartridges', cartridges);
|
|
2346
2336
|
|
|
2347
|
-
// Client can find
|
|
2348
|
-
const
|
|
2349
|
-
assert(
|
|
2350
|
-
assert(
|
|
2351
|
-
assert(
|
|
2337
|
+
// Client can find cartridge
|
|
2338
|
+
const cartridge = client.getCartridge('pdfcartridge');
|
|
2339
|
+
assert(cartridge !== null, 'Client should find cartridge from server data');
|
|
2340
|
+
assert(cartridge.isSigned(), 'Cartridge should be signed');
|
|
2341
|
+
assert(cartridge.hasPackage(), 'Cartridge should have package');
|
|
2352
2342
|
|
|
2353
2343
|
// Client can get suggestions
|
|
2354
2344
|
const capUrn = 'cap:in="media:pdf";op=disbind;out="media:disbound-page;textable;list"';
|
|
2355
2345
|
const suggestions = client.getSuggestionsForCap(capUrn);
|
|
2356
2346
|
assert(suggestions.length === 1, 'Should get suggestions');
|
|
2357
|
-
assert(suggestions[0].
|
|
2347
|
+
assert(suggestions[0].cartridgeId === 'pdfcartridge', 'Should suggest correct cartridge');
|
|
2358
2348
|
|
|
2359
2349
|
// Server can search
|
|
2360
|
-
const searchResults = server.
|
|
2350
|
+
const searchResults = server.searchCartridges('pdf');
|
|
2361
2351
|
assert(searchResults.length === 1, 'Server search should work');
|
|
2362
|
-
assert(searchResults[0].id ===
|
|
2352
|
+
assert(searchResults[0].id === cartridge.id, 'Search and client should agree');
|
|
2363
2353
|
}
|
|
2364
2354
|
|
|
2365
2355
|
// ============================================================================
|
|
@@ -3755,12 +3745,15 @@ const {
|
|
|
3755
3745
|
canonicalMediaUrn: rendererCanonicalMediaUrn,
|
|
3756
3746
|
mediaNodeLabel: rendererMediaNodeLabel,
|
|
3757
3747
|
buildStrandGraphData: rendererBuildStrandGraphData,
|
|
3748
|
+
collapseStrandShapeTransitions: rendererCollapseStrandShapeTransitions,
|
|
3758
3749
|
buildRunGraphData: rendererBuildRunGraphData,
|
|
3759
|
-
|
|
3750
|
+
buildEditorGraphData: rendererBuildEditorGraphData,
|
|
3751
|
+
buildResolvedMachineGraphData: rendererBuildResolvedMachineGraphData,
|
|
3760
3752
|
classifyStrandCapSteps: rendererClassifyStrandCapSteps,
|
|
3761
3753
|
validateStrandPayload: rendererValidateStrandPayload,
|
|
3762
3754
|
validateRunPayload: rendererValidateRunPayload,
|
|
3763
|
-
|
|
3755
|
+
validateEditorGraphPayload: rendererValidateEditorGraphPayload,
|
|
3756
|
+
validateResolvedMachinePayload: rendererValidateResolvedMachinePayload,
|
|
3764
3757
|
validateStrandStep: rendererValidateStrandStep,
|
|
3765
3758
|
validateBodyOutcome: rendererValidateBodyOutcome,
|
|
3766
3759
|
} = require('./cap-graph-renderer.js');
|
|
@@ -4204,6 +4197,260 @@ function testRenderer_buildStrandGraphData_nestedForEachThrows() {
|
|
|
4204
4197
|
assert(threw, 'nested ForEach without outer body cap must throw');
|
|
4205
4198
|
}
|
|
4206
4199
|
|
|
4200
|
+
function testRenderer_collapseStrand_singleCapBodyKeepsCapOwnLabel() {
|
|
4201
|
+
// User spec: ForEach/Collect are NOT rendered as nodes, and
|
|
4202
|
+
// the collapse does NOT relabel cap edges. Each cap edge
|
|
4203
|
+
// carries whatever label the strand builder emitted — the
|
|
4204
|
+
// cap's own cardinality marker (from its input/output sequence
|
|
4205
|
+
// flags) is the only source of truth.
|
|
4206
|
+
//
|
|
4207
|
+
// Strand [ForEach, Cap(extract, in=1, out=1), Collect],
|
|
4208
|
+
// source=pdf;list, target=txt;list. The extract cap itself is
|
|
4209
|
+
// 1→1, so its edge label has NO cardinality marker.
|
|
4210
|
+
//
|
|
4211
|
+
// Expected render shape: 3 nodes (input_slot, step_1, output),
|
|
4212
|
+
// with the entry edge labeled "extract" and an unlabeled
|
|
4213
|
+
// connector bridge to the output.
|
|
4214
|
+
const payload = {
|
|
4215
|
+
source_spec: 'media:pdf;list',
|
|
4216
|
+
target_spec: 'media:txt;list',
|
|
4217
|
+
steps: [
|
|
4218
|
+
makeForEachStep('media:pdf;list'),
|
|
4219
|
+
makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract', 'media:pdf', 'media:txt', false, false),
|
|
4220
|
+
makeCollectStep('media:txt'),
|
|
4221
|
+
],
|
|
4222
|
+
};
|
|
4223
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4224
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4225
|
+
|
|
4226
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4227
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4228
|
+
JSON.stringify(['input_slot', 'output', 'step_1']),
|
|
4229
|
+
'collapse removes the ForEach and Collect nodes; the remaining nodes are source + cap + target');
|
|
4230
|
+
|
|
4231
|
+
// Exactly one edge input_slot → step_1 carrying just the cap
|
|
4232
|
+
// title — no (1→n) or (n→n) marker because the cap's own
|
|
4233
|
+
// cardinality is 1→1.
|
|
4234
|
+
const entryEdges = collapsed.edges.filter(e => e.source === 'input_slot' && e.target === 'step_1');
|
|
4235
|
+
assertEqual(entryEdges.length, 1,
|
|
4236
|
+
'phantom duplicate cap edge must be gone — exactly one edge from source to cap');
|
|
4237
|
+
assertEqual(entryEdges[0].label, 'extract',
|
|
4238
|
+
'entry edge carries just the cap title (cap is 1→1, no marker)');
|
|
4239
|
+
|
|
4240
|
+
// The collect bridge is an unlabeled connector.
|
|
4241
|
+
const exitEdges = collapsed.edges.filter(e => e.source === 'step_1' && e.target === 'output');
|
|
4242
|
+
assertEqual(exitEdges.length, 1,
|
|
4243
|
+
'there is exactly one exit edge step_1 → output');
|
|
4244
|
+
assertEqual(exitEdges[0].label, '',
|
|
4245
|
+
'collect bridge is unlabeled');
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
|
|
4249
|
+
// [Cap_a(1→1), ForEach, Cap_b(1→1)] with no Collect,
|
|
4250
|
+
// source=media:a, target=media:c. Cap_b's to_spec is media:c
|
|
4251
|
+
// which is equivalent to target_spec, so the output node is
|
|
4252
|
+
// merged into step_2.
|
|
4253
|
+
//
|
|
4254
|
+
// Since both caps are 1→1, neither carries a cardinality
|
|
4255
|
+
// marker in the render. The foreach step is just dropped;
|
|
4256
|
+
// no relabeling.
|
|
4257
|
+
//
|
|
4258
|
+
// Final: 3 nodes (input_slot, step_0, step_2), 2 edges.
|
|
4259
|
+
const payload = {
|
|
4260
|
+
source_spec: 'media:a',
|
|
4261
|
+
target_spec: 'media:c',
|
|
4262
|
+
steps: [
|
|
4263
|
+
makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
|
|
4264
|
+
makeForEachStep('media:b'),
|
|
4265
|
+
makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
|
|
4266
|
+
],
|
|
4267
|
+
};
|
|
4268
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4269
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4270
|
+
|
|
4271
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4272
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4273
|
+
JSON.stringify(['input_slot', 'step_0', 'step_2']),
|
|
4274
|
+
'foreach node removed and output merged into step_2 (same URN as target)');
|
|
4275
|
+
|
|
4276
|
+
// Exactly one edge from step_0 to step_2, labeled with just
|
|
4277
|
+
// cap_b's title — no foreach marker because cap_b is 1→1 and
|
|
4278
|
+
// the collapse doesn't relabel cap edges.
|
|
4279
|
+
const step0ToStep2 = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
|
|
4280
|
+
assertEqual(step0ToStep2.length, 1,
|
|
4281
|
+
'exactly one step_0 → step_2 edge after dropping the foreach iteration');
|
|
4282
|
+
assertEqual(step0ToStep2[0].label, 'b',
|
|
4283
|
+
'cap_b edge carries just its title (1→1 cap, no marker)');
|
|
4284
|
+
|
|
4285
|
+
// Cap_a's edge is unchanged.
|
|
4286
|
+
const capA = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
|
|
4287
|
+
assert(capA !== undefined, 'cap_a edge input_slot → step_0 exists');
|
|
4288
|
+
assertEqual(capA.label, 'a', 'cap_a edge carries just its title');
|
|
4289
|
+
|
|
4290
|
+
// After merging, step_2 becomes the render target — no separate
|
|
4291
|
+
// output node exists.
|
|
4292
|
+
const outputNode = collapsed.nodes.find(n => n.id === 'output');
|
|
4293
|
+
assertEqual(outputNode, undefined,
|
|
4294
|
+
'output node was merged into step_2 because their URNs are semantically equivalent');
|
|
4295
|
+
const mergedTarget = collapsed.nodes.find(n => n.id === 'step_2');
|
|
4296
|
+
assertEqual(mergedTarget.nodeClass, 'strand-target',
|
|
4297
|
+
'merged step_2 takes on the strand-target role');
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
function testRenderer_collapseStrand_standaloneCollectCollapses() {
|
|
4301
|
+
// [Cap, Collect] with no enclosing ForEach, source=media:a,
|
|
4302
|
+
// target=media:b;list (NOT equivalent to cap's to_spec media:b,
|
|
4303
|
+
// so the output node is retained after collapse).
|
|
4304
|
+
//
|
|
4305
|
+
// Collapse:
|
|
4306
|
+
// - step_1 (standalone Collect) removed.
|
|
4307
|
+
// - Synthesized bridging edge step_0 → output labeled "collect".
|
|
4308
|
+
// - The cap edge input_slot → step_0 is unchanged because the
|
|
4309
|
+
// cap is not inside any foreach body.
|
|
4310
|
+
//
|
|
4311
|
+
// Final: 3 nodes (input_slot, step_0, output), 2 edges.
|
|
4312
|
+
const payload = {
|
|
4313
|
+
source_spec: 'media:a',
|
|
4314
|
+
target_spec: 'media:b;list',
|
|
4315
|
+
steps: [
|
|
4316
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4317
|
+
makeCollectStep('media:b'),
|
|
4318
|
+
],
|
|
4319
|
+
};
|
|
4320
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4321
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4322
|
+
|
|
4323
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4324
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4325
|
+
JSON.stringify(['input_slot', 'output', 'step_0']),
|
|
4326
|
+
'collect node removed; only cap + source + target remain');
|
|
4327
|
+
|
|
4328
|
+
const capEdge = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
|
|
4329
|
+
assert(capEdge !== undefined, 'cap edge survives');
|
|
4330
|
+
assertEqual(capEdge.label, 'x',
|
|
4331
|
+
'cap edge carries just its title — no foreach cardinality markers because the cap is not inside a foreach body');
|
|
4332
|
+
|
|
4333
|
+
const collectEdge = collapsed.edges.find(e => e.source === 'step_0' && e.target === 'output');
|
|
4334
|
+
assert(collectEdge !== undefined, 'step_0 → output edge synthesized by collect collapse');
|
|
4335
|
+
assertEqual(collectEdge.label, '',
|
|
4336
|
+
'the synthesized bridging edge for a standalone Collect is an unlabeled connector (cap labels carry all cardinality info)');
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
|
|
4340
|
+
// Regression test mirroring the user's real strand:
|
|
4341
|
+
// [Cap_disbind (output_is_sequence=true), ForEach, Cap_make_decision],
|
|
4342
|
+
// source = media:pdf, target = media:decision (equivalent to
|
|
4343
|
+
// the last cap's to_spec).
|
|
4344
|
+
//
|
|
4345
|
+
// Expected render shape after collapse:
|
|
4346
|
+
// input_slot → step_0 labeled "Disbind PDF Into Pages (1→n)"
|
|
4347
|
+
// — from Disbind's own output_is_sequence flag, computed
|
|
4348
|
+
// at build time by the strand builder.
|
|
4349
|
+
// step_0 → step_2 labeled "Make a Decision" — no marker
|
|
4350
|
+
// because make_decision is 1→1. The collapse does NOT
|
|
4351
|
+
// add a (1→n) marker on this edge — the (1→n) belongs
|
|
4352
|
+
// to the cap that actually produces the sequence
|
|
4353
|
+
// (Disbind), NOT the cap that consumes one item from it.
|
|
4354
|
+
// No separate output node because step_2's to_spec equals the
|
|
4355
|
+
// strand target.
|
|
4356
|
+
const payload = {
|
|
4357
|
+
source_spec: 'media:pdf',
|
|
4358
|
+
target_spec: 'media:decision',
|
|
4359
|
+
steps: [
|
|
4360
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4361
|
+
makeForEachStep('media:page'),
|
|
4362
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4363
|
+
],
|
|
4364
|
+
};
|
|
4365
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4366
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4367
|
+
|
|
4368
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4369
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4370
|
+
JSON.stringify(['input_slot', 'step_0', 'step_2']),
|
|
4371
|
+
'foreach node and duplicate output node both removed');
|
|
4372
|
+
|
|
4373
|
+
// Disbind cap edge carries its own (1→n) marker from
|
|
4374
|
+
// output_is_sequence=true, NOT from the foreach flag.
|
|
4375
|
+
const disbind = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
|
|
4376
|
+
assert(disbind !== undefined, 'Disbind edge input_slot → step_0 exists');
|
|
4377
|
+
assertEqual(disbind.label, 'Disbind (1\u2192n)',
|
|
4378
|
+
'Disbind edge reflects its own output_is_sequence=true cardinality');
|
|
4379
|
+
|
|
4380
|
+
// make_decision cap edge — the plan-builder phantom direct
|
|
4381
|
+
// edge becomes the render-visible cap edge, carrying just the
|
|
4382
|
+
// cap title. No (1→n) marker: make_decision is 1→1, and the
|
|
4383
|
+
// collapse does NOT add cardinality markers based on foreach
|
|
4384
|
+
// context. The fan-out semantics come from Disbind's own
|
|
4385
|
+
// output_is_sequence flag, which is already visible on the
|
|
4386
|
+
// Disbind edge.
|
|
4387
|
+
const makeDecision = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
|
|
4388
|
+
assertEqual(makeDecision.length, 1,
|
|
4389
|
+
'exactly one edge from Text Page to Decision (phantom not duplicated)');
|
|
4390
|
+
assertEqual(makeDecision[0].label, 'Make a Decision',
|
|
4391
|
+
'the make_decision edge carries just its title — 1→1 cap, no marker');
|
|
4392
|
+
|
|
4393
|
+
// Duplicate target must be gone.
|
|
4394
|
+
const outputNode = collapsed.nodes.find(n => n.id === 'output');
|
|
4395
|
+
assertEqual(outputNode, undefined,
|
|
4396
|
+
'output node merged into step_2 because they represent the same URN');
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
function testRenderer_collapseStrand_plainCapMergesTrailingOutput() {
|
|
4400
|
+
// A strand with a single plain 1→1 cap whose to_spec equals
|
|
4401
|
+
// target_spec. The plan-builder topology produces:
|
|
4402
|
+
// input_slot → step_0 (cap) → output
|
|
4403
|
+
// The collapse pass merges the trailing output edge because
|
|
4404
|
+
// step_0 and output represent the same URN (media:b).
|
|
4405
|
+
//
|
|
4406
|
+
// Final: 2 nodes (input_slot, step_0), 1 edge.
|
|
4407
|
+
const payload = {
|
|
4408
|
+
source_spec: 'media:a',
|
|
4409
|
+
target_spec: 'media:b',
|
|
4410
|
+
steps: [
|
|
4411
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4412
|
+
],
|
|
4413
|
+
};
|
|
4414
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4415
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4416
|
+
|
|
4417
|
+
assertEqual(collapsed.nodes.length, 2,
|
|
4418
|
+
'duplicate output node merged into step_0 — 2 nodes remain');
|
|
4419
|
+
const outputNode = collapsed.nodes.find(n => n.id === 'output');
|
|
4420
|
+
assertEqual(outputNode, undefined,
|
|
4421
|
+
'output node dropped by merge');
|
|
4422
|
+
const mergedTarget = collapsed.nodes.find(n => n.id === 'step_0');
|
|
4423
|
+
assertEqual(mergedTarget.nodeClass, 'strand-target',
|
|
4424
|
+
'step_0 takes on the strand-target role after the merge');
|
|
4425
|
+
|
|
4426
|
+
assertEqual(collapsed.edges.length, 1, 'single cap edge remains');
|
|
4427
|
+
assertEqual(collapsed.edges[0].source, 'input_slot');
|
|
4428
|
+
assertEqual(collapsed.edges[0].target, 'step_0');
|
|
4429
|
+
assertEqual(collapsed.edges[0].label, 'x', 'cap title preserved as edge label');
|
|
4430
|
+
}
|
|
4431
|
+
|
|
4432
|
+
function testRenderer_collapseStrand_plainCapDistinctTargetNoMerge() {
|
|
4433
|
+
// A strand with a single plain cap whose to_spec is NOT
|
|
4434
|
+
// equivalent to target_spec. The output node must be retained
|
|
4435
|
+
// and the trailing connector edge preserved.
|
|
4436
|
+
const payload = {
|
|
4437
|
+
source_spec: 'media:a',
|
|
4438
|
+
target_spec: 'media:b;list',
|
|
4439
|
+
steps: [
|
|
4440
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4441
|
+
],
|
|
4442
|
+
};
|
|
4443
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4444
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4445
|
+
|
|
4446
|
+
assertEqual(collapsed.nodes.length, 3,
|
|
4447
|
+
'no merge because cap to_spec (media:b) and target (media:b;list) are semantically distinct');
|
|
4448
|
+
assert(collapsed.nodes.find(n => n.id === 'output') !== undefined,
|
|
4449
|
+
'output node retained');
|
|
4450
|
+
assert(collapsed.nodes.find(n => n.id === 'step_0') !== undefined,
|
|
4451
|
+
'step_0 retained');
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4207
4454
|
function testRenderer_validateStrandPayload_missingSourceSpec() {
|
|
4208
4455
|
let threw = false;
|
|
4209
4456
|
try {
|
|
@@ -4228,12 +4475,16 @@ function testRenderer_validateBodyOutcome_rejectsNegativeIndex() {
|
|
|
4228
4475
|
}
|
|
4229
4476
|
|
|
4230
4477
|
function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
4231
|
-
// 6 successes, 4 failures.
|
|
4232
|
-
//
|
|
4233
|
-
//
|
|
4234
|
-
//
|
|
4235
|
-
//
|
|
4236
|
-
//
|
|
4478
|
+
// 6 successes, 4 failures. visible=3+2, total=10. Body has 2
|
|
4479
|
+
// caps (a, b). Each body replica is a chain of:
|
|
4480
|
+
// entry node + body_step_0 (cap a) + body_step_1 (cap b)
|
|
4481
|
+
// = 3 nodes per body. Failed bodies truncate at failed_cap
|
|
4482
|
+
// (cap b, idx=1) so `traceEnd=2` — same 3-node chain.
|
|
4483
|
+
//
|
|
4484
|
+
// Total replica nodes: (3 success × 3) + (2 failure × 3) = 15.
|
|
4485
|
+
//
|
|
4486
|
+
// Show-more nodes: one for 3 hidden successes, one for 2 hidden
|
|
4487
|
+
// failures.
|
|
4237
4488
|
const strand = {
|
|
4238
4489
|
source_spec: 'media:pdf;list',
|
|
4239
4490
|
target_spec: 'media:txt',
|
|
@@ -4269,17 +4520,14 @@ function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
|
4269
4520
|
};
|
|
4270
4521
|
const built = rendererBuildRunGraphData(payload);
|
|
4271
4522
|
|
|
4272
|
-
// Count replica nodes by classes.
|
|
4273
4523
|
let successNodes = 0;
|
|
4274
4524
|
let failureNodes = 0;
|
|
4275
4525
|
for (const n of built.replicaNodes) {
|
|
4276
4526
|
if (n.classes === 'body-success') successNodes++;
|
|
4277
4527
|
if (n.classes === 'body-failure') failureNodes++;
|
|
4278
4528
|
}
|
|
4279
|
-
assertEqual(successNodes, 3 *
|
|
4280
|
-
|
|
4281
|
-
// length includes both cap a and cap b → 2 nodes per failed body.
|
|
4282
|
-
assertEqual(failureNodes, 2 * 2, 'two failed bodies × two nodes each (trace truncated at failed_cap)');
|
|
4529
|
+
assertEqual(successNodes, 3 * 3, '3 success bodies × (1 entry + 2 body caps) = 9 success replica nodes');
|
|
4530
|
+
assertEqual(failureNodes, 2 * 3, '2 failure bodies × (1 entry + 2 body caps) = 6 failure replica nodes');
|
|
4283
4531
|
|
|
4284
4532
|
// Show-more nodes: one for success (hidden 3), one for failure (hidden 2).
|
|
4285
4533
|
const successShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'success');
|
|
@@ -4291,9 +4539,12 @@ function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
|
4291
4539
|
}
|
|
4292
4540
|
|
|
4293
4541
|
function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace() {
|
|
4294
|
-
// A failure without failed_cap (
|
|
4295
|
-
// render the full body trace — the builder must not
|
|
4296
|
-
// zero replicas.
|
|
4542
|
+
// A failure without failed_cap (infrastructure failure) must
|
|
4543
|
+
// still render the full body trace — the builder must not
|
|
4544
|
+
// crash or produce zero replicas.
|
|
4545
|
+
//
|
|
4546
|
+
// Strand [ForEach, Cap, Collect] → body has 1 cap. Each body
|
|
4547
|
+
// replica emits 1 entry node + 1 body cap node = 2 nodes.
|
|
4297
4548
|
const strand = {
|
|
4298
4549
|
source_spec: 'media:pdf;list',
|
|
4299
4550
|
target_spec: 'media:txt',
|
|
@@ -4317,7 +4568,7 @@ function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace(
|
|
|
4317
4568
|
for (const n of built.replicaNodes) {
|
|
4318
4569
|
if (n.classes === 'body-failure') failureNodes++;
|
|
4319
4570
|
}
|
|
4320
|
-
assertEqual(failureNodes,
|
|
4571
|
+
assertEqual(failureNodes, 2, 'entry + body cap = 2 failure replica nodes');
|
|
4321
4572
|
}
|
|
4322
4573
|
|
|
4323
4574
|
function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
@@ -4368,16 +4619,229 @@ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
|
4368
4619
|
for (const n of built.replicaNodes) {
|
|
4369
4620
|
if (n.classes === 'body-failure') failureNodes++;
|
|
4370
4621
|
}
|
|
4371
|
-
//
|
|
4372
|
-
|
|
4622
|
+
// 1 entry + 2 body step nodes (cap x and cap y, truncated
|
|
4623
|
+
// at cap y) = 3 failure replica nodes.
|
|
4624
|
+
assertEqual(failureNodes, 3, 'trace truncates at cap y via isEquivalent, yielding entry + 2 cap nodes');
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4627
|
+
function testRenderer_buildRunGraphData_backboneHasNoForeachNode() {
|
|
4628
|
+
// Regression test for the run-mode rendering fix: the backbone
|
|
4629
|
+
// delivered to cytoscape must NOT contain any strand-foreach or
|
|
4630
|
+
// strand-collect nodes. Run mode inherits the same cosmetic
|
|
4631
|
+
// collapse as strand mode so the foreach/collect execution-layer
|
|
4632
|
+
// concepts don't leak into the view as boxed nodes.
|
|
4633
|
+
//
|
|
4634
|
+
// User scenario: [Disbind (1→n), ForEach, make_decision] where
|
|
4635
|
+
// target_spec equals the last cap's to_spec, so the backbone
|
|
4636
|
+
// collapses to 3 nodes: input_slot, step_0 (Text Page),
|
|
4637
|
+
// step_2 (Decision, merged target). No separate `for each` or
|
|
4638
|
+
// `collect` boxes.
|
|
4639
|
+
const strand = {
|
|
4640
|
+
source_spec: 'media:pdf',
|
|
4641
|
+
target_spec: 'media:decision',
|
|
4642
|
+
steps: [
|
|
4643
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4644
|
+
makeForEachStep('media:page'),
|
|
4645
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4646
|
+
],
|
|
4647
|
+
};
|
|
4648
|
+
const payload = {
|
|
4649
|
+
resolved_strand: strand,
|
|
4650
|
+
body_outcomes: [],
|
|
4651
|
+
visible_success_count: 0,
|
|
4652
|
+
visible_failure_count: 0,
|
|
4653
|
+
total_body_count: 0,
|
|
4654
|
+
};
|
|
4655
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4656
|
+
|
|
4657
|
+
// Backbone must contain NO foreach/collect nodes.
|
|
4658
|
+
const foreachNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-foreach');
|
|
4659
|
+
const collectNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-collect');
|
|
4660
|
+
assertEqual(foreachNodes.length, 0, 'run backbone must not contain strand-foreach nodes');
|
|
4661
|
+
assertEqual(collectNodes.length, 0, 'run backbone must not contain strand-collect nodes');
|
|
4662
|
+
|
|
4663
|
+
// The backbone fallback connector is the foreach-entry cap edge
|
|
4664
|
+
// that runs from the pre-foreach node to the body cap. It must
|
|
4665
|
+
// survive collapse so the target stays reachable even with zero
|
|
4666
|
+
// successful bodies.
|
|
4667
|
+
const backboneCapEdges = built.strandBuilt.edges.filter(e => e.edgeClass === 'strand-cap-edge');
|
|
4668
|
+
assert(backboneCapEdges.some(e => e.source === 'step_0' && e.target === 'step_2'),
|
|
4669
|
+
'foreach-entry backbone edge step_0 → step_2 must be present for fallback connectivity');
|
|
4670
|
+
|
|
4671
|
+
// With zero outcomes, no replicas and no show-more nodes.
|
|
4672
|
+
assertEqual(built.replicaNodes.length, 0, 'no replica nodes when body_outcomes is empty');
|
|
4673
|
+
assertEqual(built.showMoreNodes.length, 0, 'no show-more nodes when no hidden outcomes');
|
|
4674
|
+
}
|
|
4675
|
+
|
|
4676
|
+
function testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
|
|
4677
|
+
// When every body fails, the strand target node was never
|
|
4678
|
+
// reached by any execution. The render drops BOTH the backbone
|
|
4679
|
+
// foreach-entry edge AND the orphaned target node so the user
|
|
4680
|
+
// doesn't see a stale "Decision" placeholder alongside their
|
|
4681
|
+
// failed replicas.
|
|
4682
|
+
const strand = {
|
|
4683
|
+
source_spec: 'media:pdf',
|
|
4684
|
+
target_spec: 'media:decision',
|
|
4685
|
+
steps: [
|
|
4686
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4687
|
+
makeForEachStep('media:page'),
|
|
4688
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4689
|
+
],
|
|
4690
|
+
};
|
|
4691
|
+
const failedCapUrn = 'cap:in="media:page";op=decide;out="media:decision"';
|
|
4692
|
+
const payload = {
|
|
4693
|
+
resolved_strand: strand,
|
|
4694
|
+
body_outcomes: [
|
|
4695
|
+
{ body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
|
|
4696
|
+
{ body_index: 1, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
|
|
4697
|
+
],
|
|
4698
|
+
visible_success_count: 3,
|
|
4699
|
+
visible_failure_count: 3,
|
|
4700
|
+
total_body_count: 2,
|
|
4701
|
+
};
|
|
4702
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4703
|
+
|
|
4704
|
+
// The dropped placeholder: step_2 (the merged strand target
|
|
4705
|
+
// "Decision") is absent from the backbone because all bodies
|
|
4706
|
+
// failed and the replicas didn't reach it.
|
|
4707
|
+
const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
|
|
4708
|
+
assertEqual(hasStep2, false,
|
|
4709
|
+
'strand target placeholder must be dropped when zero successful replicas reach it');
|
|
4710
|
+
|
|
4711
|
+
// The backbone foreach-entry edge is also gone — replicas
|
|
4712
|
+
// replaced it and there's no orphan target to connect.
|
|
4713
|
+
const foreachEntry = built.strandBuilt.edges.find(e =>
|
|
4714
|
+
e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
|
|
4715
|
+
assertEqual(foreachEntry, undefined,
|
|
4716
|
+
'backbone foreach-entry edge must be dropped when replicas exist');
|
|
4717
|
+
|
|
4718
|
+
// Each failed body renders as an entry node + N body-step
|
|
4719
|
+
// nodes. Body has 1 cap (make_decision), so 2 nodes per body.
|
|
4720
|
+
// 2 failed bodies × 2 nodes = 4 failure replica nodes.
|
|
4721
|
+
const failureNodes = built.replicaNodes.filter(n => n.classes === 'body-failure');
|
|
4722
|
+
assertEqual(failureNodes.length, 4,
|
|
4723
|
+
'two failed bodies × (entry + 1 body cap) = 4 failure replica nodes');
|
|
4724
|
+
|
|
4725
|
+
// Disbind is the sequence producer, so its backbone node
|
|
4726
|
+
// (step_0) is ALSO dropped — the per-body entry nodes own
|
|
4727
|
+
// the per-item rendering. The only surviving backbone node
|
|
4728
|
+
// is the input_slot (avid-optic source PDF).
|
|
4729
|
+
const hasStep0 = built.strandBuilt.nodes.some(n => n.id === 'step_0');
|
|
4730
|
+
assertEqual(hasStep0, false,
|
|
4731
|
+
'sequence producer backbone node (Disbind output) is dropped; replicas own the per-body rendering');
|
|
4732
|
+
const hasInputSlot = built.strandBuilt.nodes.some(n => n.id === 'input_slot');
|
|
4733
|
+
assertEqual(hasInputSlot, true, 'input_slot survives as the shared source');
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
function testRenderer_buildRunGraphData_unclosedForeachSuccessNoMerge() {
|
|
4737
|
+
// Strand without a Collect: [Disbind, ForEach, make_decision].
|
|
4738
|
+
// Under the machfab realize_strand semantics there's no Collect,
|
|
4739
|
+
// so each body produces its OWN terminal output. Successful
|
|
4740
|
+
// replicas do NOT merge into a shared target — each body has
|
|
4741
|
+
// its own separate decision.
|
|
4742
|
+
//
|
|
4743
|
+
// Expected replica shape per body:
|
|
4744
|
+
// anchorNode (pre-foreach backbone) → entry (per-body Text Page)
|
|
4745
|
+
// → body_n_0 (per-body Decision)
|
|
4746
|
+
// (no merge edge back into the backbone)
|
|
4747
|
+
const strand = {
|
|
4748
|
+
source_spec: 'media:pdf',
|
|
4749
|
+
target_spec: 'media:decision',
|
|
4750
|
+
steps: [
|
|
4751
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4752
|
+
makeForEachStep('media:page'),
|
|
4753
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4754
|
+
],
|
|
4755
|
+
};
|
|
4756
|
+
const payload = {
|
|
4757
|
+
resolved_strand: strand,
|
|
4758
|
+
body_outcomes: [
|
|
4759
|
+
{ body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
|
|
4760
|
+
],
|
|
4761
|
+
visible_success_count: 3,
|
|
4762
|
+
visible_failure_count: 3,
|
|
4763
|
+
total_body_count: 1,
|
|
4764
|
+
};
|
|
4765
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4766
|
+
|
|
4767
|
+
// step_2 (the merged strand target) was dropped because it's
|
|
4768
|
+
// a body cap step and there's no Collect to merge into.
|
|
4769
|
+
const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
|
|
4770
|
+
assertEqual(hasStep2, false,
|
|
4771
|
+
'body cap node dropped; without Collect there is no shared merge target');
|
|
4772
|
+
|
|
4773
|
+
// Each body produces its own entry + body cap chain: 2 nodes.
|
|
4774
|
+
const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
|
|
4775
|
+
assertEqual(successNodes.length, 2,
|
|
4776
|
+
'one successful body × (entry + 1 body cap) = 2 replica nodes');
|
|
4777
|
+
|
|
4778
|
+
// No replica edge targets the (now non-existent) step_2.
|
|
4779
|
+
const mergeEdges = built.replicaEdges.filter(e =>
|
|
4780
|
+
e.data && e.data.target === 'step_2');
|
|
4781
|
+
assertEqual(mergeEdges.length, 0,
|
|
4782
|
+
'no merge edges to step_2 because there is no Collect');
|
|
4783
|
+
|
|
4784
|
+
// The fork edge from anchor (input_slot, because Disbind IS
|
|
4785
|
+
// the sequence producer whose backbone node is dropped) to
|
|
4786
|
+
// the per-body entry IS present.
|
|
4787
|
+
const forkEdges = built.replicaEdges.filter(e =>
|
|
4788
|
+
e.data && e.data.source === 'input_slot' && e.classes === 'body-success');
|
|
4789
|
+
assertEqual(forkEdges.length, 1, 'fork edge input_slot → body-0-entry exists');
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
function testRenderer_buildRunGraphData_closedForeachSuccessMergesAtCollectTarget() {
|
|
4793
|
+
// With a Collect closing the body, successful replicas DO merge
|
|
4794
|
+
// into the post-collect target so the flow converges.
|
|
4795
|
+
// Strand: [Disbind, ForEach, Cap_a, Cap_b, Collect] with a
|
|
4796
|
+
// downstream cap after Collect to make the post-collect target
|
|
4797
|
+
// a real separate node.
|
|
4798
|
+
//
|
|
4799
|
+
// Actually simpler: [ForEach, Cap_a, Collect] with source=list
|
|
4800
|
+
// and target=list.
|
|
4801
|
+
const strand = {
|
|
4802
|
+
source_spec: 'media:pdf;list',
|
|
4803
|
+
target_spec: 'media:txt;list',
|
|
4804
|
+
steps: [
|
|
4805
|
+
makeForEachStep('media:pdf;list'),
|
|
4806
|
+
makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract', 'media:pdf', 'media:txt', false, false),
|
|
4807
|
+
makeCollectStep('media:txt'),
|
|
4808
|
+
],
|
|
4809
|
+
};
|
|
4810
|
+
const payload = {
|
|
4811
|
+
resolved_strand: strand,
|
|
4812
|
+
body_outcomes: [
|
|
4813
|
+
{ body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
|
|
4814
|
+
],
|
|
4815
|
+
visible_success_count: 3,
|
|
4816
|
+
visible_failure_count: 3,
|
|
4817
|
+
total_body_count: 1,
|
|
4818
|
+
};
|
|
4819
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4820
|
+
|
|
4821
|
+
// The post-collect target (output) is still present — it's the
|
|
4822
|
+
// strand target. Successful replicas merge into it.
|
|
4823
|
+
const hasOutput = built.strandBuilt.nodes.some(n => n.id === 'output');
|
|
4824
|
+
assertEqual(hasOutput, true,
|
|
4825
|
+
'post-collect target (output) stays because successful replicas merge into it');
|
|
4826
|
+
|
|
4827
|
+
// One body × (entry + 1 body cap) = 2 replica nodes.
|
|
4828
|
+
const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
|
|
4829
|
+
assertEqual(successNodes.length, 2,
|
|
4830
|
+
'one successful body × (entry + 1 body cap) = 2 replica nodes');
|
|
4831
|
+
|
|
4832
|
+
// One merge edge from the last body cap to the output node.
|
|
4833
|
+
const mergeEdges = built.replicaEdges.filter(e =>
|
|
4834
|
+
e.data && e.data.target === 'output' && e.classes === 'body-success');
|
|
4835
|
+
assertEqual(mergeEdges.length, 1,
|
|
4836
|
+
'one merge edge from body cap replica to collect target');
|
|
4373
4837
|
}
|
|
4374
4838
|
|
|
4375
|
-
// ----------------
|
|
4839
|
+
// ---------------- editor-graph builder ----------------
|
|
4376
4840
|
|
|
4377
|
-
function
|
|
4841
|
+
function testRenderer_validateEditorGraphPayload_rejectsUnknownKind() {
|
|
4378
4842
|
let threw = false;
|
|
4379
4843
|
try {
|
|
4380
|
-
|
|
4844
|
+
rendererValidateEditorGraphPayload({
|
|
4381
4845
|
elements: [{ kind: 'widget', graph_id: 'w1' }],
|
|
4382
4846
|
});
|
|
4383
4847
|
} catch (e) {
|
|
@@ -4388,32 +4852,102 @@ function testRenderer_validateMachinePayload_rejectsUnknownKind() {
|
|
|
4388
4852
|
assert(threw, 'unknown element kind must throw');
|
|
4389
4853
|
}
|
|
4390
4854
|
|
|
4391
|
-
function
|
|
4392
|
-
//
|
|
4393
|
-
//
|
|
4855
|
+
function testRenderer_buildEditorGraphData_collapsesCapsIntoLabeledEdges() {
|
|
4856
|
+
// The notation analyzer emits a bipartite chain per cap
|
|
4857
|
+
// application: data_node → arg_edge → cap_node → arg_edge →
|
|
4858
|
+
// data_node. The machine builder collapses each cap into a
|
|
4859
|
+
// single labeled edge between the input and output data slots.
|
|
4860
|
+
// Cap nodes do NOT appear as cytoscape nodes. Cap tokenIds are
|
|
4861
|
+
// carried on the synthesized edge so editor cross-highlight
|
|
4862
|
+
// still resolves from the rendered edge to the cap's source
|
|
4863
|
+
// text.
|
|
4864
|
+
const data = {
|
|
4865
|
+
elements: [
|
|
4866
|
+
{ kind: 'node', graph_id: 'n_src', label: 'n0', token_id: 't-src' },
|
|
4867
|
+
{ kind: 'node', graph_id: 'n_dst', label: 'n1', token_id: 't-dst' },
|
|
4868
|
+
{ kind: 'cap', graph_id: 'c1', label: 'my_cap', token_id: 't-cap', linked_cap_urn: 'cap:...' },
|
|
4869
|
+
{ kind: 'edge', graph_id: 'e_in', source_graph_id: 'n_src', target_graph_id: 'c1', label: 'in', token_id: 't-ein' },
|
|
4870
|
+
{ kind: 'edge', graph_id: 'e_out', source_graph_id: 'c1', target_graph_id: 'n_dst', label: 'out', token_id: 't-eout' },
|
|
4871
|
+
],
|
|
4872
|
+
};
|
|
4873
|
+
const built = rendererBuildEditorGraphData(data);
|
|
4874
|
+
|
|
4875
|
+
// Only data-slot nodes survive. Cap is NOT a node.
|
|
4876
|
+
assertEqual(built.nodes.length, 2, 'only data-slot nodes are rendered');
|
|
4877
|
+
const nodeIds = built.nodes.map(n => n.data.id).sort();
|
|
4878
|
+
assertEqual(JSON.stringify(nodeIds), JSON.stringify(['n_dst', 'n_src']),
|
|
4879
|
+
'data-slot nodes preserved verbatim');
|
|
4880
|
+
assertEqual(built.nodes.every(n => n.data.kind === 'node'), true,
|
|
4881
|
+
'every surviving node has kind=node (no cap nodes)');
|
|
4882
|
+
|
|
4883
|
+
// The cap is collapsed to a single labeled edge.
|
|
4884
|
+
assertEqual(built.edges.length, 1, 'one collapsed edge per cap application');
|
|
4885
|
+
const edge = built.edges[0];
|
|
4886
|
+
assertEqual(edge.data.source, 'n_src', 'edge source is the cap input data slot');
|
|
4887
|
+
assertEqual(edge.data.target, 'n_dst', 'edge target is the cap output data slot');
|
|
4888
|
+
assertEqual(edge.data.label, 'my_cap', 'edge label is the cap title');
|
|
4889
|
+
assertEqual(edge.data.tokenId, 't-cap',
|
|
4890
|
+
'edge carries the cap node tokenId so editor cross-highlight points to the cap in source text');
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4893
|
+
function testRenderer_buildEditorGraphData_loopMarkedEdgeGetsLoopClass() {
|
|
4894
|
+
// A cap marked `is_loop: true` must produce a `machine-loop`
|
|
4895
|
+
// edge so the stylesheet's dashed amber rule applies.
|
|
4896
|
+
const data = {
|
|
4897
|
+
elements: [
|
|
4898
|
+
{ kind: 'node', graph_id: 'a', label: 'a', token_id: 't-a' },
|
|
4899
|
+
{ kind: 'node', graph_id: 'b', label: 'b', token_id: 't-b' },
|
|
4900
|
+
{ kind: 'cap', graph_id: 'c', label: 'looped', token_id: 't-c', is_loop: true },
|
|
4901
|
+
{ kind: 'edge', graph_id: 'e1', source_graph_id: 'a', target_graph_id: 'c', token_id: 't-e1' },
|
|
4902
|
+
{ kind: 'edge', graph_id: 'e2', source_graph_id: 'c', target_graph_id: 'b', token_id: 't-e2' },
|
|
4903
|
+
],
|
|
4904
|
+
};
|
|
4905
|
+
const built = rendererBuildEditorGraphData(data);
|
|
4906
|
+
assertEqual(built.edges.length, 1, 'one collapsed edge');
|
|
4907
|
+
assert(built.edges[0].classes.indexOf('machine-loop') >= 0,
|
|
4908
|
+
'loop-marked cap emits machine-loop class on the collapsed edge');
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
function testRenderer_buildEditorGraphData_cardinalityFromDataSlotSequenceFlags() {
|
|
4912
|
+
// Cardinality markers come from the source and target data
|
|
4913
|
+
// slots' `is_sequence` flags. A cap whose output data slot has
|
|
4914
|
+
// `is_sequence=true` shows "(1→n)" on its collapsed edge.
|
|
4915
|
+
const data = {
|
|
4916
|
+
elements: [
|
|
4917
|
+
{ kind: 'node', graph_id: 'a', label: 'pdf', token_id: 't-a', is_sequence: false },
|
|
4918
|
+
{ kind: 'node', graph_id: 'b', label: 'pages', token_id: 't-b', is_sequence: true },
|
|
4919
|
+
{ kind: 'cap', graph_id: 'c', label: 'disbind', token_id: 't-c' },
|
|
4920
|
+
{ kind: 'edge', graph_id: 'e1', source_graph_id: 'a', target_graph_id: 'c', token_id: 't-e1' },
|
|
4921
|
+
{ kind: 'edge', graph_id: 'e2', source_graph_id: 'c', target_graph_id: 'b', token_id: 't-e2' },
|
|
4922
|
+
],
|
|
4923
|
+
};
|
|
4924
|
+
const built = rendererBuildEditorGraphData(data);
|
|
4925
|
+
assertEqual(built.edges.length, 1, 'one collapsed edge');
|
|
4926
|
+
assertEqual(built.edges[0].data.label, 'disbind (1\u2192n)',
|
|
4927
|
+
'cardinality marker "(1→n)" derived from output data slot is_sequence=true');
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
function testRenderer_buildEditorGraphData_capWithoutCompleteArgsIsDropped() {
|
|
4931
|
+
// A cap with no incoming or no outgoing argument edges (e.g.
|
|
4932
|
+
// the user is mid-typing) contributes nothing to the render.
|
|
4933
|
+
// The data slots are still emitted.
|
|
4394
4934
|
const data = {
|
|
4395
4935
|
elements: [
|
|
4396
|
-
{ kind: 'node', graph_id: '
|
|
4397
|
-
{ kind: 'cap', graph_id: '
|
|
4398
|
-
{ kind: 'edge', graph_id: 'e1', source_graph_id: '
|
|
4936
|
+
{ kind: 'node', graph_id: 'a', label: 'a', token_id: 't-a' },
|
|
4937
|
+
{ kind: 'cap', graph_id: 'c', label: 'halfway', token_id: 't-c' },
|
|
4938
|
+
{ kind: 'edge', graph_id: 'e1', source_graph_id: 'a', target_graph_id: 'c', token_id: 't-e1' },
|
|
4399
4939
|
],
|
|
4400
4940
|
};
|
|
4401
|
-
const built =
|
|
4402
|
-
|
|
4403
|
-
assertEqual(
|
|
4404
|
-
'
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
const kinds = built.nodes.map(n => n.data.kind).sort();
|
|
4409
|
-
assertEqual(JSON.stringify(kinds), JSON.stringify(['cap', 'node']),
|
|
4410
|
-
'element kinds must be preserved');
|
|
4411
|
-
}
|
|
4412
|
-
|
|
4413
|
-
function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
|
|
4941
|
+
const built = rendererBuildEditorGraphData(data);
|
|
4942
|
+
assertEqual(built.nodes.length, 1, 'data slot emitted');
|
|
4943
|
+
assertEqual(built.edges.length, 0,
|
|
4944
|
+
'incomplete cap (no outgoing argument) drops out of the render');
|
|
4945
|
+
}
|
|
4946
|
+
|
|
4947
|
+
function testRenderer_buildEditorGraphData_rejectsEdgeWithMissingSource() {
|
|
4414
4948
|
let threw = false;
|
|
4415
4949
|
try {
|
|
4416
|
-
|
|
4950
|
+
rendererBuildEditorGraphData({
|
|
4417
4951
|
elements: [
|
|
4418
4952
|
{ kind: 'edge', graph_id: 'e1', target_graph_id: 't' },
|
|
4419
4953
|
],
|
|
@@ -4424,6 +4958,261 @@ function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
|
|
|
4424
4958
|
assert(threw, 'edge without source_graph_id must throw');
|
|
4425
4959
|
}
|
|
4426
4960
|
|
|
4961
|
+
// ---------------- resolved-machine builder ----------------
|
|
4962
|
+
|
|
4963
|
+
function testRenderer_buildResolvedMachineGraphData_singleStrandLinearChain() {
|
|
4964
|
+
// A single-strand machine: media:pdf → extract → media:txt
|
|
4965
|
+
// → embed → media:embedding. Two edges, three nodes, no
|
|
4966
|
+
// loops, no fan-in. Tests the basic shape — nodes and
|
|
4967
|
+
// edges flow through verbatim from the resolved machine
|
|
4968
|
+
// payload.
|
|
4969
|
+
const payload = {
|
|
4970
|
+
strands: [
|
|
4971
|
+
{
|
|
4972
|
+
nodes: [
|
|
4973
|
+
{ id: 'n0', urn: 'media:pdf' },
|
|
4974
|
+
{ id: 'n1', urn: 'media:txt;textable' },
|
|
4975
|
+
{ id: 'n2', urn: 'media:embedding;record' },
|
|
4976
|
+
],
|
|
4977
|
+
edges: [
|
|
4978
|
+
{
|
|
4979
|
+
alias: 'edge_0',
|
|
4980
|
+
cap_urn: 'cap:in=media:pdf;op=extract;out=media:txt;textable',
|
|
4981
|
+
is_loop: false,
|
|
4982
|
+
assignment: [
|
|
4983
|
+
{ cap_arg_media_urn: 'media:pdf', source_node: 'n0' },
|
|
4984
|
+
],
|
|
4985
|
+
target_node: 'n1',
|
|
4986
|
+
},
|
|
4987
|
+
{
|
|
4988
|
+
alias: 'edge_1',
|
|
4989
|
+
cap_urn: 'cap:in=media:textable;op=embed;out=media:embedding;record',
|
|
4990
|
+
is_loop: false,
|
|
4991
|
+
assignment: [
|
|
4992
|
+
{ cap_arg_media_urn: 'media:textable', source_node: 'n1' },
|
|
4993
|
+
],
|
|
4994
|
+
target_node: 'n2',
|
|
4995
|
+
},
|
|
4996
|
+
],
|
|
4997
|
+
input_anchor_nodes: ['n0'],
|
|
4998
|
+
output_anchor_nodes: ['n2'],
|
|
4999
|
+
},
|
|
5000
|
+
],
|
|
5001
|
+
};
|
|
5002
|
+
const built = rendererBuildResolvedMachineGraphData(payload);
|
|
5003
|
+
assertEqual(built.nodes.length, 3, 'three data-slot nodes');
|
|
5004
|
+
assertEqual(built.edges.length, 2, 'two cap edges (one assignment each)');
|
|
5005
|
+
// First edge connects n0 → n1, second connects n1 → n2.
|
|
5006
|
+
const edges = built.edges.map(e => `${e.data.source}->${e.data.target}`);
|
|
5007
|
+
assertEqual(edges[0], 'n0->n1', 'first edge wires n0 to n1');
|
|
5008
|
+
assertEqual(edges[1], 'n1->n2', 'second edge wires n1 to n2');
|
|
5009
|
+
// Anchor nodes carry the strand-source / strand-target classes.
|
|
5010
|
+
const n0 = built.nodes.find(n => n.data.id === 'n0');
|
|
5011
|
+
const n2 = built.nodes.find(n => n.data.id === 'n2');
|
|
5012
|
+
assert(n0.classes.indexOf('strand-source') >= 0,
|
|
5013
|
+
'input anchor node carries strand-source class');
|
|
5014
|
+
assert(n2.classes.indexOf('strand-target') >= 0,
|
|
5015
|
+
'output anchor node carries strand-target class');
|
|
5016
|
+
}
|
|
5017
|
+
|
|
5018
|
+
function testRenderer_buildResolvedMachineGraphData_loopEdgeGetsLoopClass() {
|
|
5019
|
+
// An is_loop edge corresponds to a strand step inside a
|
|
5020
|
+
// ForEach body. The renderer must mark it with the
|
|
5021
|
+
// `machine-loop` class so the dashed amber rule applies.
|
|
5022
|
+
const payload = {
|
|
5023
|
+
strands: [
|
|
5024
|
+
{
|
|
5025
|
+
nodes: [
|
|
5026
|
+
{ id: 'n0', urn: 'media:page;textable' },
|
|
5027
|
+
{ id: 'n1', urn: 'media:decision;json;record;textable' },
|
|
5028
|
+
],
|
|
5029
|
+
edges: [
|
|
5030
|
+
{
|
|
5031
|
+
alias: 'edge_0',
|
|
5032
|
+
cap_urn: 'cap:in=media:textable;op=make_decision;out=media:decision;json;record;textable',
|
|
5033
|
+
is_loop: true,
|
|
5034
|
+
assignment: [
|
|
5035
|
+
{ cap_arg_media_urn: 'media:textable', source_node: 'n0' },
|
|
5036
|
+
],
|
|
5037
|
+
target_node: 'n1',
|
|
5038
|
+
},
|
|
5039
|
+
],
|
|
5040
|
+
input_anchor_nodes: ['n0'],
|
|
5041
|
+
output_anchor_nodes: ['n1'],
|
|
5042
|
+
},
|
|
5043
|
+
],
|
|
5044
|
+
};
|
|
5045
|
+
const built = rendererBuildResolvedMachineGraphData(payload);
|
|
5046
|
+
assertEqual(built.edges.length, 1, 'one cap edge');
|
|
5047
|
+
assert(built.edges[0].classes.indexOf('machine-loop') >= 0,
|
|
5048
|
+
'is_loop=true must produce a machine-loop class on the cap edge');
|
|
5049
|
+
}
|
|
5050
|
+
|
|
5051
|
+
function testRenderer_buildResolvedMachineGraphData_fanInProducesEdgePerAssignment() {
|
|
5052
|
+
// A cap with two input args (a fan-in) gets one rendered
|
|
5053
|
+
// edge per (source_node, target_node) pair so cytoscape can
|
|
5054
|
+
// draw both incoming wires. Both edges share the cap title
|
|
5055
|
+
// and color so they read as a single fan-in.
|
|
5056
|
+
const payload = {
|
|
5057
|
+
strands: [
|
|
5058
|
+
{
|
|
5059
|
+
nodes: [
|
|
5060
|
+
{ id: 'n0', urn: 'media:image;png' },
|
|
5061
|
+
{ id: 'n1', urn: 'media:model-spec;textable' },
|
|
5062
|
+
{ id: 'n2', urn: 'media:image-description;textable' },
|
|
5063
|
+
],
|
|
5064
|
+
edges: [
|
|
5065
|
+
{
|
|
5066
|
+
alias: 'edge_0',
|
|
5067
|
+
cap_urn: 'cap:in=media:image;png;op=describe_image;out=media:image-description;textable',
|
|
5068
|
+
is_loop: false,
|
|
5069
|
+
assignment: [
|
|
5070
|
+
{ cap_arg_media_urn: 'media:image;png', source_node: 'n0' },
|
|
5071
|
+
{ cap_arg_media_urn: 'media:model-spec;textable', source_node: 'n1' },
|
|
5072
|
+
],
|
|
5073
|
+
target_node: 'n2',
|
|
5074
|
+
},
|
|
5075
|
+
],
|
|
5076
|
+
input_anchor_nodes: ['n0', 'n1'],
|
|
5077
|
+
output_anchor_nodes: ['n2'],
|
|
5078
|
+
},
|
|
5079
|
+
],
|
|
5080
|
+
};
|
|
5081
|
+
const built = rendererBuildResolvedMachineGraphData(payload);
|
|
5082
|
+
assertEqual(built.edges.length, 2, 'two rendered edges, one per assignment binding');
|
|
5083
|
+
const sources = built.edges.map(e => e.data.source).sort();
|
|
5084
|
+
assertEqual(JSON.stringify(sources), JSON.stringify(['n0', 'n1']),
|
|
5085
|
+
'each binding gets its own source-node edge into the same target');
|
|
5086
|
+
assertEqual(built.edges[0].data.target, 'n2', 'first edge targets n2');
|
|
5087
|
+
assertEqual(built.edges[1].data.target, 'n2', 'second edge targets n2');
|
|
5088
|
+
}
|
|
5089
|
+
|
|
5090
|
+
function testRenderer_buildResolvedMachineGraphData_multiStrandKeepsStrandsDisjoint() {
|
|
5091
|
+
// Two strands inside one machine. Each strand has its own
|
|
5092
|
+
// nodes and edges. Node ids are globally unique across
|
|
5093
|
+
// strands (Rust assigns them via a single counter), so no
|
|
5094
|
+
// node id collision can happen. The renderer must emit
|
|
5095
|
+
// every node and every edge from both strands.
|
|
5096
|
+
const payload = {
|
|
5097
|
+
strands: [
|
|
5098
|
+
{
|
|
5099
|
+
nodes: [
|
|
5100
|
+
{ id: 'n0', urn: 'media:pdf' },
|
|
5101
|
+
{ id: 'n1', urn: 'media:txt;textable' },
|
|
5102
|
+
],
|
|
5103
|
+
edges: [
|
|
5104
|
+
{
|
|
5105
|
+
alias: 'edge_0',
|
|
5106
|
+
cap_urn: 'cap:in=media:pdf;op=extract;out=media:txt;textable',
|
|
5107
|
+
is_loop: false,
|
|
5108
|
+
assignment: [
|
|
5109
|
+
{ cap_arg_media_urn: 'media:pdf', source_node: 'n0' },
|
|
5110
|
+
],
|
|
5111
|
+
target_node: 'n1',
|
|
5112
|
+
},
|
|
5113
|
+
],
|
|
5114
|
+
input_anchor_nodes: ['n0'],
|
|
5115
|
+
output_anchor_nodes: ['n1'],
|
|
5116
|
+
},
|
|
5117
|
+
{
|
|
5118
|
+
nodes: [
|
|
5119
|
+
{ id: 'n2', urn: 'media:json;record;textable' },
|
|
5120
|
+
{ id: 'n3', urn: 'media:csv;list;record;textable' },
|
|
5121
|
+
],
|
|
5122
|
+
edges: [
|
|
5123
|
+
{
|
|
5124
|
+
alias: 'edge_1',
|
|
5125
|
+
cap_urn: 'cap:in=media:json;record;textable;op=convert_format;out=media:csv;list;record;textable',
|
|
5126
|
+
is_loop: false,
|
|
5127
|
+
assignment: [
|
|
5128
|
+
{ cap_arg_media_urn: 'media:json;record;textable', source_node: 'n2' },
|
|
5129
|
+
],
|
|
5130
|
+
target_node: 'n3',
|
|
5131
|
+
},
|
|
5132
|
+
],
|
|
5133
|
+
input_anchor_nodes: ['n2'],
|
|
5134
|
+
output_anchor_nodes: ['n3'],
|
|
5135
|
+
},
|
|
5136
|
+
],
|
|
5137
|
+
};
|
|
5138
|
+
const built = rendererBuildResolvedMachineGraphData(payload);
|
|
5139
|
+
assertEqual(built.nodes.length, 4, 'all four nodes from both strands present');
|
|
5140
|
+
assertEqual(built.edges.length, 2, 'one edge per strand');
|
|
5141
|
+
// Each node carries a strandIndex matching which strand it came from.
|
|
5142
|
+
const idToStrand = {};
|
|
5143
|
+
for (const n of built.nodes) idToStrand[n.data.id] = n.data.strandIndex;
|
|
5144
|
+
assertEqual(idToStrand['n0'], 0, 'n0 belongs to strand 0');
|
|
5145
|
+
assertEqual(idToStrand['n1'], 0, 'n1 belongs to strand 0');
|
|
5146
|
+
assertEqual(idToStrand['n2'], 1, 'n2 belongs to strand 1');
|
|
5147
|
+
assertEqual(idToStrand['n3'], 1, 'n3 belongs to strand 1');
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
function testRenderer_buildResolvedMachineGraphData_duplicateNodeIdAcrossStrandsFailsHard() {
|
|
5151
|
+
// Node ids must be globally unique across strands. The
|
|
5152
|
+
// Rust serializer guarantees this via a single global
|
|
5153
|
+
// counter. If the host ever feeds a payload that violates
|
|
5154
|
+
// it, the renderer must fail hard so the bug surfaces
|
|
5155
|
+
// instead of silently overwriting one node with another.
|
|
5156
|
+
const payload = {
|
|
5157
|
+
strands: [
|
|
5158
|
+
{
|
|
5159
|
+
nodes: [{ id: 'n0', urn: 'media:pdf' }],
|
|
5160
|
+
edges: [],
|
|
5161
|
+
input_anchor_nodes: ['n0'],
|
|
5162
|
+
output_anchor_nodes: ['n0'],
|
|
5163
|
+
},
|
|
5164
|
+
{
|
|
5165
|
+
nodes: [{ id: 'n0', urn: 'media:html' }],
|
|
5166
|
+
edges: [],
|
|
5167
|
+
input_anchor_nodes: ['n0'],
|
|
5168
|
+
output_anchor_nodes: ['n0'],
|
|
5169
|
+
},
|
|
5170
|
+
],
|
|
5171
|
+
};
|
|
5172
|
+
let threw = false;
|
|
5173
|
+
let message = '';
|
|
5174
|
+
try {
|
|
5175
|
+
rendererBuildResolvedMachineGraphData(payload);
|
|
5176
|
+
} catch (e) {
|
|
5177
|
+
threw = true;
|
|
5178
|
+
message = e.message || '';
|
|
5179
|
+
}
|
|
5180
|
+
assert(threw, 'duplicate node id across strands must throw');
|
|
5181
|
+
assert(message.includes('duplicate node id') && message.includes('n0'),
|
|
5182
|
+
'error must name the colliding node id');
|
|
5183
|
+
}
|
|
5184
|
+
|
|
5185
|
+
function testRenderer_validateResolvedMachinePayload_rejectsMissingFields() {
|
|
5186
|
+
// The validator must reject any payload missing a required
|
|
5187
|
+
// field on a strand, edge, node, or assignment binding.
|
|
5188
|
+
// We exercise the most-likely-to-be-missed field on each
|
|
5189
|
+
// sub-shape.
|
|
5190
|
+
const cases = [
|
|
5191
|
+
{ strands: 'not-an-array' },
|
|
5192
|
+
{ strands: [{ nodes: [], edges: [], input_anchor_nodes: [] /* missing output_anchor_nodes */ }] },
|
|
5193
|
+
{ strands: [{ nodes: [{ id: 'n0' /* missing urn */ }], edges: [], input_anchor_nodes: [], output_anchor_nodes: [] }] },
|
|
5194
|
+
{
|
|
5195
|
+
strands: [{
|
|
5196
|
+
nodes: [{ id: 'n0', urn: 'media:x' }],
|
|
5197
|
+
edges: [{
|
|
5198
|
+
alias: 'edge_0',
|
|
5199
|
+
cap_urn: 'cap:in=...;out=...',
|
|
5200
|
+
is_loop: false,
|
|
5201
|
+
assignment: [{ cap_arg_media_urn: 'media:x' /* missing source_node */ }],
|
|
5202
|
+
target_node: 'n0',
|
|
5203
|
+
}],
|
|
5204
|
+
input_anchor_nodes: ['n0'],
|
|
5205
|
+
output_anchor_nodes: ['n0'],
|
|
5206
|
+
}],
|
|
5207
|
+
},
|
|
5208
|
+
];
|
|
5209
|
+
for (const c of cases) {
|
|
5210
|
+
let threw = false;
|
|
5211
|
+
try { rendererValidateResolvedMachinePayload(c); } catch (e) { threw = true; }
|
|
5212
|
+
assert(threw, `validator must reject payload: ${JSON.stringify(c).slice(0, 80)}`);
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
|
|
4427
5216
|
// ============================================================================
|
|
4428
5217
|
// Test runner
|
|
4429
5218
|
// ============================================================================
|
|
@@ -4602,24 +5391,24 @@ async function runTests() {
|
|
|
4602
5391
|
if (p2) await p2;
|
|
4603
5392
|
runTest('JS: media_spec_construction', testJS_mediaSpecConstruction);
|
|
4604
5393
|
|
|
4605
|
-
//
|
|
4606
|
-
console.log('\n---
|
|
4607
|
-
runTest('TEST320:
|
|
4608
|
-
runTest('TEST321:
|
|
4609
|
-
runTest('TEST322:
|
|
4610
|
-
runTest('TEST323:
|
|
4611
|
-
runTest('TEST324:
|
|
4612
|
-
runTest('TEST325:
|
|
4613
|
-
runTest('TEST326:
|
|
4614
|
-
runTest('TEST327:
|
|
4615
|
-
runTest('TEST328:
|
|
4616
|
-
runTest('TEST329:
|
|
4617
|
-
runTest('TEST330:
|
|
4618
|
-
runTest('TEST331:
|
|
4619
|
-
runTest('TEST332:
|
|
4620
|
-
runTest('TEST333:
|
|
4621
|
-
runTest('TEST334:
|
|
4622
|
-
runTest('TEST335:
|
|
5394
|
+
// cartridge_repo: CartridgeRepoServer and CartridgeRepoClient tests
|
|
5395
|
+
console.log('\n--- cartridge_repo ---');
|
|
5396
|
+
runTest('TEST320: cartridge_info_construction', test320_cartridgeInfoConstruction);
|
|
5397
|
+
runTest('TEST321: cartridge_info_is_signed', test321_cartridgeInfoIsSigned);
|
|
5398
|
+
runTest('TEST322: cartridge_info_has_package', test322_cartridgeInfoHasPackage);
|
|
5399
|
+
runTest('TEST323: cartridge_repo_server_validate_registry', test323_cartridgeRepoServerValidateRegistry);
|
|
5400
|
+
runTest('TEST324: cartridge_repo_server_transform_to_array', test324_cartridgeRepoServerTransformToArray);
|
|
5401
|
+
runTest('TEST325: cartridge_repo_server_get_cartridges', test325_cartridgeRepoServerGetCartridges);
|
|
5402
|
+
runTest('TEST326: cartridge_repo_server_get_cartridge_by_id', test326_cartridgeRepoServerGetCartridgeById);
|
|
5403
|
+
runTest('TEST327: cartridge_repo_server_search_cartridges', test327_cartridgeRepoServerSearchCartridges);
|
|
5404
|
+
runTest('TEST328: cartridge_repo_server_get_by_category', test328_cartridgeRepoServerGetByCategory);
|
|
5405
|
+
runTest('TEST329: cartridge_repo_server_get_by_cap', test329_cartridgeRepoServerGetByCap);
|
|
5406
|
+
runTest('TEST330: cartridge_repo_client_update_cache', test330_cartridgeRepoClientUpdateCache);
|
|
5407
|
+
runTest('TEST331: cartridge_repo_client_get_suggestions', test331_cartridgeRepoClientGetSuggestions);
|
|
5408
|
+
runTest('TEST332: cartridge_repo_client_get_cartridge', test332_cartridgeRepoClientGetCartridge);
|
|
5409
|
+
runTest('TEST333: cartridge_repo_client_get_all_caps', test333_cartridgeRepoClientGetAllCaps);
|
|
5410
|
+
runTest('TEST334: cartridge_repo_client_needs_sync', test334_cartridgeRepoClientNeedsSync);
|
|
5411
|
+
runTest('TEST335: cartridge_repo_server_client_integration', test335_cartridgeRepoServerClientIntegration);
|
|
4623
5412
|
|
|
4624
5413
|
// media_urn.rs: TEST546-TEST558 (MediaUrn predicates)
|
|
4625
5414
|
console.log('\n--- media_urn.rs (predicates) ---');
|
|
@@ -4800,6 +5589,12 @@ async function runTests() {
|
|
|
4800
5589
|
runTest('RENDERER: buildStrand_standaloneCollect', testRenderer_buildStrandGraphData_standaloneCollect);
|
|
4801
5590
|
runTest('RENDERER: buildStrand_unclosedForEachBody', testRenderer_buildStrandGraphData_unclosedForEachBody);
|
|
4802
5591
|
runTest('RENDERER: buildStrand_nestedForEachThrows', testRenderer_buildStrandGraphData_nestedForEachThrows);
|
|
5592
|
+
runTest('RENDERER: collapseStrand_singleCapBody', testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker);
|
|
5593
|
+
runTest('RENDERER: collapseStrand_unclosedForEachBody', testRenderer_collapseStrand_unclosedForEachBodyCollapses);
|
|
5594
|
+
runTest('RENDERER: collapseStrand_standaloneCollect', testRenderer_collapseStrand_standaloneCollectCollapses);
|
|
5595
|
+
runTest('RENDERER: collapseStrand_seqCapBeforeForeach', testRenderer_collapseStrand_sequenceProducingCapBeforeForeach);
|
|
5596
|
+
runTest('RENDERER: collapseStrand_plainCapMergesOutput', testRenderer_collapseStrand_plainCapMergesTrailingOutput);
|
|
5597
|
+
runTest('RENDERER: collapseStrand_plainCapDistinctTarget', testRenderer_collapseStrand_plainCapDistinctTargetNoMerge);
|
|
4803
5598
|
runTest('RENDERER: validateStrand_missingSourceSpec', testRenderer_validateStrandPayload_missingSourceSpec);
|
|
4804
5599
|
|
|
4805
5600
|
console.log('\n--- cap-graph-renderer run builder ---');
|
|
@@ -4807,11 +5602,26 @@ async function runTests() {
|
|
|
4807
5602
|
runTest('RENDERER: buildRun_pagesSuccessesAndFailures', testRenderer_buildRunGraphData_pagesSuccessesAndFailures);
|
|
4808
5603
|
runTest('RENDERER: buildRun_failureWithoutFailedCap', testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
|
|
4809
5604
|
runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
runTest('RENDERER:
|
|
4813
|
-
runTest('RENDERER:
|
|
4814
|
-
|
|
5605
|
+
runTest('RENDERER: buildRun_backboneHasNoForeachNode', testRenderer_buildRunGraphData_backboneHasNoForeachNode);
|
|
5606
|
+
runTest('RENDERER: buildRun_allFailedDropsPlaceholder', testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder);
|
|
5607
|
+
runTest('RENDERER: buildRun_unclosedForeachNoMerge', testRenderer_buildRunGraphData_unclosedForeachSuccessNoMerge);
|
|
5608
|
+
runTest('RENDERER: buildRun_closedForeachMerges', testRenderer_buildRunGraphData_closedForeachSuccessMergesAtCollectTarget);
|
|
5609
|
+
|
|
5610
|
+
console.log('\n--- cap-graph-renderer editor-graph builder ---');
|
|
5611
|
+
runTest('RENDERER: validateEditorGraph_unknownKind', testRenderer_validateEditorGraphPayload_rejectsUnknownKind);
|
|
5612
|
+
runTest('RENDERER: buildEditorGraph_collapsesCapsIntoEdges', testRenderer_buildEditorGraphData_collapsesCapsIntoLabeledEdges);
|
|
5613
|
+
runTest('RENDERER: buildEditorGraph_loopEdgeGetsClass', testRenderer_buildEditorGraphData_loopMarkedEdgeGetsLoopClass);
|
|
5614
|
+
runTest('RENDERER: buildEditorGraph_cardinalityFromIsSeq', testRenderer_buildEditorGraphData_cardinalityFromDataSlotSequenceFlags);
|
|
5615
|
+
runTest('RENDERER: buildEditorGraph_incompleteCapDropped', testRenderer_buildEditorGraphData_capWithoutCompleteArgsIsDropped);
|
|
5616
|
+
runTest('RENDERER: buildEditorGraph_rejectsEdgeMissingSrc', testRenderer_buildEditorGraphData_rejectsEdgeWithMissingSource);
|
|
5617
|
+
|
|
5618
|
+
console.log('\n--- cap-graph-renderer resolved-machine builder ---');
|
|
5619
|
+
runTest('RENDERER: buildResolvedMachine_singleStrandLinear', testRenderer_buildResolvedMachineGraphData_singleStrandLinearChain);
|
|
5620
|
+
runTest('RENDERER: buildResolvedMachine_loopGetsLoopClass', testRenderer_buildResolvedMachineGraphData_loopEdgeGetsLoopClass);
|
|
5621
|
+
runTest('RENDERER: buildResolvedMachine_fanInOneEdgePerSrc', testRenderer_buildResolvedMachineGraphData_fanInProducesEdgePerAssignment);
|
|
5622
|
+
runTest('RENDERER: buildResolvedMachine_multiStrandDisjoint', testRenderer_buildResolvedMachineGraphData_multiStrandKeepsStrandsDisjoint);
|
|
5623
|
+
runTest('RENDERER: buildResolvedMachine_dupNodeIdFails', testRenderer_buildResolvedMachineGraphData_duplicateNodeIdAcrossStrandsFailsHard);
|
|
5624
|
+
runTest('RENDERER: validateResolvedMachine_rejectsMissingFields', testRenderer_validateResolvedMachinePayload_rejectsMissingFields);
|
|
4815
5625
|
|
|
4816
5626
|
// Summary
|
|
4817
5627
|
console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
|