capdag 0.119.263 → 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 +721 -452
- package/capdag.js +123 -132
- package/capdag.test.js +716 -329
- 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
|
// ============================================================================
|
|
@@ -3757,11 +3747,13 @@ const {
|
|
|
3757
3747
|
buildStrandGraphData: rendererBuildStrandGraphData,
|
|
3758
3748
|
collapseStrandShapeTransitions: rendererCollapseStrandShapeTransitions,
|
|
3759
3749
|
buildRunGraphData: rendererBuildRunGraphData,
|
|
3760
|
-
|
|
3750
|
+
buildEditorGraphData: rendererBuildEditorGraphData,
|
|
3751
|
+
buildResolvedMachineGraphData: rendererBuildResolvedMachineGraphData,
|
|
3761
3752
|
classifyStrandCapSteps: rendererClassifyStrandCapSteps,
|
|
3762
3753
|
validateStrandPayload: rendererValidateStrandPayload,
|
|
3763
3754
|
validateRunPayload: rendererValidateRunPayload,
|
|
3764
|
-
|
|
3755
|
+
validateEditorGraphPayload: rendererValidateEditorGraphPayload,
|
|
3756
|
+
validateResolvedMachinePayload: rendererValidateResolvedMachinePayload,
|
|
3765
3757
|
validateStrandStep: rendererValidateStrandStep,
|
|
3766
3758
|
validateBodyOutcome: rendererValidateBodyOutcome,
|
|
3767
3759
|
} = require('./cap-graph-renderer.js');
|
|
@@ -4205,20 +4197,20 @@ function testRenderer_buildStrandGraphData_nestedForEachThrows() {
|
|
|
4205
4197
|
assert(threw, 'nested ForEach without outer body cap must throw');
|
|
4206
4198
|
}
|
|
4207
4199
|
|
|
4208
|
-
function
|
|
4209
|
-
// User spec: ForEach/Collect are NOT rendered as nodes
|
|
4210
|
-
//
|
|
4211
|
-
//
|
|
4212
|
-
//
|
|
4213
|
-
//
|
|
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.
|
|
4214
4206
|
//
|
|
4215
|
-
// Strand [ForEach, Cap(extract), Collect],
|
|
4216
|
-
// target=txt;list
|
|
4217
|
-
//
|
|
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.
|
|
4218
4210
|
//
|
|
4219
4211
|
// Expected render shape: 3 nodes (input_slot, step_1, output),
|
|
4220
|
-
// with the entry edge labeled "extract
|
|
4221
|
-
//
|
|
4212
|
+
// with the entry edge labeled "extract" and an unlabeled
|
|
4213
|
+
// connector bridge to the output.
|
|
4222
4214
|
const payload = {
|
|
4223
4215
|
source_spec: 'media:pdf;list',
|
|
4224
4216
|
target_spec: 'media:txt;list',
|
|
@@ -4236,44 +4228,32 @@ function testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMa
|
|
|
4236
4228
|
JSON.stringify(['input_slot', 'output', 'step_1']),
|
|
4237
4229
|
'collapse removes the ForEach and Collect nodes; the remaining nodes are source + cap + target');
|
|
4238
4230
|
|
|
4239
|
-
// Exactly one edge
|
|
4240
|
-
// title
|
|
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.
|
|
4241
4234
|
const entryEdges = collapsed.edges.filter(e => e.source === 'input_slot' && e.target === 'step_1');
|
|
4242
4235
|
assertEqual(entryEdges.length, 1,
|
|
4243
4236
|
'phantom duplicate cap edge must be gone — exactly one edge from source to cap');
|
|
4244
|
-
assertEqual(entryEdges[0].label, 'extract
|
|
4245
|
-
'
|
|
4237
|
+
assertEqual(entryEdges[0].label, 'extract',
|
|
4238
|
+
'entry edge carries just the cap title (cap is 1→1, no marker)');
|
|
4246
4239
|
|
|
4247
|
-
// The
|
|
4248
|
-
// is already shown on the entry edge.
|
|
4240
|
+
// The collect bridge is an unlabeled connector.
|
|
4249
4241
|
const exitEdges = collapsed.edges.filter(e => e.source === 'step_1' && e.target === 'output');
|
|
4250
4242
|
assertEqual(exitEdges.length, 1,
|
|
4251
4243
|
'there is exactly one exit edge step_1 → output');
|
|
4252
4244
|
assertEqual(exitEdges[0].label, '',
|
|
4253
|
-
'
|
|
4245
|
+
'collect bridge is unlabeled');
|
|
4254
4246
|
}
|
|
4255
4247
|
|
|
4256
4248
|
function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
|
|
4257
|
-
// [Cap_a, ForEach, Cap_b] with no Collect,
|
|
4258
|
-
// target=media:c. Cap_b's to_spec is media:c
|
|
4259
|
-
// equivalent to target_spec, so the output node is
|
|
4260
|
-
// into step_2.
|
|
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.
|
|
4261
4253
|
//
|
|
4262
|
-
//
|
|
4263
|
-
//
|
|
4264
|
-
//
|
|
4265
|
-
// plan builder, but the foreach body entry
|
|
4266
|
-
// under the render model)
|
|
4267
|
-
// step_0 → step_1 (foreach direct)
|
|
4268
|
-
// step_1 → step_2 (iteration)
|
|
4269
|
-
// step_2 → output (trailing connector, empty label)
|
|
4270
|
-
//
|
|
4271
|
-
// Collapse:
|
|
4272
|
-
// - step_1 (foreach) removed with its iteration edges.
|
|
4273
|
-
// - step_0 → step_2 relabeled "b (1→n)" via foreachEntry.
|
|
4274
|
-
// - step_2 → output merged because upstream.fullUrn equivalent
|
|
4275
|
-
// to target_spec (both media:c); step_2 takes the target
|
|
4276
|
-
// display label.
|
|
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.
|
|
4277
4257
|
//
|
|
4278
4258
|
// Final: 3 nodes (input_slot, step_0, step_2), 2 edges.
|
|
4279
4259
|
const payload = {
|
|
@@ -4293,18 +4273,19 @@ function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
|
|
|
4293
4273
|
JSON.stringify(['input_slot', 'step_0', 'step_2']),
|
|
4294
4274
|
'foreach node removed and output merged into step_2 (same URN as target)');
|
|
4295
4275
|
|
|
4296
|
-
// Exactly one edge from step_0 to step_2, labeled with
|
|
4297
|
-
// title
|
|
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.
|
|
4298
4279
|
const step0ToStep2 = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
|
|
4299
4280
|
assertEqual(step0ToStep2.length, 1,
|
|
4300
4281
|
'exactly one step_0 → step_2 edge after dropping the foreach iteration');
|
|
4301
|
-
assertEqual(step0ToStep2[0].label, 'b
|
|
4302
|
-
'
|
|
4282
|
+
assertEqual(step0ToStep2[0].label, 'b',
|
|
4283
|
+
'cap_b edge carries just its title (1→1 cap, no marker)');
|
|
4303
4284
|
|
|
4304
|
-
// Cap_a's edge is unchanged
|
|
4285
|
+
// Cap_a's edge is unchanged.
|
|
4305
4286
|
const capA = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
|
|
4306
4287
|
assert(capA !== undefined, 'cap_a edge input_slot → step_0 exists');
|
|
4307
|
-
assertEqual(capA.label, 'a', 'cap_a edge carries just its title
|
|
4288
|
+
assertEqual(capA.label, 'a', 'cap_a edge carries just its title');
|
|
4308
4289
|
|
|
4309
4290
|
// After merging, step_2 becomes the render target — no separate
|
|
4310
4291
|
// output node exists.
|
|
@@ -4351,8 +4332,8 @@ function testRenderer_collapseStrand_standaloneCollectCollapses() {
|
|
|
4351
4332
|
|
|
4352
4333
|
const collectEdge = collapsed.edges.find(e => e.source === 'step_0' && e.target === 'output');
|
|
4353
4334
|
assert(collectEdge !== undefined, 'step_0 → output edge synthesized by collect collapse');
|
|
4354
|
-
assertEqual(collectEdge.label, '
|
|
4355
|
-
'the synthesized bridging edge for a standalone Collect is
|
|
4335
|
+
assertEqual(collectEdge.label, '',
|
|
4336
|
+
'the synthesized bridging edge for a standalone Collect is an unlabeled connector (cap labels carry all cardinality info)');
|
|
4356
4337
|
}
|
|
4357
4338
|
|
|
4358
4339
|
function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
|
|
@@ -4362,18 +4343,16 @@ function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
|
|
|
4362
4343
|
// the last cap's to_spec).
|
|
4363
4344
|
//
|
|
4364
4345
|
// Expected render shape after collapse:
|
|
4365
|
-
// input_slot → step_0 labeled "Disbind (1→n)"
|
|
4366
|
-
// own output_is_sequence flag, computed
|
|
4367
|
-
//
|
|
4368
|
-
//
|
|
4369
|
-
//
|
|
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.
|
|
4370
4354
|
// No separate output node because step_2's to_spec equals the
|
|
4371
4355
|
// strand target.
|
|
4372
|
-
//
|
|
4373
|
-
// If this test fails, the runtime bug would manifest as either
|
|
4374
|
-
// (a) a duplicate target node, (b) a "for each" labeled edge
|
|
4375
|
-
// where the cap title should be, or (c) the phantom direct cap
|
|
4376
|
-
// edge not being relabeled.
|
|
4377
4356
|
const payload = {
|
|
4378
4357
|
source_spec: 'media:pdf',
|
|
4379
4358
|
target_spec: 'media:decision',
|
|
@@ -4398,14 +4377,18 @@ function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
|
|
|
4398
4377
|
assertEqual(disbind.label, 'Disbind (1\u2192n)',
|
|
4399
4378
|
'Disbind edge reflects its own output_is_sequence=true cardinality');
|
|
4400
4379
|
|
|
4401
|
-
// make_decision cap edge
|
|
4402
|
-
//
|
|
4403
|
-
// (1→n)
|
|
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.
|
|
4404
4387
|
const makeDecision = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
|
|
4405
4388
|
assertEqual(makeDecision.length, 1,
|
|
4406
4389
|
'exactly one edge from Text Page to Decision (phantom not duplicated)');
|
|
4407
|
-
assertEqual(makeDecision[0].label, 'Make a Decision
|
|
4408
|
-
'the
|
|
4390
|
+
assertEqual(makeDecision[0].label, 'Make a Decision',
|
|
4391
|
+
'the make_decision edge carries just its title — 1→1 cap, no marker');
|
|
4409
4392
|
|
|
4410
4393
|
// Duplicate target must be gone.
|
|
4411
4394
|
const outputNode = collapsed.nodes.find(n => n.id === 'output');
|
|
@@ -4492,12 +4475,16 @@ function testRenderer_validateBodyOutcome_rejectsNegativeIndex() {
|
|
|
4492
4475
|
}
|
|
4493
4476
|
|
|
4494
4477
|
function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
4495
|
-
// 6 successes, 4 failures.
|
|
4496
|
-
//
|
|
4497
|
-
//
|
|
4498
|
-
//
|
|
4499
|
-
//
|
|
4500
|
-
//
|
|
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.
|
|
4501
4488
|
const strand = {
|
|
4502
4489
|
source_spec: 'media:pdf;list',
|
|
4503
4490
|
target_spec: 'media:txt',
|
|
@@ -4533,17 +4520,14 @@ function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
|
4533
4520
|
};
|
|
4534
4521
|
const built = rendererBuildRunGraphData(payload);
|
|
4535
4522
|
|
|
4536
|
-
// Count replica nodes by classes.
|
|
4537
4523
|
let successNodes = 0;
|
|
4538
4524
|
let failureNodes = 0;
|
|
4539
4525
|
for (const n of built.replicaNodes) {
|
|
4540
4526
|
if (n.classes === 'body-success') successNodes++;
|
|
4541
4527
|
if (n.classes === 'body-failure') failureNodes++;
|
|
4542
4528
|
}
|
|
4543
|
-
assertEqual(successNodes, 3 *
|
|
4544
|
-
|
|
4545
|
-
// length includes both cap a and cap b → 2 nodes per failed body.
|
|
4546
|
-
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');
|
|
4547
4531
|
|
|
4548
4532
|
// Show-more nodes: one for success (hidden 3), one for failure (hidden 2).
|
|
4549
4533
|
const successShowMore = built.showMoreNodes.find(n => n.data.showMoreGroup === 'success');
|
|
@@ -4555,9 +4539,12 @@ function testRenderer_buildRunGraphData_pagesSuccessesAndFailures() {
|
|
|
4555
4539
|
}
|
|
4556
4540
|
|
|
4557
4541
|
function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace() {
|
|
4558
|
-
// A failure without failed_cap (
|
|
4559
|
-
// render the full body trace — the builder must not
|
|
4560
|
-
// 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.
|
|
4561
4548
|
const strand = {
|
|
4562
4549
|
source_spec: 'media:pdf;list',
|
|
4563
4550
|
target_spec: 'media:txt',
|
|
@@ -4581,7 +4568,7 @@ function testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace(
|
|
|
4581
4568
|
for (const n of built.replicaNodes) {
|
|
4582
4569
|
if (n.classes === 'body-failure') failureNodes++;
|
|
4583
4570
|
}
|
|
4584
|
-
assertEqual(failureNodes,
|
|
4571
|
+
assertEqual(failureNodes, 2, 'entry + body cap = 2 failure replica nodes');
|
|
4585
4572
|
}
|
|
4586
4573
|
|
|
4587
4574
|
function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
@@ -4632,8 +4619,9 @@ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
|
4632
4619
|
for (const n of built.replicaNodes) {
|
|
4633
4620
|
if (n.classes === 'body-failure') failureNodes++;
|
|
4634
4621
|
}
|
|
4635
|
-
//
|
|
4636
|
-
|
|
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');
|
|
4637
4625
|
}
|
|
4638
4626
|
|
|
4639
4627
|
function testRenderer_buildRunGraphData_backboneHasNoForeachNode() {
|
|
@@ -4727,26 +4715,35 @@ function testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
|
|
|
4727
4715
|
assertEqual(foreachEntry, undefined,
|
|
4728
4716
|
'backbone foreach-entry edge must be dropped when replicas exist');
|
|
4729
4717
|
|
|
4730
|
-
//
|
|
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.
|
|
4731
4721
|
const failureNodes = built.replicaNodes.filter(n => n.classes === 'body-failure');
|
|
4732
|
-
assertEqual(failureNodes.length,
|
|
4733
|
-
'two failed bodies
|
|
4722
|
+
assertEqual(failureNodes.length, 4,
|
|
4723
|
+
'two failed bodies × (entry + 1 body cap) = 4 failure replica nodes');
|
|
4734
4724
|
|
|
4735
|
-
//
|
|
4736
|
-
//
|
|
4737
|
-
//
|
|
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).
|
|
4738
4729
|
const hasStep0 = built.strandBuilt.nodes.some(n => n.id === 'step_0');
|
|
4739
|
-
assertEqual(hasStep0,
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
}
|
|
4744
|
-
|
|
4745
|
-
function
|
|
4746
|
-
//
|
|
4747
|
-
// the
|
|
4748
|
-
//
|
|
4749
|
-
//
|
|
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)
|
|
4750
4747
|
const strand = {
|
|
4751
4748
|
source_spec: 'media:pdf',
|
|
4752
4749
|
target_spec: 'media:decision',
|
|
@@ -4767,31 +4764,84 @@ function testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful() {
|
|
|
4767
4764
|
};
|
|
4768
4765
|
const built = rendererBuildRunGraphData(payload);
|
|
4769
4766
|
|
|
4770
|
-
//
|
|
4771
|
-
|
|
4772
|
-
e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
|
|
4773
|
-
assertEqual(foreachEntry, undefined,
|
|
4774
|
-
'foreach-entry backbone edge dropped when at least one success exists');
|
|
4775
|
-
|
|
4776
|
-
// step_2 (target) stays because the replica merge edge lands on it.
|
|
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.
|
|
4777
4769
|
const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
|
|
4778
|
-
assertEqual(hasStep2,
|
|
4779
|
-
'
|
|
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');
|
|
4780
4826
|
|
|
4781
|
-
//
|
|
4827
|
+
// One body × (entry + 1 body cap) = 2 replica nodes.
|
|
4782
4828
|
const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
|
|
4783
|
-
assertEqual(successNodes.length,
|
|
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.
|
|
4784
4833
|
const mergeEdges = built.replicaEdges.filter(e =>
|
|
4785
|
-
e.data && e.data.target === '
|
|
4786
|
-
assertEqual(mergeEdges.length, 1,
|
|
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');
|
|
4787
4837
|
}
|
|
4788
4838
|
|
|
4789
|
-
// ----------------
|
|
4839
|
+
// ---------------- editor-graph builder ----------------
|
|
4790
4840
|
|
|
4791
|
-
function
|
|
4841
|
+
function testRenderer_validateEditorGraphPayload_rejectsUnknownKind() {
|
|
4792
4842
|
let threw = false;
|
|
4793
4843
|
try {
|
|
4794
|
-
|
|
4844
|
+
rendererValidateEditorGraphPayload({
|
|
4795
4845
|
elements: [{ kind: 'widget', graph_id: 'w1' }],
|
|
4796
4846
|
});
|
|
4797
4847
|
} catch (e) {
|
|
@@ -4802,32 +4852,102 @@ function testRenderer_validateMachinePayload_rejectsUnknownKind() {
|
|
|
4802
4852
|
assert(threw, 'unknown element kind must throw');
|
|
4803
4853
|
}
|
|
4804
4854
|
|
|
4805
|
-
function
|
|
4806
|
-
//
|
|
4807
|
-
//
|
|
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.
|
|
4808
4915
|
const data = {
|
|
4809
4916
|
elements: [
|
|
4810
|
-
{ kind: 'node', graph_id: '
|
|
4811
|
-
{ kind: '
|
|
4812
|
-
{ kind: '
|
|
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' },
|
|
4813
4922
|
],
|
|
4814
4923
|
};
|
|
4815
|
-
const built =
|
|
4816
|
-
|
|
4817
|
-
assertEqual(
|
|
4818
|
-
'
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
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.
|
|
4934
|
+
const data = {
|
|
4935
|
+
elements: [
|
|
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' },
|
|
4939
|
+
],
|
|
4940
|
+
};
|
|
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() {
|
|
4828
4948
|
let threw = false;
|
|
4829
4949
|
try {
|
|
4830
|
-
|
|
4950
|
+
rendererBuildEditorGraphData({
|
|
4831
4951
|
elements: [
|
|
4832
4952
|
{ kind: 'edge', graph_id: 'e1', target_graph_id: 't' },
|
|
4833
4953
|
],
|
|
@@ -4838,6 +4958,261 @@ function testRenderer_buildMachineGraphData_rejectsEdgeWithMissingSource() {
|
|
|
4838
4958
|
assert(threw, 'edge without source_graph_id must throw');
|
|
4839
4959
|
}
|
|
4840
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
|
+
|
|
4841
5216
|
// ============================================================================
|
|
4842
5217
|
// Test runner
|
|
4843
5218
|
// ============================================================================
|
|
@@ -5016,24 +5391,24 @@ async function runTests() {
|
|
|
5016
5391
|
if (p2) await p2;
|
|
5017
5392
|
runTest('JS: media_spec_construction', testJS_mediaSpecConstruction);
|
|
5018
5393
|
|
|
5019
|
-
//
|
|
5020
|
-
console.log('\n---
|
|
5021
|
-
runTest('TEST320:
|
|
5022
|
-
runTest('TEST321:
|
|
5023
|
-
runTest('TEST322:
|
|
5024
|
-
runTest('TEST323:
|
|
5025
|
-
runTest('TEST324:
|
|
5026
|
-
runTest('TEST325:
|
|
5027
|
-
runTest('TEST326:
|
|
5028
|
-
runTest('TEST327:
|
|
5029
|
-
runTest('TEST328:
|
|
5030
|
-
runTest('TEST329:
|
|
5031
|
-
runTest('TEST330:
|
|
5032
|
-
runTest('TEST331:
|
|
5033
|
-
runTest('TEST332:
|
|
5034
|
-
runTest('TEST333:
|
|
5035
|
-
runTest('TEST334:
|
|
5036
|
-
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);
|
|
5037
5412
|
|
|
5038
5413
|
// media_urn.rs: TEST546-TEST558 (MediaUrn predicates)
|
|
5039
5414
|
console.log('\n--- media_urn.rs (predicates) ---');
|
|
@@ -5229,12 +5604,24 @@ async function runTests() {
|
|
|
5229
5604
|
runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
|
|
5230
5605
|
runTest('RENDERER: buildRun_backboneHasNoForeachNode', testRenderer_buildRunGraphData_backboneHasNoForeachNode);
|
|
5231
5606
|
runTest('RENDERER: buildRun_allFailedDropsPlaceholder', testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder);
|
|
5232
|
-
runTest('RENDERER:
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
runTest('RENDERER:
|
|
5237
|
-
runTest('RENDERER:
|
|
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);
|
|
5238
5625
|
|
|
5239
5626
|
// Summary
|
|
5240
5627
|
console.log(`\n${passCount + failCount} tests: ${passCount} passed, ${failCount} failed`);
|