1716 lines
115 KiB
JavaScript
1716 lines
115 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import { readFileSync } from "node:fs";
|
||
|
||
import { hasNodePresentationPolicy, REGIONAL_LORA_CONSUMER_NODE_TYPES, resolveNodePresentation } from "../ui/src/regional/nodePresentation.ts";
|
||
import { applyClassicNodePresentation, applyClassicSubgraphLayout, removeNodePresentation } from "../ui/src/regional/classicNodePresentation.ts";
|
||
import { setLegacyDebugVisible } from "../ui/src/regional/legacyPorts.ts";
|
||
import { scheduleCompactLoraConsumerNode } from "../ui/src/regional/loraV3Graph.ts";
|
||
import { markProjectedProvider, setProjectedSlotLabel, scheduleProjectedPortLayout } from "../ui/src/regional/portProjection.ts";
|
||
import { configureNodes2NodePresentation, installNodes2NodePresentation, projectNodes2NodePresentation, removeNodes2NodePresentation } from "../ui/src/regional/nodes2NodePresentation.ts";
|
||
import { installLutNodePresentation } from "../ui/src/regional/lutNodePresentation.ts";
|
||
import { installNodePresentationLifecycle } from "../ui/src/regional/nodePresentationLifecycle.ts";
|
||
import { lutLibrary } from "../ui/src/regional/lutLibrary.ts";
|
||
import { installProjectedPortInteraction, installProjectedPortCanvasInteraction } from "../ui/src/regional/projectedPortInteraction.ts";
|
||
import { configurePresentationSizeLifecycle, installPresentationSizeLifecycle, isPresentationUserResizing, presentationSize, removePresentationSizeLifecycle, setAutomaticPresentationSize } from "../ui/src/regional/presentationSize.ts";
|
||
|
||
const indexSource=readFileSync(new URL("../ui/src/index.tsx",import.meta.url),"utf8");
|
||
|
||
test("explicit provider labels stay stable across Classic refresh and DG reconciliation",()=>{
|
||
for(const direction of ['input','output'])for(const legacyDebug of [false,true]){
|
||
const provider={name:'resource_provider',type:'BV_RUNTIME_RESOURCE_PROVIDER',link:null,links:[]};
|
||
const normal={name:'alpha',type:'STRING',label:'Normal label',localized_name:'Normal localized',link:null,links:[]};
|
||
const node={id:990,size:[320,120],properties:{},inputs:direction==='input'?[normal,provider]:[],outputs:direction==='output'?[normal,provider]:[],widgets:[],computeSize(){return[320,120]},setSize(size){this.size=[...size]}};
|
||
const kind=direction==='input'?'BV Regional Detailer Plan':'BV LoRA Registry';
|
||
try{
|
||
markProjectedProvider(provider);setProjectedSlotLabel(provider,'DG');
|
||
applyClassicNodePresentation(node,kind,{legacyDebug});
|
||
const captured=JSON.stringify([node.inputs,node.outputs]);
|
||
for(let i=0;i<4;i++){
|
||
markProjectedProvider(provider);
|
||
assert.equal(JSON.stringify([node.inputs,node.outputs]),captured,'mark alone must not briefly clear the label');
|
||
setProjectedSlotLabel(provider,'DG');
|
||
assert.equal(JSON.stringify([node.inputs,node.outputs]),captured,'poll must not create a serialized label-only history change');
|
||
applyClassicNodePresentation(node,kind,{legacyDebug});
|
||
assert.equal(JSON.stringify([node.inputs,node.outputs]),captured,'Classic must preserve explicit presentation label');
|
||
}
|
||
assert.equal(provider.name,'resource_provider');
|
||
assert.equal(provider.label,'DG');assert.equal(provider.localized_name,'DG');
|
||
assert.equal(normal.label,'Normal label');assert.equal(normal.localized_name,'Normal localized');
|
||
const replacement={name:'resource_provider',type:'BV_RUNTIME_RESOURCE_PROVIDER',label:'previous native label'};
|
||
markProjectedProvider(replacement);
|
||
assert.equal(replacement.label,'','override belongs to the slot object, not its name or index');
|
||
setProjectedSlotLabel(replacement,'DG');markProjectedProvider(replacement);
|
||
assert.equal(replacement.label,'DG','marking also respects explicit labels');
|
||
setProjectedSlotLabel(replacement,'');markProjectedProvider(replacement);
|
||
assert.equal(replacement.label,'','explicit empty override remains supported');
|
||
}finally{removeNodePresentation(node)}
|
||
}
|
||
});
|
||
|
||
test("production registration excludes throwaway test adapters",()=>{
|
||
assert.doesNotMatch(indexSource,/installDgCanaryPrototype|installM0ResourceSpike|bv-dg-canary-received/);
|
||
});
|
||
|
||
test("DG subgraph hosts explicitly enroll in the shared Vue presentation lifecycle",()=>{
|
||
const source=readFileSync(new URL('../ui/src/regional/dgRouting.ts',import.meta.url),'utf8');
|
||
assert.match(source,/if\(node\?\.subgraph\)installNodes2NodePresentation\(node,String\(node.type/);
|
||
});
|
||
|
||
test("Nodes 2.0 captures only opted-in DG row pointer actions and releases reused rows",()=>{
|
||
for(const direction of ['input','output'])for(const legacyDebug of [false,true]){
|
||
const row=nodes2Row('DG'),listeners=new Map();
|
||
row.addEventListener=(name,fn,capture)=>{assert.equal(capture,true);listeners.set(name,fn)};
|
||
row.removeEventListener=(name,fn)=>{if(listeners.get(name)===fn)listeners.delete(name)};
|
||
const dispatch=()=>{let blocked=false;listeners.get('pointerdown')?.({preventDefault(){blocked=true},stopImmediatePropagation(){}});return blocked};
|
||
const provider={name:'resource_provider',type:'BV_RUNTIME_RESOURCE_PROVIDER',link:17,links:[17]};
|
||
const node={id:440,inputs:direction==='input'?[provider]:[],outputs:direction==='output'?[provider]:[],widgets:[]};
|
||
const element={dataset:{},querySelectorAll:s=>s===`.lg-slot--${direction}`?[row]:[]};
|
||
const doc={querySelector:()=>element},kind='BV Regional Detailer Plan';
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
assert.equal(dispatch(),false,'unregistered provider remains native');
|
||
installProjectedPortInteraction(node,s=>s?.type==='BV_RUNTIME_RESOURCE_PROVIDER');
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
assert.equal(dispatch(),true,'DG cannot reach native Vue pointer handler');
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
assert.equal(listeners.size,4,'repeated presentation does not stack handlers');
|
||
node[direction==='input'?'inputs':'outputs'][0]={name:'normal',type:'STRING'};
|
||
assert.equal(dispatch(),false,'event resolves current slot, not stale captured provider');
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
assert.equal(listeners.size,0,'row reuse releases DG capture');
|
||
const field=direction==='input'?'inputs':'outputs';
|
||
node[field]=[{name:'widget',type:'STRING'},provider];
|
||
row.querySelector=()=>({dataset:{slotKey:`440-${direction==='input'?'in':'out'}-1`}});
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
assert.equal(dispatch(),true,'filtered row uses canonical data-slot-key');
|
||
const unguarded={...node,id:441};
|
||
projectNodes2NodePresentation(unguarded,kind,doc,{legacyDebug});
|
||
assert.equal(listeners.size,0,'DOM reuse for unguarded node releases capture');
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
installProjectedPortInteraction(unguarded,s=>s?.type==='BV_RUNTIME_RESOURCE_PROVIDER');
|
||
projectNodes2NodePresentation(unguarded,kind,doc,{legacyDebug});
|
||
removeNodes2NodePresentation(node);
|
||
assert.equal(dispatch(),true,'old owner cleanup cannot release rebound row');
|
||
removeNodes2NodePresentation(unguarded);
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
removeNodes2NodePresentation(node);
|
||
assert.equal(listeners.size,0,'explicit removal releases handlers');
|
||
projectNodes2NodePresentation(node,kind,doc,{legacyDebug});
|
||
projectNodes2NodePresentation(node,kind,{querySelector:()=>null},{legacyDebug});
|
||
assert.equal(listeners.size,0,'unmounted renderer releases handlers');
|
||
}
|
||
});
|
||
const lutV3CatalogSource=readFileSync(new URL("../ui/src/regional/lutV3Catalog.ts",import.meta.url),"utf8");
|
||
|
||
const regionalPromptInventory = {
|
||
ports: [
|
||
{ direction: "output", name: "regional", connected: false },
|
||
{ direction: "output", name: "lora_bindings", connected: false },
|
||
{ direction: "input", name: "resource_provider_1", connected: false },
|
||
{ direction: "input", name: "canvas_image", type: "IMAGE", connected: true },
|
||
],
|
||
widgets: [
|
||
{ name: "regional_json" },
|
||
{ name: "lora_bindings_json" },
|
||
{ name: "lora_v3_config_json" },
|
||
{ name: "detailer_v3_config_json" },
|
||
{ name: "lut_v3_config_json" },
|
||
],
|
||
};
|
||
|
||
const regionalLoraInventory = {
|
||
ports: [
|
||
{ direction: "input", name: "regional", connected: false },
|
||
{ direction: "output", name: "regional", connected: false },
|
||
{ direction: "input", name: "resource_provider_1", connected: false },
|
||
],
|
||
widgets: [{ name: "operation" }, { name: "config_json" }],
|
||
};
|
||
|
||
const smartPipeMergeInventory = {
|
||
ports: [
|
||
{ direction: "input", name: "pipe_001", connected: false },
|
||
{ direction: "input", name: "pipe_002", connected: false },
|
||
{ direction: "input", name: "bv_add_pipe_source", connected: false },
|
||
{ direction: "output", name: "pipe", connected: false },
|
||
],
|
||
widgets: [{ name: "bv_smart_pipe_merge_json" }, { name: "Configure Merge" }],
|
||
};
|
||
|
||
const controlCenterInventory={
|
||
ports:[],
|
||
widgets:[{name:"bv_control_config_json"},{name:"configure_control_center"},{name:"bv_control_conflict_status"}],
|
||
};
|
||
|
||
test("real registry and consumer use the central provider presentation contract",()=>{
|
||
const inventory={
|
||
ports:[
|
||
{direction:"input",name:"alpha",type:"BV_RUNTIME_RESOURCE_PROVIDER",connected:true},
|
||
{direction:"output",name:"beta",type:"BV_RUNTIME_RESOURCE_PROVIDER",connected:true},
|
||
],
|
||
widgets:[],
|
||
};
|
||
for(const nodeType of["BV LoRA Registry","BV Regional Detailer Plan"]){
|
||
assert.equal(hasNodePresentationPolicy(nodeType),true);
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
for(const legacyDebug of[false,true]){
|
||
const plan=resolveNodePresentation(nodeType,inventory,{surface,legacyDebug});
|
||
assert.ok(plan.ports.every(({role,visible})=>role==="provider"&&!visible));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
test("Control Center shares one technical-state and action contract on every surface",()=>{
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const plan=resolveNodePresentation("BV Control Center",controlCenterInventory,{surface,legacyDebug:false});
|
||
assert.deepEqual(plan.widgets.map(({name,role,visible})=>[name,role,visible]),[
|
||
["bv_control_config_json","internalState",false],
|
||
["configure_control_center","nativeAction",surface!=="ghost"],
|
||
["bv_control_conflict_status","public",true],
|
||
]);
|
||
assert.deepEqual(plan.actions,["Configure Control Center"]);
|
||
}
|
||
});
|
||
|
||
test("Classic measurement excludes hidden technical widgets instead of retaining their layout history",()=>{
|
||
const sizes=[];
|
||
const node={size:[320,220],properties:{},inputs:[],outputs:[],widgets:[
|
||
{name:"bv_control_config_json",hidden:false,computeSize:()=>[0,80],y:70},
|
||
{name:"configure_control_center",type:"button",computeSize:()=>[0,20]},
|
||
{name:"bv_control_conflict_status",type:"custom",computeSize:()=>[0,24]},
|
||
],computeSize(){return[280,40+this.widgets.reduce((height,widget)=>height+Number(widget.computeSize?.()[1]??20),0)]},setSize(size){sizes.push([...size]);this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
applyClassicNodePresentation(node,"BV Control Center",{minWidth:320,compactWidgetOnly:true});
|
||
assert.deepEqual(node.size,[320,104]);
|
||
assert.equal(sizes.length,1);
|
||
assert.equal(node.widgets[0].hidden,true);
|
||
assert.equal(node.widgets[0].y,0);
|
||
assert.equal(node.widgets[0].last_y,0);
|
||
assert.equal(node.widgets_start_y,20);
|
||
});
|
||
|
||
test("Classic subgraph layout excludes promoted widget inputs and preserves manual width",()=>{
|
||
const widgetInput={name:"Header"},realInput={name:"image"},proxy={name:"Header",computeSize:()=>[0,34]};
|
||
const node={size:[420,300],inputs:[widgetInput,realInput],outputs:[],widgets:[proxy],getWidgetFromSlot(input){return input===widgetInput?proxy:null},computeSize(){return[240,10+this.inputs.length*20+this.widgets.reduce((sum,widget)=>sum+widget.computeSize()[1],0)]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
assert.deepEqual(applyClassicSubgraphLayout(node),[420,64]);
|
||
assert.equal(node.widgets_start_y,8);
|
||
});
|
||
|
||
const nativeConditioningInventory={
|
||
ports:[
|
||
{direction:"input",name:"regional",connected:false},
|
||
{direction:"input",name:"clip",connected:false},
|
||
{direction:"input",name:"lora_registry",connected:false},
|
||
{direction:"input",name:"lora_bindings",connected:false},
|
||
{direction:"output",name:"positive",connected:false},
|
||
{direction:"output",name:"negative",connected:false},
|
||
],
|
||
widgets:[
|
||
{name:"region_strength_multiplier"},
|
||
{name:"native_composition"},
|
||
{name:"hybrid_blend_ratio"},
|
||
],
|
||
};
|
||
|
||
const detailerPlanInventory={
|
||
ports:[
|
||
{direction:"input",name:"regional_prompt",connected:false},
|
||
{direction:"input",name:"detector_registry",connected:false},
|
||
{direction:"input",name:"resource_provider",connected:false},
|
||
{direction:"input",name:"resource_provider_1",connected:true},
|
||
{direction:"output",name:"detailer_plan",connected:false},
|
||
{direction:"output",name:"job_count",connected:false},
|
||
{direction:"output",name:"plan_summary",connected:false},
|
||
],
|
||
widgets:[{name:"config_json"},{name:"configure_detailer_plan"}],
|
||
};
|
||
|
||
const hundredPipeInventory=(internal=false)=>({
|
||
ports:[
|
||
{direction:"input",name:"pipe",connected:false},
|
||
...Array.from({length:100},(_,index)=>({direction:"input",name:`v_${String(index+1).padStart(3,"0")}`,connected:false})),
|
||
{direction:"output",name:"pipe",connected:false},
|
||
...Array.from({length:100},(_,index)=>({direction:"output",name:`out_${String(index+1).padStart(3,"0")}`,connected:false})),
|
||
],
|
||
widgets:internal?[{name:"bv_smart_pipe_schema_json"},{name:"bv_smart_pipe_route_json"},{name:"Pipe predecessor"},{name:"Configure Smart Pipe"}]:[],
|
||
});
|
||
|
||
test("Smart Pipe and deprecated Pipe share one hundred-slot Ghost reserve contract",()=>{
|
||
for(const[nodeType,internal]of[["BV Smart Pipe",true],["BV Pipe",false]]){
|
||
const inventory=hundredPipeInventory(internal);
|
||
const ghost=resolveNodePresentation(nodeType,inventory,{surface:"ghost",legacyDebug:false});
|
||
assert.equal(ghost.ports.filter(({role})=>role==="dynamicReserve").length,200);
|
||
assert.ok(ghost.ports.filter(({role})=>role==="dynamicReserve").every(({visible})=>!visible));
|
||
assert.deepEqual(ghost.ports.filter(({role})=>role==="public").map(({name,visible})=>[name,visible]),[["pipe",true],["pipe",true]]);
|
||
for(const surface of["classic","nodes2"]){
|
||
const placed=resolveNodePresentation(nodeType,inventory,{surface,legacyDebug:false});
|
||
assert.ok(placed.ports.every(({visible})=>visible));
|
||
}
|
||
}
|
||
const smartGhost=resolveNodePresentation("BV Smart Pipe",hundredPipeInventory(true),{surface:"ghost",legacyDebug:false});
|
||
assert.deepEqual(smartGhost.widgets.map(({name,role,visible})=>[name,role,visible]),[
|
||
["bv_smart_pipe_schema_json","internalState",false],["bv_smart_pipe_route_json","internalState",false],
|
||
["Pipe predecessor","public",true],["Configure Smart Pipe","public",true],
|
||
]);
|
||
assert.deepEqual(smartGhost.actions,["Configure Smart Pipe"]);
|
||
});
|
||
|
||
test("all Regional LoRA consumers share one legacy-input contract on every surface",()=>{
|
||
assert.equal(REGIONAL_LORA_CONSUMER_NODE_TYPES.length,6);
|
||
for(const nodeType of REGIONAL_LORA_CONSUMER_NODE_TYPES){
|
||
assert.equal(hasNodePresentationPolicy(nodeType),true,nodeType);
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const normal=resolveNodePresentation(nodeType,nativeConditioningInventory,{surface,legacyDebug:false});
|
||
const debug=resolveNodePresentation(nodeType,nativeConditioningInventory,{surface,legacyDebug:true});
|
||
assert.deepEqual(normal.ports.map(({name,role,visible})=>[name,role,visible]),[
|
||
["regional","public",true],["clip","public",true],
|
||
["lora_registry","legacy",false],["lora_bindings","legacy",false],
|
||
["positive","public",true],["negative","public",true],
|
||
]);
|
||
assert.equal(debug.ports.find(({name})=>name==="lora_registry")?.visible,true);
|
||
assert.equal(debug.ports.find(({name})=>name==="lora_bindings")?.visible,true);
|
||
assert.ok(normal.widgets.every(({role,visible})=>role==="public"&&visible));
|
||
assert.ok(debug.widgets.every(({role,visible})=>role==="public"&&visible));
|
||
}
|
||
}
|
||
});
|
||
|
||
test("Regional Detailer Plan separates legacy, provider, internal state and public UI on every surface",()=>{
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const normal=resolveNodePresentation("BV Regional Detailer Plan",detailerPlanInventory,{surface,legacyDebug:false});
|
||
const debug=resolveNodePresentation("BV Regional Detailer Plan",detailerPlanInventory,{surface,legacyDebug:true});
|
||
assert.deepEqual(normal.ports.map(({name,role,visible})=>[name,role,visible]),[
|
||
["regional_prompt","public",true],["detector_registry","legacy",false],
|
||
["resource_provider","provider",false],["resource_provider_1","provider",false],
|
||
["detailer_plan","public",true],["job_count","public",true],["plan_summary","public",true],
|
||
]);
|
||
assert.equal(debug.ports.find(({name})=>name==="detector_registry")?.visible,true);
|
||
assert.ok(debug.ports.filter(({role})=>role==="provider").every(({visible})=>!visible));
|
||
assert.deepEqual(normal.widgets.map(({name,role,visible})=>[name,role,visible]),[
|
||
["config_json","internalState",false],["configure_detailer_plan","public",true],
|
||
]);
|
||
assert.deepEqual(debug.widgets,normal.widgets);
|
||
}
|
||
});
|
||
|
||
test("LUT Plan and Loop Start hide all forty provider ports independently of debug mode",()=>{
|
||
const providers=Array.from({length:40},(_,index)=>({direction:"input",name:`resource_provider_${index+1}`,connected:index===0}));
|
||
const fixtures=[
|
||
["BV Regional LUT Plan",{ports:[{direction:"input",name:"regional_prompt",connected:false},...providers,{direction:"output",name:"lut_plan",connected:false},{direction:"output",name:"job_count",connected:false},{direction:"output",name:"plan_summary",connected:false}],widgets:[{name:"config_json"},{name:"configure_lut_plan"}]}],
|
||
["BV LUT Loop Start",{ports:[{direction:"input",name:"plan",connected:false},{direction:"input",name:"initial_image",connected:false},...providers,{direction:"output",name:"flow",connected:false},{direction:"output",name:"loop_state",connected:false}],widgets:[]}],
|
||
];
|
||
for(const[nodeType,inventory]of fixtures)for(const surface of["classic","ghost","nodes2"]){
|
||
const normal=resolveNodePresentation(nodeType,inventory,{surface,legacyDebug:false});
|
||
const debug=resolveNodePresentation(nodeType,inventory,{surface,legacyDebug:true});
|
||
assert.equal(normal.ports.filter(({role})=>role==="provider").length,40);
|
||
assert.ok(normal.ports.filter(({role})=>role==="provider").every(({visible})=>!visible));
|
||
assert.deepEqual(debug,normal);
|
||
assert.ok(normal.ports.filter(({role})=>role==="public").every(({visible})=>visible));
|
||
}
|
||
});
|
||
|
||
test("LUT Registry and manual LoRA chain apply direction-aware provider presentation on every surface",()=>{
|
||
const fixtures=[
|
||
["BV LUT Registry",{ports:[{direction:"output",name:"lut_count",connected:false},{direction:"output",name:"registry_summary",connected:false},{direction:"output",name:"resource_provider",connected:true}],widgets:[{name:"config_json"},{name:"configure_lut_registry"}]}],
|
||
["BV LoRA Stack Collector",{ports:[{direction:"input",name:"resource_provider",connected:false},{direction:"output",name:"resource_provider",connected:true}],widgets:[{name:"collector_id"}]}],
|
||
];
|
||
for(const[nodeType,inventory]of fixtures)for(const surface of["classic","ghost","nodes2"]){
|
||
const normal=resolveNodePresentation(nodeType,inventory,{surface,legacyDebug:false});
|
||
const debug=resolveNodePresentation(nodeType,inventory,{surface,legacyDebug:true});
|
||
assert.deepEqual(debug,normal);
|
||
assert.ok(normal.ports.filter(({role})=>role==="provider").every(({visible})=>!visible));
|
||
assert.ok(normal.ports.filter(({role})=>role==="public").every(({visible})=>visible));
|
||
assert.ok(normal.widgets.filter(({role})=>role==="internalState").every(({visible})=>!visible));
|
||
}
|
||
});
|
||
|
||
test("Classic first native action reserves exactly one host slot row",()=>{
|
||
const previousLiteGraph=globalThis.LiteGraph;
|
||
globalThis.LiteGraph={NODE_SLOT_HEIGHT:24};
|
||
const action={name:"configure_control_center",type:"button",computeSize:()=>[0,20]};
|
||
const node={size:[320,120],properties:{},inputs:[],outputs:[],widgets:[
|
||
{name:"bv_control_config_json",computeSize:()=>[0,80]},action,
|
||
{name:"bv_control_conflict_status",computeSize:()=>[0,24]},
|
||
],computeSize(){return[320,80]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
try{
|
||
applyClassicNodePresentation(node,"BV Control Center",{minWidth:320,compactWidgetOnly:true});
|
||
assert.equal(node.widgets_start_y,24);
|
||
}finally{removeNodePresentation(node);globalThis.LiteGraph=previousLiteGraph}
|
||
});
|
||
|
||
test("Classic first native action replaces a host-generated widget start and restores it on removal",()=>{
|
||
const action={name:"configure_control_center",type:"button",computeSize:()=>[0,20]};
|
||
const node={size:[320,260],widgets_start_y:240,properties:{},inputs:[],outputs:[],widgets:[
|
||
{name:"bv_control_config_json",computeSize:()=>[0,80]},action,
|
||
{name:"bv_control_conflict_status",computeSize:()=>[0,24]},
|
||
],computeSize(){return[320,260]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
applyClassicNodePresentation(node,"BV Control Center",{minWidth:320,compactWidgetOnly:true});
|
||
assert.equal(node.widgets_start_y,20);
|
||
removeNodePresentation(node);
|
||
assert.equal(node.widgets_start_y,240);
|
||
});
|
||
|
||
test("port-only nodes do not retain a widget start after technical providers are projected",()=>{
|
||
const providers=Array.from({length:40},(_,index)=>({name:`resource_provider_${index+1}`,type:"BV_RUNTIME_RESOURCE_PROVIDER",link:null}));
|
||
const inputs=[{name:"plan",type:"*",link:null},{name:"initial_image",type:"IMAGE",link:null},...providers];
|
||
const outputs=[{name:"flow",type:"FLOW_CONTROL",links:null},{name:"loop_state",type:"BV_LUT_LOOP_STATE",links:null}];
|
||
const beforeInputs=[...inputs],beforeOutputs=[...outputs];
|
||
const node={size:[260,80],properties:{},inputs,outputs,widgets:[],computeSize(){return[260,80]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
try{
|
||
applyClassicNodePresentation(node,"BV LUT Loop Start",{legacyDebug:false});
|
||
assert.equal(node.widgets_start_y,undefined);
|
||
assert.deepEqual(node.inputs,beforeInputs);
|
||
assert.deepEqual(node.outputs,beforeOutputs);
|
||
}finally{removeNodePresentation(node)}
|
||
});
|
||
|
||
test("Classic provider policy binds reusable titlebar anchors without serializing geometry",()=>{
|
||
const provider={name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",links:[17]},outputs=[{name:"lut_count",type:"INT",links:null},{name:"registry_summary",type:"STRING",links:null},provider],widgets=[{name:"config_json",computeSize:()=>[0,20]},{name:"configure_lut_registry",computeSize:()=>[0,20]}];
|
||
const node={size:[320,180],properties:{},inputs:[],outputs,widgets,computeSize(){return[280,40+Math.max(this.inputs.filter(slot=>!slot.hidden).length,this.outputs.filter(slot=>!slot.hidden).length)*20+this.widgets.filter(widget=>!widget.hidden).length*20]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
applyClassicNodePresentation(node,"BV LUT Registry",{legacyDebug:true});
|
||
assert.deepEqual(provider.pos,[node.size[0],-15]);assert.equal(Object.getOwnPropertyDescriptor(provider,"pos")?.enumerable,false);assert.equal(JSON.stringify(provider).includes('"pos"'),false);assert.equal(outputs[2],provider);assert.equal(node.widgets_start_y,46);
|
||
applyClassicNodePresentation(node,"BV LUT Registry",{legacyDebug:false});assert.equal(Object.hasOwn(provider,"pos"),false);assert.equal(node.widgets_start_y,46);
|
||
});
|
||
|
||
test("Classic sender layout excludes hidden provider outputs without changing canonical slots",()=>{
|
||
const fixtures=[
|
||
{type:"BV LUT Registry",inputs:[],outputs:["lut_count","registry_summary","resource_provider"],provider:2,widgets:["config_json","configure_lut_registry"],start:46},
|
||
{type:"BV LUT Registry",inputs:[],outputs:["lut_count","resource_provider","registry_summary"],provider:1,widgets:["config_json","configure_lut_registry"],start:46},
|
||
{type:"BV Detector Registry",inputs:[],outputs:["detector_count","registry_summary","resource_provider"],provider:2,widgets:["config_json","configure_detector_registry"],start:46},
|
||
{type:"BV LoRA Registry",inputs:[],outputs:["lora_count","registry_summary","resource_provider"],provider:2,widgets:["config_json","open_lora_registry"],start:46},
|
||
{type:"BV LoRA Stack Collector",inputs:["resource_provider"],outputs:["resource_provider"],provider:0,widgets:["collector_id"],start:undefined},
|
||
];
|
||
for(const fixture of fixtures){
|
||
const inputs=fixture.inputs.map(name=>({name,type:"BV_RUNTIME_RESOURCE_PROVIDER",link:null}));
|
||
const outputs=fixture.outputs.map((name,index)=>({name,type:index===fixture.provider?"BV_RUNTIME_RESOURCE_PROVIDER":"STRING",links:index===fixture.provider?[17]:null}));
|
||
const widgets=fixture.widgets.map(name=>({name,computeSize:()=>[0,20]}));
|
||
const beforeInputs=[...inputs],beforeOutputs=[...outputs],beforeInputLinks=inputs.map(slot=>slot.link),beforeOutputLinks=outputs.map(slot=>slot.links?[...slot.links]:slot.links),provider=outputs[fixture.provider];
|
||
const node={size:[320,180],properties:{},inputs,outputs,widgets,computeSize(){return[280,120]},serialize(){return{inputs:this.inputs.map(({name,type,link})=>({name,type,link})),outputs:this.outputs.map(({name,type,links})=>({name,type,links:links?[...links]:links}))}},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
const serialized=JSON.stringify(node.serialize());
|
||
try{
|
||
applyClassicNodePresentation(node,fixture.type,{legacyDebug:false});
|
||
assert.equal(node.widgets_start_y,fixture.start,fixture.type);
|
||
assert.ok(inputs.every((slot,index)=>slot===beforeInputs[index]),fixture.type);assert.ok(outputs.every((slot,index)=>slot===beforeOutputs[index]),fixture.type);assert.equal(outputs[fixture.provider],provider,fixture.type);
|
||
assert.equal(Object.hasOwn(provider,"pos"),false,fixture.type);assert.equal(JSON.stringify(provider).includes('"pos"'),false,fixture.type);
|
||
assert.equal(JSON.stringify(node.serialize()),serialized,`${fixture.type} normal serialization`);
|
||
applyClassicNodePresentation(node,fixture.type,{legacyDebug:true});
|
||
assert.equal(node.widgets_start_y,fixture.start,`${fixture.type} debug widget start`);assert.deepEqual(provider.pos,[node.size[0],-15],`${fixture.type} debug provider anchor`);
|
||
assert.equal(Object.hasOwn(inputs[0]??{},"pos"),false,`${fixture.type} public input remains in body`);
|
||
assert.deepEqual(inputs.map(slot=>slot.link),beforeInputLinks,`${fixture.type} debug input links`);assert.deepEqual(outputs.map(slot=>slot.links?[...slot.links]:slot.links),beforeOutputLinks,`${fixture.type} debug output links`);
|
||
assert.equal(JSON.stringify(node.serialize()),serialized,`${fixture.type} debug serialization`);
|
||
applyClassicNodePresentation(node,fixture.type,{legacyDebug:false});
|
||
assert.equal(node.widgets_start_y,fixture.start,`${fixture.type} restored widget start`);assert.equal(Object.hasOwn(provider,"pos"),false,`${fixture.type} restored provider anchor`);
|
||
assert.equal(JSON.stringify(node.serialize()),serialized,`${fixture.type} restored serialization`);
|
||
}finally{removeNodePresentation(node)}
|
||
assert.equal(Object.hasOwn(node,"widgets_start_y"),false,`${fixture.type} cleanup`);
|
||
}
|
||
});
|
||
|
||
test("Classic sender layout preserves an existing special widget start",()=>{
|
||
const provider={name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",links:[17]},node={size:[320,180],properties:{},widgets_start_y:77,inputs:[],outputs:[{name:"registry_summary",type:"STRING",links:null},provider],widgets:[{name:"config_json",computeSize:()=>[0,20]},{name:"configure_lut_registry",computeSize:()=>[0,20]}],computeSize(){return[280,120]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
try{applyClassicNodePresentation(node,"BV LUT Registry",{legacyDebug:false});assert.equal(node.widgets_start_y,77)}finally{removeNodePresentation(node)}
|
||
assert.equal(node.widgets_start_y,77);
|
||
});
|
||
|
||
test("Classic fresh sender owns LiteGraph's undefined widget start across a debug roundtrip",()=>{
|
||
const provider={name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",links:[17]};
|
||
const node={size:[320,180],properties:{},widgets_start_y:undefined,inputs:[],outputs:[{name:"lut_count",type:"INT",links:null},{name:"registry_summary",type:"STRING",links:null},provider],widgets:[{name:"config_json",computeSize:()=>[0,20]},{name:"configure_lut_registry",computeSize:()=>[0,20]}],computeSize(){return[280,120]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:null};
|
||
const graph={_nodes:[node],setDirtyCanvas(){}};node.graph=graph;
|
||
const hostWidgetStart=()=>node.widgets_start_y??((node.outputs.filter(slot=>!slot.pos).length+.2)*20+2);
|
||
setLegacyDebugVisible(true);
|
||
try{
|
||
applyClassicNodePresentation(node,"BV LUT Registry");
|
||
assert.equal(hostWidgetStart(),46,"debug titlebar projection masks the native undefined field");
|
||
setLegacyDebugVisible(false,graph);
|
||
assert.equal(hostWidgetStart(),46,"normal mode must not restore the hidden provider row");
|
||
}finally{setLegacyDebugVisible(false);removeNodePresentation(node)}
|
||
assert.equal(Object.hasOwn(node,"widgets_start_y"),true);assert.equal(node.widgets_start_y,undefined);
|
||
});
|
||
|
||
test("Nodes 2 transition suspends Classic anchors and central removal prevents re-entry",()=>{
|
||
const provider={name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",links:[17]},node={id:44,size:[320,180],properties:{},inputs:[],outputs:[{name:"lut_count",type:"INT",links:null},{name:"registry_summary",type:"STRING",links:null},provider],widgets:[{name:"config_json",computeSize:()=>[0,20]},{name:"configure_lut_registry",computeSize:()=>[0,20]}],computeSize(){return[280,120]},setSize(size){this.size=[...size];this.onResize?.(size)},setDirtyCanvas(){},graph:{setDirtyCanvas(){}},onRemoved(){this.nativeRemoved=(this.nativeRemoved??0)+1}};
|
||
applyClassicNodePresentation(node,"BV LUT Registry",{legacyDebug:true});assert.deepEqual(provider.pos,[node.size[0],-15]);
|
||
const element={dataset:{},querySelectorAll(){return[]}};assert.equal(projectNodes2NodePresentation(node,"BV LUT Registry",{querySelector:()=>element},{legacyDebug:true}),true);assert.equal(node.__bvNodes2PresentationActive,true);assert.equal(Object.hasOwn(provider,"pos"),false);
|
||
node.size=[360,180];node.onResize?.();assert.equal(Object.hasOwn(provider,"pos"),false);
|
||
node.__bvNodes2PresentationActive=false;applyClassicNodePresentation(node,"BV LUT Registry",{legacyDebug:true});assert.deepEqual(provider.pos,[node.size[0],-15]);node.onRemoved();assert.equal(node.nativeRemoved,1);assert.equal(Object.hasOwn(provider,"pos"),false);node.onResize?.();assert.equal(Object.hasOwn(provider,"pos"),false);
|
||
});
|
||
|
||
test("Named LoRA Stack keeps both V2 and manually wired V3 chain ports public",()=>{
|
||
const inventory={ports:[{direction:"input",name:"lora_stack",connected:false},{direction:"input",name:"registry",connected:false},{direction:"output",name:"registry",connected:false},{direction:"input",name:"resource_provider",connected:false},{direction:"output",name:"resource_provider",connected:false}],widgets:[{name:"name"},{name:"stack_id"}]};
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const plan=resolveNodePresentation("BV Named LoRA Stack",inventory,{surface,legacyDebug:false});
|
||
assert.ok(plan.ports.every(({role,visible})=>role==="public"&&visible));
|
||
assert.deepEqual(plan.widgets.map(({name,role,visible})=>[name,role,visible]),[["name","public",true],["stack_id","internalState",false]]);
|
||
}
|
||
});
|
||
|
||
test("LoRA Registry exposes active-count diagnostics, one semantic action and internal config state",()=>{
|
||
const inventory={ports:[{direction:"output",name:"resource_provider",connected:false},{direction:"output",name:"lora_count",connected:false},{direction:"output",name:"registry_summary",connected:false}],widgets:[{name:"config_json"},{name:"open_lora_registry"},{name:"lora_registry_widget"}]};
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const plan=resolveNodePresentation("BV LoRA Registry",inventory,{surface,legacyDebug:false});
|
||
assert.deepEqual(plan.actions,["Open LoRA Registry"]);
|
||
assert.deepEqual(plan.ports.map(port=>[port.name,port.visible]),[["resource_provider",false],["lora_count",true],["registry_summary",true]]);
|
||
assert.deepEqual(plan.widgets.map(widget=>[widget.name,widget.visible]),[["config_json",false],["open_lora_registry",surface!=="ghost"],["lora_registry_widget",true]]);
|
||
}
|
||
});
|
||
|
||
test("the manual Collector keeps its provider input connectable and hides only its final output",()=>{
|
||
const inventory={ports:[{direction:"input",name:"resource_provider",connected:false},{direction:"output",name:"resource_provider",connected:false}],widgets:[{name:"collector_id"}]};
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const plan=resolveNodePresentation("BV LoRA Stack Collector",inventory,{surface,legacyDebug:false});
|
||
assert.deepEqual(plan.ports.map(port=>[port.direction,port.role,port.visible]),[["input","public",true],["output","provider",false]]);
|
||
}
|
||
});
|
||
|
||
test("provider types are centrally projected unless a manual-chain policy keeps them public",()=>{
|
||
const provider={name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",connected:true};
|
||
for(const nodeType of["BV Regional Prompt","BV Regional LoRA","BV Regional LUT Plan"]){
|
||
for(const surface of["classic","nodes2"]){
|
||
const plan=resolveNodePresentation(nodeType,{ports:[{direction:"input",...provider}],widgets:[]},{surface,legacyDebug:true});
|
||
assert.deepEqual(plan.ports.map(port=>[port.role,port.visible]),[["provider",false]],`${nodeType} ${surface}`);
|
||
}
|
||
}
|
||
for(const nodeType of["BV Named LoRA Stack","BV LoRA Stack Collector"]){
|
||
const plan=resolveNodePresentation(nodeType,{ports:[{direction:"input",...provider}],widgets:[]},{surface:"nodes2",legacyDebug:true});
|
||
assert.deepEqual(plan.ports.map(port=>[port.role,port.visible]),[["public",true]],`${nodeType} manual input`);
|
||
}
|
||
});
|
||
|
||
test("Detector Registry hides external reserves only in Ghost and always hides provider state",()=>{
|
||
const inventory={
|
||
ports:[
|
||
...Array.from({length:10},(_,index)=>({direction:"input",name:`external_detector_${index+1}`,connected:index===0})),
|
||
{direction:"output",name:"detector_count",connected:false},{direction:"output",name:"registry_summary",connected:false},{direction:"output",name:"resource_provider",connected:true},
|
||
],
|
||
widgets:[{name:"config_json"},{name:"configure_detector_registry"}],
|
||
};
|
||
for(const legacyDebug of[false,true]){
|
||
const ghost=resolveNodePresentation("BV Detector Registry",inventory,{surface:"ghost",legacyDebug});
|
||
assert.ok(ghost.ports.filter(({role})=>role==="dynamicReserve").every(({visible})=>!visible));
|
||
assert.equal(ghost.ports.find(({role})=>role==="provider")?.visible,false);
|
||
assert.equal(ghost.widgets.find(({role})=>role==="internalState")?.visible,false);
|
||
for(const surface of["classic","nodes2"]){
|
||
const placed=resolveNodePresentation("BV Detector Registry",inventory,{surface,legacyDebug});
|
||
assert.ok(placed.ports.filter(({role})=>role==="dynamicReserve").every(({visible})=>visible));
|
||
assert.equal(placed.ports.find(({role})=>role==="provider")?.visible,false);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("Regional Native Conditioning Classic hides unused legacy rows and preserves debug access",()=>{
|
||
const sizes=[];
|
||
const node={size:[300,260],properties:{},inputs:nativeConditioningInventory.ports.filter(port=>port.direction==="input").map(port=>({name:port.name,link:null})),outputs:nativeConditioningInventory.ports.filter(port=>port.direction==="output").map(port=>({name:port.name,links:null})),widgets:nativeConditioningInventory.widgets.map(({name})=>({name,computeSize:()=>[0,20]})),computeSize(){return[280,180]},setSize(size){sizes.push([...size]);this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
applyClassicNodePresentation(node,"BV Regional Native Conditioning",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,180]);
|
||
assert.equal(sizes.length,1);
|
||
for(const name of["lora_registry","lora_bindings"]){const port=node.inputs.find(item=>item.name===name);assert.equal(port.hidden,true);assert.equal(port.__bvM0VisualHidden,true)}
|
||
applyClassicNodePresentation(node,"BV Regional Native Conditioning",{legacyDebug:true});
|
||
for(const name of["lora_registry","lora_bindings"])assert.equal(node.inputs.find(item=>item.name===name).__bvM0VisualHidden,false);
|
||
applyClassicNodePresentation(node,"BV Regional Native Conditioning",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,180]);
|
||
assert.equal(sizes.length,1);
|
||
});
|
||
|
||
test("Smart Pipe Merge presentation separates Ghost reserves from placed wiring",()=>{
|
||
for(const legacyDebug of[false,true]){
|
||
const ghost=resolveNodePresentation("BV Smart Pipe Merge",smartPipeMergeInventory,{surface:"ghost",legacyDebug});
|
||
assert.deepEqual(ghost.ports.map(({name,role,visible})=>[name,role,visible]),[
|
||
["pipe_001","dynamicReserve",false],
|
||
["pipe_002","dynamicReserve",false],
|
||
["bv_add_pipe_source","public",true],
|
||
["pipe","public",true],
|
||
]);
|
||
assert.deepEqual(ghost.widgets.map(({name,role,visible})=>[name,role,visible]),[
|
||
["bv_smart_pipe_merge_json","internalState",false],
|
||
["Configure Merge","public",true],
|
||
]);
|
||
assert.deepEqual(ghost.actions,["Configure Pipe Merge"]);
|
||
|
||
for(const surface of["classic","nodes2"]){
|
||
const placed=resolveNodePresentation("BV Smart Pipe Merge",smartPipeMergeInventory,{surface,legacyDebug});
|
||
assert.ok(placed.ports.every(({visible})=>visible));
|
||
assert.equal(placed.widgets[0].visible,false);
|
||
assert.equal(placed.widgets[1].visible,true);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("Regional LoRA presentation keeps only public wiring visible on every Classic debug state", () => {
|
||
for (const legacyDebug of [false, true]) {
|
||
const plan = resolveNodePresentation("BV Regional LoRA", regionalLoraInventory, {
|
||
surface: "classic",
|
||
legacyDebug,
|
||
});
|
||
assert.deepEqual(plan.ports.map(({ name, role, visible }) => [name, role, visible]), [
|
||
["regional", "public", true],
|
||
["regional", "public", true],
|
||
["resource_provider_1", "provider", false],
|
||
]);
|
||
assert.deepEqual(plan.widgets.map(({ name, role, visible }) => [name, role, visible]), [
|
||
["operation", "internalState", false],
|
||
["config_json", "internalState", false],
|
||
]);
|
||
assert.deepEqual(plan.actions, ["Open LoRA Editor"]);
|
||
}
|
||
});
|
||
|
||
test("Regional Prompt presentation distinguishes legacy ports from internal and provider state", () => {
|
||
const normal = resolveNodePresentation("BV Regional Prompt", regionalPromptInventory, {
|
||
surface: "classic",
|
||
legacyDebug: false,
|
||
});
|
||
assert.deepEqual(normal.ports.map(({ name, role, visible }) => [name, role, visible]), [
|
||
["regional", "public", true],
|
||
["lora_bindings", "legacy", false],
|
||
["resource_provider_1", "provider", false],
|
||
["canvas_image", "public", true],
|
||
]);
|
||
assert.deepEqual(normal.widgets.map(({ name, role, visible }) => [name, role, visible]), [
|
||
["regional_json", "internalState", false],
|
||
["lora_bindings_json", "internalState", false],
|
||
["lora_v3_config_json", "internalState", false],
|
||
["detailer_v3_config_json", "internalState", false],
|
||
["lut_v3_config_json", "internalState", false],
|
||
]);
|
||
assert.deepEqual(normal.actions, ["Open Regional Editor", "Quick Edit Prompts"]);
|
||
|
||
const debug = resolveNodePresentation("BV Regional Prompt", regionalPromptInventory, {
|
||
surface: "classic",
|
||
legacyDebug: true,
|
||
});
|
||
assert.equal(debug.ports.find(({ name }) => name === "lora_bindings")?.visible, true);
|
||
assert.equal(debug.ports.find(({ name }) => name === "resource_provider_1")?.visible, false);
|
||
assert.equal(debug.ports.find(({ name }) => name === "canvas_image")?.visible, true);
|
||
assert.ok(debug.widgets.every(({ visible }) => !visible));
|
||
|
||
const connectedLegacy = resolveNodePresentation("BV Regional Prompt", {
|
||
...regionalPromptInventory,
|
||
ports: [{ direction: "output", name: "lora_bindings", connected: true }],
|
||
}, { surface: "classic", legacyDebug: false });
|
||
assert.equal(connectedLegacy.ports[0].visible, true);
|
||
});
|
||
|
||
test("connected legacy ports remain visible without enabling global debug",()=>{
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const plan=resolveNodePresentation("BV Regional Native Conditioning",{
|
||
...nativeConditioningInventory,
|
||
ports:nativeConditioningInventory.ports.map(port=>port.name==="lora_bindings"?{...port,connected:true}:port),
|
||
},{surface,legacyDebug:false});
|
||
assert.equal(plan.ports.find(({name})=>name==="lora_registry")?.visible,false);
|
||
assert.equal(plan.ports.find(({name})=>name==="lora_bindings")?.visible,true);
|
||
}
|
||
});
|
||
|
||
test("Regional Prompt Ghost uses the same role matrix without exposing technical state",()=>{
|
||
const ghost=resolveNodePresentation("BV Regional Prompt",{
|
||
...regionalPromptInventory,
|
||
ports:[...regionalPromptInventory.ports,{direction:"input",name:"regional_json",connected:false}],
|
||
},{surface:"ghost",legacyDebug:true});
|
||
assert.equal(ghost.ports.find(({name})=>name==="lora_bindings")?.visible,true);
|
||
assert.equal(ghost.ports.find(({name})=>name==="resource_provider_1")?.visible,false);
|
||
assert.equal(ghost.ports.find(({name})=>name==="canvas_image")?.visible,true);
|
||
assert.deepEqual(ghost.ports.find(({name})=>name==="regional_json"),{
|
||
direction:"input",name:"regional_json",connected:false,role:"internalState",visible:false,
|
||
});
|
||
assert.ok(ghost.widgets.every(({visible})=>!visible));
|
||
});
|
||
|
||
test("Regional Prompt canvas image stays public across Classic Ghost and Nodes 2",()=>{
|
||
for(const surface of["classic","ghost","nodes2"]){
|
||
const plan=resolveNodePresentation("BV Regional Prompt",regionalPromptInventory,{surface,legacyDebug:false});
|
||
assert.deepEqual(plan.ports.find(({name})=>name==="canvas_image"),{
|
||
direction:"input",name:"canvas_image",type:"IMAGE",connected:true,role:"public",visible:true,
|
||
});
|
||
}
|
||
});
|
||
|
||
function regionalPromptNode(){
|
||
const sizes=[];
|
||
const node={
|
||
size:[300,260],
|
||
properties:{},
|
||
inputs:[
|
||
{name:"resource_provider_1",type:"BV_RUNTIME_RESOURCE_PROVIDER",link:null},
|
||
{name:"canvas_image",type:"IMAGE",link:81},
|
||
],
|
||
outputs:[{name:"regional",type:"BV_REGIONAL",links:null},{name:"lora_bindings",type:"BV_REGIONAL_LORA_BINDINGS",links:null}],
|
||
widgets:[
|
||
...["regional_json","lora_bindings_json","lora_v3_config_json","detailer_v3_config_json","lut_v3_config_json"].map(name=>({name,computeSize:()=>[0,20]})),
|
||
{name:"open_regional_editor",type:"button",computeSize:()=>[0,20]},
|
||
{name:"quick_edit_regional_prompts",type:"button",computeSize:()=>[0,20]},
|
||
],
|
||
computeSize(){
|
||
const ports=Math.max(this.inputs.filter(port=>!port.hidden).length,this.outputs.filter(port=>!port.hidden).length);
|
||
const widgets=this.widgets.reduce((height,widget)=>height+Math.max(0,Number(widget.computeSize?.()[1]??20)),0);
|
||
return[280,40+ports*20+widgets];
|
||
},
|
||
setSize(size){sizes.push([...size]);this.size=[...size]},
|
||
setDirtyCanvas(){},
|
||
graph:{setDirtyCanvas(){}},
|
||
};
|
||
return{node,sizes};
|
||
}
|
||
|
||
test("Regional Prompt Classic reconciliation is reversible and publishes one final height",()=>{
|
||
const{node,sizes}=regionalPromptNode();
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,100]);
|
||
assert.equal(sizes.length,1);
|
||
assert.equal(node.outputs.find(port=>port.name==="lora_bindings").hidden,true);
|
||
assert.equal(node.outputs.find(port=>port.name==="lora_bindings").__bvM0VisualHidden,true);
|
||
assert.equal(node.inputs[0].hidden,true);
|
||
assert.equal(node.inputs[1].hidden,false);
|
||
assert.equal(node.inputs[1].link,81);
|
||
assert.ok(node.widgets.slice(0,5).every(widget=>widget.hidden));
|
||
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:true});
|
||
assert.deepEqual(node.size,[300,120]);
|
||
assert.equal(sizes.length,2);
|
||
assert.equal(node.outputs.find(port=>port.name==="lora_bindings").hidden,false);
|
||
assert.equal(node.inputs[0].hidden,true);
|
||
assert.equal(node.inputs[1].hidden,false);
|
||
assert.equal(node.inputs[1].link,81);
|
||
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,100]);
|
||
assert.equal(sizes.length,3);
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
|
||
assert.equal(sizes.length,3);
|
||
assert.equal(node.properties.bvPresentationUserHeight,undefined);
|
||
});
|
||
|
||
test("Regional Prompt publishes a semantic Nodes 2 height even when host computeSize is stale",()=>{
|
||
const node={size:[420,330],inputs:[],outputs:[{name:"regional",links:null},{name:"lora_bindings",links:null}],widgets:[
|
||
...["regional_json","lora_bindings_json","lora_v3_config_json","detailer_v3_config_json","lut_v3_config_json"].map(name=>({name,computeSize:()=>[0,80]})),
|
||
{name:"open_regional_editor",computeSize:()=>[0,20]},{name:"quick_edit_regional_prompts",computeSize:()=>[0,20]},
|
||
],computeSize(){return[420,330]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
|
||
assert.equal(node.__bvPresentationAutoHeight,100);
|
||
assert.ok(node.widgets.slice(0,5).every(widget=>widget.options.hidden===true));
|
||
});
|
||
|
||
test("Regional LoRA Classic reconciliation keeps one stable height across debug toggles",()=>{
|
||
const sizes=[];
|
||
const node={
|
||
size:[300,220],
|
||
inputs:[
|
||
{name:"regional",link:null},
|
||
{name:"resource_provider_1",link:null},
|
||
],
|
||
outputs:[{name:"regional",links:null}],
|
||
widgets:[
|
||
{name:"operation",computeSize:()=>[0,20]},
|
||
{name:"config_json",computeSize:()=>[0,60]},
|
||
{name:"open_lora_editor",type:"button",computeSize:()=>[0,20]},
|
||
],
|
||
computeSize(){
|
||
const ports=Math.max(this.inputs.filter(port=>!port.hidden).length,this.outputs.filter(port=>!port.hidden).length);
|
||
const widgets=this.widgets.reduce((height,widget)=>height+Math.max(0,Number(widget.computeSize?.()[1]??20)),0);
|
||
return[280,40+ports*20+widgets];
|
||
},
|
||
setSize(size){sizes.push([...size]);this.size=[...size]},
|
||
setDirtyCanvas(){},
|
||
graph:{setDirtyCanvas(){}},
|
||
};
|
||
applyClassicNodePresentation(node,"BV Regional LoRA",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,80]);
|
||
assert.equal(sizes.length,1);
|
||
assert.deepEqual(node.inputs.map(port=>port.hidden),[false,true]);
|
||
assert.ok(node.widgets.slice(0,2).every(widget=>widget.hidden));
|
||
|
||
applyClassicNodePresentation(node,"BV Regional LoRA",{legacyDebug:true});
|
||
applyClassicNodePresentation(node,"BV Regional LoRA",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,80]);
|
||
assert.equal(sizes.length,1);
|
||
});
|
||
|
||
test("Smart Pipe Merge Classic uses one measured height and hides its real JSON widget",()=>{
|
||
const sizes=[];
|
||
const node={
|
||
size:[300,180],
|
||
inputs:[{name:"pipe_007",link:3},{name:"bv_add_pipe_source",link:null}],
|
||
outputs:[{name:"pipe",links:null}],
|
||
widgets:[
|
||
{name:"bv_smart_pipe_merge_json",type:"text",computeSize:()=>[0,60]},
|
||
{name:"Configure Merge",type:"button",computeSize:()=>[0,20]},
|
||
],
|
||
computeSize(){
|
||
const ports=Math.max(this.inputs.filter(port=>!port.hidden).length,this.outputs.filter(port=>!port.hidden).length);
|
||
const widgets=this.widgets.reduce((height,widget)=>height+Math.max(0,Number(widget.computeSize?.()[1]??20)),0);
|
||
return[280,40+ports*20+widgets];
|
||
},
|
||
setSize(size){sizes.push([...size]);this.size=[...size]},
|
||
setDirtyCanvas(){},graph:{setDirtyCanvas(){}},
|
||
};
|
||
applyClassicNodePresentation(node,"BV Smart Pipe Merge",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,100]);
|
||
assert.equal(node.widgets[0].hidden,true);
|
||
assert.equal(node.widgets[0].computeSize()[1],0);
|
||
assert.equal(node.widgets[1].hidden,false);
|
||
assert.equal(node.__bvPresentationHasLegacy,false);
|
||
applyClassicNodePresentation(node,"BV Smart Pipe Merge",{legacyDebug:true});
|
||
assert.deepEqual(node.size,[300,100]);
|
||
assert.equal(sizes.length,1);
|
||
});
|
||
|
||
test("Classic presentation re-exposes public widgets hidden by the Nodes 2 renderer",()=>{
|
||
const node={
|
||
size:[260,90],inputs:[{name:"pipe",link:null}],outputs:[{name:"pipe",links:null}],
|
||
widgets:[{name:"Pipe predecessor",type:"combo",hidden:true,options:{hidden:true},computeSize:()=>[0,20]}],
|
||
computeSize(){return[260,90]},setSize(size){this.size=size},setDirtyCanvas(){},graph:{setDirtyCanvas(){}},
|
||
};
|
||
applyClassicNodePresentation(node,"BV Smart Pipe");
|
||
assert.equal(node.widgets[0].hidden,false);
|
||
assert.equal(node.widgets[0].options.hidden,false);
|
||
});
|
||
|
||
test("legacy debug changes reconcile an installed presentation exactly once",()=>{
|
||
let reconciliations=0;
|
||
let legacyHiddenWrites=0;
|
||
const legacy={name:"lora_bindings",__bvLegacyPort:true,links:null,_hidden:true};
|
||
Object.defineProperty(legacy,"hidden",{get(){return this._hidden},set(value){legacyHiddenWrites++;this._hidden=value}});
|
||
const node={outputs:[legacy],__bvApplyPresentation(){reconciliations++}};
|
||
const graph={_nodes:[node],setDirtyCanvas(){}};
|
||
setLegacyDebugVisible(true,graph);
|
||
assert.equal(reconciliations,1);
|
||
assert.equal(legacyHiddenWrites,0);
|
||
setLegacyDebugVisible(false,graph);
|
||
assert.equal(reconciliations,2);
|
||
assert.equal(legacyHiddenWrites,0);
|
||
});
|
||
|
||
test("legacy debug does not reproject a managed node without legacy presentation roles",()=>{
|
||
let reconciliations=0,anchorRefreshes=0;
|
||
const node={
|
||
__bvPresentationHasLegacy:false,
|
||
__bvApplyPresentation(){reconciliations++},
|
||
__bvRefreshProviderAnchors(){anchorRefreshes++},
|
||
outputs:[{name:"regional",links:null}],
|
||
};
|
||
const graph={_nodes:[node],setDirtyCanvas(){}};
|
||
setLegacyDebugVisible(true,graph);
|
||
setLegacyDebugVisible(false,graph);
|
||
assert.equal(reconciliations,0);
|
||
assert.equal(anchorRefreshes,2);
|
||
});
|
||
|
||
function nodes2Row(text=""){
|
||
const properties=new Map(),priorities=new Map();
|
||
const style={display:"",visibility:"",
|
||
setProperty(name,value,priority=""){properties.set(name,String(value));priorities.set(name,String(priority))},
|
||
getPropertyValue(name){return properties.get(name)??""},
|
||
getPropertyPriority(name){return priorities.get(name)??""},
|
||
removeProperty(name){const value=properties.get(name)??"";properties.delete(name);priorities.delete(name);return value},
|
||
};
|
||
return{hidden:false,style,textContent:text,closest(){return null}};
|
||
}
|
||
|
||
function nodes2IndexedRow(text,slotKey){
|
||
const row=nodes2Row(text),slot={dataset:{slotKey},getAttribute(name){return name==="data-slot-key"?slotKey:null}};
|
||
row.querySelector=selector=>selector==="[data-slot-key]"?slot:null;
|
||
return row;
|
||
}
|
||
|
||
test("Nodes 2.0 removes internal rows and applies the same legacy visibility without stale space",()=>{
|
||
const sizes=[];
|
||
const widgetRows=[...[
|
||
"regional_json","lora_bindings_json","lora_v3_config_json","detailer_v3_config_json","lut_v3_config_json",
|
||
].map(nodes2Row),nodes2Row("Open Regional Editor"),nodes2Row("Quick Edit Prompts")];
|
||
const outputRows=[nodes2Row("regional"),nodes2Row("lora_bindings")];
|
||
const element={
|
||
dataset:{},
|
||
querySelectorAll(selector){
|
||
if(selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]')return widgetRows;
|
||
if(selector===".lg-slot--input")return[];
|
||
if(selector===".lg-slot--output")return outputRows;
|
||
return[];
|
||
},
|
||
};
|
||
const documentLike={querySelector:()=>element};
|
||
const node={id:1,size:[420,330],__bvPresentationAutoHeight:120,inputs:[],outputs:[{name:"regional"},{name:"lora_bindings"}],widgets:[
|
||
...["regional_json","lora_bindings_json","lora_v3_config_json","detailer_v3_config_json","lut_v3_config_json"].map(name=>({name})),
|
||
{name:"open_regional_editor"},{name:"quick_edit_regional_prompts"},
|
||
],computeSize(){return[280,120]},setSize(size){sizes.push([...size]);this.size=[...size]}};
|
||
|
||
assert.equal(projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:false}),true);
|
||
assert.deepEqual(widgetRows.map(row=>row.style.display),["none","none","none","none","none","",""]);
|
||
assert.deepEqual(outputRows.map(row=>[row.style.display,row.style.visibility]),[["",""],["","hidden"]]);
|
||
assert.deepEqual(sizes,[[420,120]]);
|
||
|
||
assert.equal(projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:true}),true);
|
||
assert.deepEqual(widgetRows.map(row=>row.style.display),["none","none","none","none","none","",""]);
|
||
assert.deepEqual(outputRows.map(row=>[row.style.display,row.style.visibility]),[["",""],["",""]]);
|
||
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
assert.deepEqual(outputRows.map(row=>[row.style.display,row.style.visibility]),[["",""],["","hidden"]]);
|
||
assert.deepEqual(sizes,[[420,120]]);
|
||
});
|
||
|
||
test("Nodes 2.0 projects provider anchors independently of their canonical slot order",()=>{
|
||
const positions=["first","middle","last"];
|
||
for(const direction of["input","output"]){
|
||
for(const position of positions){
|
||
const provider=direction==="input"
|
||
?{name:"resource_provider_1",type:"BV_RUNTIME_RESOURCE_PROVIDER",link:17}
|
||
:{name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",links:[17]};
|
||
const publicSlots=direction==="input"
|
||
?[{name:"regional_prompt",type:"BV_REGIONAL",link:null},{name:"mask",type:"MASK",link:null}]
|
||
:[{name:"lut_count",type:"INT",links:null},{name:"registry_summary",type:"STRING",links:null}];
|
||
const providerIndex=position==="first"?0:position==="middle"?1:2;
|
||
const slots=[...publicSlots];slots.splice(providerIndex,0,provider);
|
||
const inputRows=direction==="input"?slots.map(slot=>nodes2Row(slot.name)):[];
|
||
const outputRows=direction==="output"?slots.map(slot=>nodes2Row(slot.name)):[];
|
||
const element={dataset:{},querySelectorAll(selector){
|
||
if(selector===".lg-slot--input")return inputRows;
|
||
if(selector===".lg-slot--output")return outputRows;
|
||
return[];
|
||
}};
|
||
const node={id:`${direction}-${position}`,inputs:direction==="input"?slots:[],outputs:direction==="output"?slots:[],widgets:[]};
|
||
const nodeType=direction==="input"?"BV Regional LUT Plan":"BV LUT Registry";
|
||
const beforeSlots=[...slots],beforeJson=JSON.stringify({inputs:node.inputs,outputs:node.outputs,widgets:node.widgets});
|
||
|
||
projectNodes2NodePresentation(node,nodeType,{querySelector:()=>element},{legacyDebug:true});
|
||
const rows=direction==="input"?inputRows:outputRows,anchor=rows[providerIndex];
|
||
assert.equal(anchor.dataset.bvPresentationProviderAnchor,direction,`${direction} ${position} anchor direction`);
|
||
assert.equal(anchor.dataset.bvPresentationProviderVisible,"true",`${direction} ${position} debug visibility`);
|
||
assert.equal(anchor.hidden,false,`${direction} ${position} remains measurable`);
|
||
assert.equal(anchor.style.display,"",`${direction} ${position} does not reserve a body row`);
|
||
assert.deepEqual(rows.filter((_,index)=>index!==providerIndex).map(row=>row.style.display),["",""],`${direction} ${position} public rows`);
|
||
|
||
projectNodes2NodePresentation(node,nodeType,{querySelector:()=>element},{legacyDebug:false});
|
||
assert.equal(anchor.dataset.bvPresentationProviderVisible,"false",`${direction} ${position} normal visibility`);
|
||
assert.equal(anchor.hidden,false,`${direction} ${position} keeps a stable measured anchor`);
|
||
assert.equal(anchor.style.display,"",`${direction} ${position} remains outside body flow`);
|
||
assert.equal(JSON.stringify({inputs:node.inputs,outputs:node.outputs,widgets:node.widgets}),beforeJson,`${direction} ${position} graph serialization`);
|
||
assert.deepEqual(slots,beforeSlots,`${direction} ${position} canonical slot order`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("Nodes 2.0 maps filtered DOM rows back to canonical input indexes",()=>{
|
||
const fixtures=[
|
||
{
|
||
type:"BV Regional Prompt",id:"prompt-with-widget-inputs",
|
||
inputs:[
|
||
{name:"regions",widget:{}},{name:"background_prompt",widget:{}},{name:"negative_prompt",widget:{}},
|
||
{name:"width",widget:{}},{name:"height",widget:{}},{name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",link:17},
|
||
],
|
||
rows:[nodes2IndexedRow("resource_provider","prompt-with-widget-inputs-in-5")],providerRow:0,
|
||
},
|
||
{
|
||
type:"BV Regional LUT Plan",id:"lut-plan-filtered-inputs",
|
||
inputs:[
|
||
{name:"regional_prompt",type:"BV_REGIONAL",link:null},{name:"config",widget:{}},{name:"strength",widget:{}},
|
||
{name:"resource_provider_1",type:"BV_RUNTIME_RESOURCE_PROVIDER",link:17},
|
||
],
|
||
rows:[nodes2IndexedRow("regional_prompt","lut-plan-filtered-inputs-in-0"),nodes2IndexedRow("resource_provider_1","lut-plan-filtered-inputs-in-3")],providerRow:1,
|
||
},
|
||
];
|
||
for(const fixture of fixtures){
|
||
const element={dataset:{},querySelectorAll(selector){return selector===".lg-slot--input"?fixture.rows:[]}};
|
||
const node={id:fixture.id,inputs:fixture.inputs,outputs:[],widgets:[]};
|
||
projectNodes2NodePresentation(node,fixture.type,{querySelector:()=>element},{legacyDebug:true});
|
||
const provider=fixture.rows[fixture.providerRow];
|
||
assert.equal(provider.dataset.bvPresentationProviderAnchor,"input",`${fixture.type} canonical provider row`);
|
||
assert.equal(provider.dataset.bvPresentationProviderVisible,"true",`${fixture.type} provider debug visibility`);
|
||
assert.equal(fixture.rows[0].style.display,"",`${fixture.type} first rendered row remains mounted`);
|
||
}
|
||
});
|
||
|
||
test("Nodes 2.0 never overwrites an active or later manual user height",()=>{
|
||
const sizes=[];
|
||
let resizing=true;
|
||
configureNodes2NodePresentation({isUserResizing:()=>resizing});
|
||
const hiddenRow=nodes2Row("regional_json"),buttonRow=nodes2Row("Open Regional Editor");
|
||
const element={dataset:{},querySelectorAll(selector){if(selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]')return[hiddenRow,buttonRow];return[]}};
|
||
const node={id:99,size:[420,260],__bvPresentationAutoHeight:100,inputs:[],outputs:[],widgets:[{name:"regional_json"},{name:"open_regional_editor"}],setSize(size){sizes.push([...size]);this.size=[...size]}};
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",{querySelector:()=>element},{legacyDebug:false});
|
||
assert.deepEqual(sizes,[]);
|
||
resizing=false;
|
||
hiddenRow.hidden=false;hiddenRow.style.display="";
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",{querySelector:()=>element},{legacyDebug:false});
|
||
assert.deepEqual(sizes,[]);
|
||
assert.deepEqual(node.size,[420,260]);
|
||
configureNodes2NodePresentation({});
|
||
});
|
||
|
||
test("Classic legacy refresh cannot overwrite a mounted Nodes 2 manual height",()=>{
|
||
const{node,sizes}=regionalPromptNode();
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
|
||
node.__bvNodes2PresentationActive=true;
|
||
node.size=[420,260];
|
||
sizes.length=0;
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:true});
|
||
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
|
||
assert.deepEqual(sizes,[]);
|
||
assert.deepEqual(node.size,[420,260]);
|
||
});
|
||
|
||
test("a proven Classic user height survives debug and a Classic Nodes 2 Classic roundtrip",()=>{
|
||
let resizing=false;
|
||
configureNodes2NodePresentation({isUserResizing:()=>resizing});
|
||
const provider={name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",links:null};
|
||
const node={
|
||
id:131,size:[320,220],properties:{},inputs:[{name:"regional_prompt",link:null}],outputs:[
|
||
{name:"lut_plan",links:null},{name:"job_count",links:null},{name:"plan_summary",links:null},provider,
|
||
],widgets:[{name:"config_json",computeSize:()=>[0,20]},{name:"connect_regional_prompt",computeSize:()=>[0,20]}],
|
||
computeSize(){return[300,120]},setSize(size){this.size=[...size];this.onResize?.(size)},setDirtyCanvas(){},graph:{setDirtyCanvas(){}},
|
||
};
|
||
try{
|
||
applyClassicNodePresentation(node,"BV Regional LUT Plan",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[320,120]);
|
||
resizing=true;node.setSize([360,260]);resizing=false;
|
||
assert.equal(node.properties.bvPresentationUserHeight,260);
|
||
applyClassicNodePresentation(node,"BV Regional LUT Plan",{legacyDebug:true});
|
||
applyClassicNodePresentation(node,"BV Regional LUT Plan",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[360,260]);
|
||
const element={dataset:{},querySelectorAll(){return[]}};
|
||
projectNodes2NodePresentation(node,"BV Regional LUT Plan",{querySelector:()=>element},{legacyDebug:false});
|
||
node.__bvNodes2PresentationActive=false;
|
||
node.__bvApplyPresentation();
|
||
assert.deepEqual(node.size,[360,260]);
|
||
assert.deepEqual(JSON.parse(JSON.stringify(node.properties)),{bvPresentationSizeVersion:1,bvPresentationUserHeight:260});
|
||
}finally{configureNodes2NodePresentation({})}
|
||
});
|
||
|
||
test("automatic presentation sizes and unmarked oversize nodes do not create a user height",()=>{
|
||
const node={id:132,size:[320,520],properties:{},inputs:[],outputs:[],widgets:[],computeSize(){return[300,100]},setSize(size){this.size=[...size];this.onResize?.(size)},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
applyClassicNodePresentation(node,"BV LUT Registry",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[320,100]);
|
||
assert.equal(node.properties.bvPresentationUserHeight,undefined);
|
||
assert.equal(node.properties.bvPresentationSizeVersion,undefined);
|
||
});
|
||
|
||
test("a proven Nodes 2 user height is reused by Classic without freezing width",()=>{
|
||
let resizing=false;
|
||
configureNodes2NodePresentation({isUserResizing:()=>resizing});
|
||
const node={id:133,size:[320,180],properties:{},inputs:[],outputs:[],widgets:[],computeSize(){return[300,100]},setSize(size){this.size=[...size];this.onResize?.(size)},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
const element={dataset:{},querySelectorAll(){return[]}};
|
||
try{
|
||
applyClassicNodePresentation(node,"BV LUT Registry",{legacyDebug:false});
|
||
projectNodes2NodePresentation(node,"BV LUT Registry",{querySelector:()=>element},{legacyDebug:false});
|
||
resizing=true;node.setSize([410,280]);resizing=false;
|
||
assert.equal(node.properties.bvPresentationUserHeight,280);
|
||
node.__bvNodes2PresentationActive=false;node.__bvApplyPresentation();
|
||
assert.deepEqual(node.size,[410,280]);
|
||
node.size[0]=460;node.__bvApplyPresentation();
|
||
assert.deepEqual(node.size,[460,280]);
|
||
}finally{configureNodes2NodePresentation({})}
|
||
});
|
||
|
||
test("the central size tracker is idempotent, guarded and exactly removable",()=>{
|
||
let resizing=true,nativeCalls=0;
|
||
configurePresentationSizeLifecycle({isUserResizing:()=>resizing});
|
||
const node={size:[300,120],properties:{},onResize(){nativeCalls++},setSize(size){this.size=[...size];this.onResize?.(size)}};
|
||
try{
|
||
const nativeSetSize=node.setSize;
|
||
assert.equal(installPresentationSizeLifecycle(node),true);const wrapper=node.setSize;
|
||
assert.notEqual(wrapper,nativeSetSize);
|
||
assert.equal(installPresentationSizeLifecycle(node),false);assert.equal(node.setSize,wrapper);
|
||
node.setSize([330,240]);assert.equal(nativeCalls,1);assert.equal(node.properties.bvPresentationUserHeight,240);
|
||
resizing=false;setAutomaticPresentationSize(node,[360,100]);
|
||
assert.equal(nativeCalls,2);assert.equal(node.properties.bvPresentationUserHeight,240);assert.deepEqual(node.size,[360,100]);
|
||
assert.equal(removePresentationSizeLifecycle(node),true);assert.equal(removePresentationSizeLifecycle(node),false);
|
||
resizing=true;node.setSize([390,310]);assert.equal(nativeCalls,3);assert.equal(node.properties.bvPresentationUserHeight,240);
|
||
assert.equal(installPresentationSizeLifecycle(node),true);node.setSize([420,280]);
|
||
assert.equal(nativeCalls,4);assert.equal(node.properties.bvPresentationUserHeight,280);
|
||
}finally{configurePresentationSizeLifecycle({});removePresentationSizeLifecycle(node)}
|
||
});
|
||
|
||
test("a later foreign resize wrapper remains installed while a removed tracker becomes inert",()=>{
|
||
let resizing=true,nativeCalls=0,foreignCalls=0;
|
||
configurePresentationSizeLifecycle({isUserResizing:()=>resizing});
|
||
const node={size:[300,120],properties:{},onResize(){nativeCalls++},setSize(size){this.size=[...size];this.onResize?.(size)}};
|
||
try{
|
||
installPresentationSizeLifecycle(node);const tracked=node.setSize;
|
||
node.setSize=function(){foreignCalls++;return tracked.apply(this,arguments)};
|
||
assert.equal(removePresentationSizeLifecycle(node),true);
|
||
node.setSize([320,210]);assert.equal(node.properties.bvPresentationUserHeight,undefined);
|
||
assert.deepEqual([nativeCalls,foreignCalls],[1,1]);
|
||
installPresentationSizeLifecycle(node);node.setSize([340,230]);
|
||
assert.equal(node.properties.bvPresentationUserHeight,230);assert.deepEqual([nativeCalls,foreignCalls],[2,2]);
|
||
}finally{configurePresentationSizeLifecycle({});removePresentationSizeLifecycle(node)}
|
||
});
|
||
|
||
test("the central size tracker restores an inherited native setSize exactly",()=>{
|
||
const prototype={setSize(size){this.size=[...size]}};
|
||
const node=Object.assign(Object.create(prototype),{size:[300,120],properties:{}});
|
||
assert.equal(Object.hasOwn(node,"setSize"),false);
|
||
assert.equal(installPresentationSizeLifecycle(node),true);
|
||
assert.equal(Object.hasOwn(node,"setSize"),true);
|
||
assert.equal(removePresentationSizeLifecycle(node),true);
|
||
assert.equal(Object.hasOwn(node,"setSize"),false);
|
||
assert.equal(node.setSize,prototype.setSize);
|
||
});
|
||
|
||
test("the central size tracker repairs a late host setSize replacement",()=>{
|
||
let resizing=true;
|
||
configurePresentationSizeLifecycle({isUserResizing:()=>resizing});
|
||
const nativeSetSize=function(size){this.size=[...size]};
|
||
const node={size:[300,120],properties:{},setSize:nativeSetSize};
|
||
try{
|
||
installPresentationSizeLifecycle(node);
|
||
node.setSize=nativeSetSize;
|
||
assert.equal(installPresentationSizeLifecycle(node),true);
|
||
assert.notEqual(node.setSize,nativeSetSize);
|
||
node.setSize([360,240]);
|
||
assert.equal(node.properties.bvPresentationUserHeight,240);
|
||
removePresentationSizeLifecycle(node);
|
||
assert.equal(node.setSize,nativeSetSize);
|
||
}finally{resizing=false;configurePresentationSizeLifecycle({});removePresentationSizeLifecycle(node)}
|
||
});
|
||
|
||
test("the central size tracker records only a host-declared graph resize transaction",()=>{
|
||
let resizing=false,afterChanges=0;
|
||
const graph={afterChange(){afterChanges++}};
|
||
const active={size:[320,240],properties:{},graph,setSize(size){this.size=[...size]}},inactive={size:[320,280],properties:{},graph,setSize(size){this.size=[...size]}};
|
||
try{
|
||
configurePresentationSizeLifecycle({isUserResizing:node=>resizing&&node===active});
|
||
installPresentationSizeLifecycle(active);installPresentationSizeLifecycle(inactive);
|
||
presentationSize(active,[320,100]);presentationSize(inactive,[320,100]);
|
||
resizing=true;graph.afterChange(active);resizing=false;
|
||
assert.equal(afterChanges,1);
|
||
assert.equal(active.properties.bvPresentationUserHeight,240);
|
||
assert.equal(inactive.properties.bvPresentationUserHeight,undefined);
|
||
removePresentationSizeLifecycle(active);removePresentationSizeLifecycle(inactive);
|
||
assert.equal(Object.hasOwn(graph,"afterChange"),true);
|
||
graph.afterChange(active);assert.equal(afterChanges,2);
|
||
}finally{
|
||
configurePresentationSizeLifecycle({});
|
||
removePresentationSizeLifecycle(active);removePresentationSizeLifecycle(inactive);
|
||
}
|
||
});
|
||
|
||
test("serialization and host drift never infer user height outside an active resize transaction",()=>{
|
||
let resizing=false,afterChanges=0;
|
||
const prototype={serialize(){return{properties:{...this.properties}}}};
|
||
const graph={afterChange(){afterChanges++}};
|
||
const node=Object.assign(Object.create(prototype),{size:[320,120],properties:{},graph,setSize(size){this.size=[...size]}});
|
||
try{
|
||
configurePresentationSizeLifecycle({isUserResizing:()=>resizing});presentationSize(node,[320,120]);installPresentationSizeLifecycle(node);
|
||
assert.equal(node.serialize().properties.bvPresentationUserHeight,undefined);
|
||
node.setSize([320,260]);
|
||
graph.afterChange(node);
|
||
assert.equal(node.serialize().properties.bvPresentationUserHeight,undefined);
|
||
resizing=true;node.setSize([320,240]);resizing=false;
|
||
const serialized=node.serialize();assert.equal(serialized.properties.bvPresentationSizeVersion,1);assert.equal(serialized.properties.bvPresentationUserHeight,240);assert.equal(afterChanges,1);
|
||
}finally{configurePresentationSizeLifecycle({});removePresentationSizeLifecycle(node)}
|
||
assert.equal(Object.hasOwn(node,"serialize"),false);
|
||
assert.equal(node.serialize,prototype.serialize);
|
||
});
|
||
|
||
test("a throwing host resize probe fails closed without creating user height state",()=>{
|
||
const node={size:[320,120],properties:{},setSize(size){this.size=[...size]}};
|
||
configurePresentationSizeLifecycle({isUserResizing(){throw new Error("host probe unavailable")}});
|
||
try{
|
||
installPresentationSizeLifecycle(node);
|
||
assert.equal(isPresentationUserResizing(node),false);
|
||
assert.doesNotThrow(()=>node.setSize([320,180]));
|
||
assert.doesNotThrow(()=>setAutomaticPresentationSize(node,[320,140]));
|
||
assert.equal(node.properties.bvPresentationSizeVersion,undefined);
|
||
assert.equal(node.properties.bvPresentationUserHeight,undefined);
|
||
}finally{removePresentationSizeLifecycle(node);configurePresentationSizeLifecycle({})}
|
||
});
|
||
|
||
test("removal drops reconciliation proof before a later reinstallation",()=>{
|
||
const node={size:[320,120],properties:{},setSize(size){this.size=[...size]},serialize(){return{properties:{...this.properties}}}};
|
||
presentationSize(node,[320,120]);installPresentationSizeLifecycle(node);removePresentationSizeLifecycle(node);
|
||
node.size=[320,260];installPresentationSizeLifecycle(node);
|
||
try{assert.equal(node.serialize().properties.bvPresentationUserHeight,undefined)}finally{removePresentationSizeLifecycle(node)}
|
||
});
|
||
|
||
test("Nodes 2.0 commits a new semantic height when only public rows were mounted",()=>{
|
||
const sizes=[];
|
||
const buttonRow=nodes2Row("Open Regional Editor");
|
||
const element={dataset:{},querySelectorAll(selector){if(selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]')return[buttonRow];return[]}};
|
||
const node={id:100,size:[420,330],__bvPresentationAutoHeight:100,inputs:[],outputs:[],widgets:[{name:"open_regional_editor"}],setSize(size){sizes.push([...size]);this.size=[...size]}};
|
||
const documentLike={querySelector:()=>element};
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
assert.deepEqual(sizes,[[420,100]]);
|
||
node.__bvPresentationAutoHeight=140;
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
assert.deepEqual(sizes,[[420,100],[420,140]]);
|
||
});
|
||
|
||
test("Nodes 2.0 reapplies an unchanged semantic height after host layout drift",()=>{
|
||
const sizes=[];
|
||
const element={dataset:{},querySelectorAll(){return[]}};
|
||
const node={id:101,size:[320,220],__bvPresentationAutoHeight:80,inputs:[],outputs:[],widgets:[],setSize(size){sizes.push([...size]);this.size=[...size]}};
|
||
const documentLike={querySelector:()=>element};
|
||
projectNodes2NodePresentation(node,"BV LUT Loop Start",documentLike,{legacyDebug:false});
|
||
assert.deepEqual(sizes,[[320,80]]);
|
||
node.size=[320,110];
|
||
projectNodes2NodePresentation(node,"BV LUT Loop Start",documentLike,{legacyDebug:false});
|
||
assert.deepEqual(sizes,[[320,80],[320,80]]);
|
||
});
|
||
|
||
test("Nodes 2.0 settles a late host layout drift after installation",async()=>{
|
||
const sizes=[];
|
||
const element={dataset:{},querySelectorAll(){return[]}};
|
||
const node={id:102,size:[320,220],__bvPresentationAutoHeight:80,inputs:[],outputs:[],widgets:[],graph:{},setSize(size){sizes.push([...size]);this.size=[...size]}};
|
||
try{
|
||
installNodes2NodePresentation(node,"BV LUT Loop Start",{querySelector:()=>element});
|
||
node.size=[320,110];
|
||
await new Promise(resolve=>setTimeout(resolve,175));
|
||
assert.deepEqual(node.size,[320,80]);
|
||
assert.deepEqual(sizes,[[320,80],[320,80]]);
|
||
}finally{removeNodes2NodePresentation(node)}
|
||
});
|
||
|
||
test("Nodes 2.0 derives port-only height from mounted row geometry instead of the Classic slot constant",()=>{
|
||
const sizes=[];
|
||
const inputRows=[nodes2Row("plan"),nodes2Row("initial_image")],outputRows=[nodes2Row("flow"),nodes2Row("loop_state")];
|
||
for(const row of[...inputRows,...outputRows])row.offsetHeight=20;
|
||
const style=nodes2Row().style;style.setProperty("--node-height","110px");
|
||
const element={dataset:{},style,querySelectorAll(selector){if(selector===".lg-slot--input")return inputRows;if(selector===".lg-slot--output")return outputRows;return[]}};
|
||
const node={id:103,size:[320,110],__bvPresentationAutoHeight:110,inputs:[{name:"plan"},{name:"initial_image"}],outputs:[{name:"flow"},{name:"loop_state"}],widgets:[{name:"initial_value0"}],setSize(size){sizes.push([...size]);this.size=[...size]}};
|
||
projectNodes2NodePresentation(node,"BV LUT Loop Start",{querySelector:()=>element},{legacyDebug:false});
|
||
assert.deepEqual(node.size,[320,76]);
|
||
assert.deepEqual(sizes,[[320,76]]);
|
||
assert.equal(style.getPropertyValue("--node-height"),"76px");
|
||
});
|
||
|
||
test("Nodes 2.0 restores the exact host height style when presentation is removed",()=>{
|
||
const inputRows=[nodes2Row("plan"),nodes2Row("initial_image")],outputRows=[nodes2Row("flow"),nodes2Row("loop_state")];
|
||
for(const row of[...inputRows,...outputRows])row.offsetHeight=20;
|
||
const style=nodes2Row().style;style.setProperty("--node-height","110px","important");
|
||
const element={dataset:{},style,querySelectorAll(selector){if(selector===".lg-slot--input")return inputRows;if(selector===".lg-slot--output")return outputRows;return[]}};
|
||
const node={id:104,size:[320,110],__bvPresentationAutoHeight:110,inputs:[{name:"plan"},{name:"initial_image"}],outputs:[{name:"flow"},{name:"loop_state"}],widgets:[],graph:{},setSize(size){this.size=[...size]}};
|
||
installNodes2NodePresentation(node,"BV LUT Loop Start",{querySelector:()=>element});
|
||
assert.equal(style.getPropertyValue("--node-height"),"76px");
|
||
removeNodes2NodePresentation(node);
|
||
assert.equal(style.getPropertyValue("--node-height"),"110px");
|
||
assert.equal(style.getPropertyPriority("--node-height"),"important");
|
||
});
|
||
|
||
test("Nodes 2.0 keeps connected legacy source and target sockets visible without debug",()=>{
|
||
const sourceRows=[nodes2Row("regional"),nodes2Row("lora_bindings")];
|
||
const targetRows=[nodes2Row("regional"),nodes2Row("clip"),nodes2Row("lora_registry"),nodes2Row("lora_bindings")];
|
||
const sourceElement={dataset:{},querySelectorAll(selector){if(selector===".lg-slot--output")return sourceRows;if(selector===".lg-slot--input")return[];return[]}};
|
||
const targetElement={dataset:{},querySelectorAll(selector){if(selector===".lg-slot--input")return targetRows;if(selector===".lg-slot--output")return[nodes2Row("positive"),nodes2Row("negative")];return[]}};
|
||
const documentLike={querySelector(selector){return selector.includes('"41"')?sourceElement:targetElement}};
|
||
const source={id:41,inputs:[],outputs:[{name:"regional",links:null},{name:"lora_bindings",links:[9]}],widgets:[]};
|
||
const target={id:42,inputs:[{name:"regional",link:null},{name:"clip",link:null},{name:"lora_registry",link:null},{name:"lora_bindings",link:9}],outputs:[{name:"positive",links:null},{name:"negative",links:null}],widgets:[]};
|
||
projectNodes2NodePresentation(source,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
projectNodes2NodePresentation(target,"BV Regional Native Conditioning",documentLike,{legacyDebug:false});
|
||
assert.equal(sourceRows[1].style.visibility,"");
|
||
assert.equal(targetRows[2].style.visibility,"hidden");
|
||
assert.equal(targetRows[3].style.visibility,"");
|
||
});
|
||
|
||
test("Nodes 2.0 connected legacy sockets project and exactly restore the canonical link color",()=>{
|
||
const type="BV_LORA_BINDINGS",variable=`--color-datatype-${type}`;
|
||
const sourceRow=nodes2Row("lora_bindings"),targetRow=nodes2Row("lora_bindings");
|
||
sourceRow.style.setProperty(variable,"#112233","important");
|
||
const link={id:9,type,color:"#c0ffee",origin_id:51,origin_slot:0,target_id:52,target_slot:0};
|
||
const graph={getLink(id){return id===9?link:null},links:new Map([[9,link]])};
|
||
const source={id:51,graph,inputs:[],outputs:[{name:"lora_bindings",type,links:[9]}],widgets:[]};
|
||
const target={id:52,graph,inputs:[{name:"lora_bindings",type,link:9}],outputs:[],widgets:[]};
|
||
const sourceElement={dataset:{},querySelectorAll(selector){return selector===".lg-slot--output"?[sourceRow]:[]}};
|
||
const targetElement={dataset:{},querySelectorAll(selector){return selector===".lg-slot--input"?[targetRow]:[]}};
|
||
const documentLike={querySelector(selector){return selector.includes('"51"')?sourceElement:targetElement}};
|
||
|
||
projectNodes2NodePresentation(source,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
projectNodes2NodePresentation(target,"BV Regional Native Conditioning",documentLike,{legacyDebug:false});
|
||
for(const row of[sourceRow,targetRow]){
|
||
assert.equal(row.dataset.bvPresentationLegacyConnected,"true");
|
||
assert.equal(row.style.getPropertyValue(variable),"#c0ffee");
|
||
assert.equal(row.style.getPropertyPriority(variable),"");
|
||
}
|
||
|
||
source.outputs[0].links=null;target.inputs[0].link=null;
|
||
projectNodes2NodePresentation(source,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
projectNodes2NodePresentation(target,"BV Regional Native Conditioning",documentLike,{legacyDebug:false});
|
||
assert.equal(sourceRow.dataset.bvPresentationLegacyConnected,undefined);
|
||
assert.equal(sourceRow.style.getPropertyValue(variable),"#112233");
|
||
assert.equal(sourceRow.style.getPropertyPriority(variable),"important");
|
||
assert.equal(targetRow.dataset.bvPresentationLegacyConnected,undefined);
|
||
assert.equal(targetRow.style.getPropertyValue(variable),"");
|
||
});
|
||
|
||
test("Nodes 2.0 legacy color resolves the native palette from object-backed links and cleans up on removal",()=>{
|
||
const type="BV_LORA_BINDINGS",variable=`--color-datatype-${type}`,previousCanvas=globalThis.LGraphCanvas;
|
||
globalThis.LGraphCanvas={link_type_colors:{[type]:"#abc123"}};
|
||
const row=nodes2Row("lora_bindings"),link={id:4,type,target_id:61,target_slot:0};
|
||
const graph={links:{4:link}},node={id:61,graph,inputs:[{name:"lora_bindings",type,link:4}],outputs:[],widgets:[]};
|
||
const element={dataset:{},querySelectorAll(selector){return selector===".lg-slot--input"?[row]:[]}};
|
||
try{
|
||
projectNodes2NodePresentation(node,"BV Regional Native Conditioning",{querySelector:()=>element},{legacyDebug:false});
|
||
assert.equal(row.style.getPropertyValue(variable),"#abc123");
|
||
assert.equal(row.dataset.bvPresentationLegacyConnected,"true");
|
||
removeNodes2NodePresentation(node);
|
||
assert.equal(row.style.getPropertyValue(variable),"");
|
||
assert.equal(row.dataset.bvPresentationLegacyConnected,undefined);
|
||
}finally{
|
||
if(previousCanvas===undefined)delete globalThis.LGraphCanvas;else globalThis.LGraphCanvas=previousCanvas;
|
||
}
|
||
});
|
||
|
||
test("Regional LoRA Nodes 2.0 removes internal rows and titlebar-projects providers in debug mode",()=>{
|
||
const inputRows=[nodes2Row("regional"),nodes2Row("resource_provider_1")];
|
||
const outputRows=[nodes2Row("regional")];
|
||
const widgetRows=[nodes2Row("operation"),nodes2Row("config_json"),nodes2Row("Open LoRA Editor")];
|
||
const element={
|
||
dataset:{},
|
||
querySelectorAll(selector){
|
||
if(selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]')return widgetRows;
|
||
if(selector===".lg-slot--input")return inputRows;
|
||
if(selector===".lg-slot--output")return outputRows;
|
||
return[];
|
||
},
|
||
};
|
||
const documentLike={querySelector:()=>element};
|
||
const node={id:2,inputs:[{name:"regional"},{name:"resource_provider_1"}],outputs:[{name:"regional"}],widgets:[
|
||
{name:"operation"},{name:"config_json"},{name:"open_lora_editor"},
|
||
]};
|
||
|
||
for(const legacyDebug of[false,true,false]){
|
||
assert.equal(projectNodes2NodePresentation(node,"BV Regional LoRA",documentLike,{legacyDebug}),true);
|
||
assert.deepEqual(inputRows.map(row=>[row.style.display,row.style.visibility]),[["",""],["",""]]);
|
||
assert.equal(inputRows[1].dataset.bvPresentationProviderAnchor,"input");
|
||
assert.equal(inputRows[1].dataset.bvPresentationProviderVisible,String(legacyDebug));
|
||
assert.deepEqual(outputRows.map(row=>[row.style.display,row.style.visibility]),[["",""]]);
|
||
assert.deepEqual(widgetRows.map(row=>[row.style.display,row.style.visibility]),[["none",""],["none",""],["",""]]);
|
||
}
|
||
|
||
const lateProvider=nodes2Row("resource_provider_2");
|
||
inputRows.push(lateProvider);
|
||
node.inputs.push({name:"resource_provider_2"});
|
||
projectNodes2NodePresentation(node,"BV Regional LoRA",documentLike,{legacyDebug:false});
|
||
assert.equal(lateProvider.style.display,"");
|
||
assert.equal(lateProvider.dataset.bvPresentationProviderAnchor,"input");
|
||
assert.equal(lateProvider.dataset.bvPresentationProviderVisible,"false");
|
||
});
|
||
|
||
test("Smart Pipe Merge Nodes 2.0 removes JSON space and retains materialized pipe rows",()=>{
|
||
const inputRows=[nodes2Row("pipe_007"),nodes2Row("+ Add Pipe Source")];
|
||
const outputRows=[nodes2Row("pipe")];
|
||
const widgetRows=[nodes2Row("bv_smart_pipe_merge_json"),nodes2Row("Configure Pipe Merge")];
|
||
const element={dataset:{},querySelectorAll(selector){
|
||
if(selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]')return widgetRows;
|
||
if(selector===".lg-slot--input")return inputRows;
|
||
if(selector===".lg-slot--output")return outputRows;
|
||
return[];
|
||
}};
|
||
const documentLike={querySelector:()=>element};
|
||
const node={id:3,inputs:[{name:"pipe_007"},{name:"bv_add_pipe_source"}],outputs:[{name:"pipe"}],widgets:[{name:"bv_smart_pipe_merge_json"},{name:"Configure Merge"}]};
|
||
for(const legacyDebug of[false,true,false]){
|
||
assert.equal(projectNodes2NodePresentation(node,"BV Smart Pipe Merge",documentLike,{legacyDebug}),true);
|
||
assert.deepEqual(inputRows.map(row=>row.style.display),["",""]);
|
||
assert.deepEqual(outputRows.map(row=>row.style.display),[""]);
|
||
assert.deepEqual(widgetRows.map(row=>row.style.display),["none",""]);
|
||
}
|
||
});
|
||
|
||
test("Smart Pipe Nodes 2.0 keeps public widgets when hidden JSON rows are absent from the DOM",()=>{
|
||
const inputRows=[nodes2Row("pipe"),nodes2Row("+ Add Slot")],outputRows=[nodes2Row("pipe")];
|
||
const widgetRows=[nodes2Row("Pipe predecessor Start new pipe"),nodes2Row("Configure Smart Pipe")];
|
||
const element={dataset:{},querySelectorAll(selector){
|
||
if(selector==='.lg-slot--input')return inputRows;
|
||
if(selector==='.lg-slot--output')return outputRows;
|
||
if(selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]')return widgetRows;
|
||
return[];
|
||
}};
|
||
const node={id:77,inputs:[{name:"pipe",link:null},{name:"bv_add_slot",link:null}],outputs:[{name:"pipe",links:null}],widgets:[
|
||
{name:"bv_smart_pipe_schema_json"},{name:"bv_smart_pipe_route_json"},
|
||
{name:"Pipe predecessor",label:"Pipe predecessor"},{name:"Configure",label:"Configure Smart Pipe"},
|
||
]};
|
||
assert.equal(projectNodes2NodePresentation(node,"BV Smart Pipe",{querySelector:()=>element},{legacyDebug:false}),true);
|
||
assert.deepEqual(widgetRows.map(row=>[row.hidden,row.style.display]),[[false,""],[false,""]]);
|
||
});
|
||
|
||
test("Regional Detailer Plan Nodes 2.0 titlebar-projects providers and preserves its legacy row geometry",()=>{
|
||
const inputRows=[nodes2Row("regional_prompt"),nodes2Row("detector_registry"),nodes2Row("resource_provider"),nodes2Row("resource_provider_1")];
|
||
const outputRows=[nodes2Row("detailer_plan"),nodes2Row("job_count"),nodes2Row("plan_summary")];
|
||
const widgetRows=[nodes2Row("config_json"),nodes2Row("Connect a BV Regional Prompt")];
|
||
const element={dataset:{},querySelectorAll(selector){
|
||
if(selector===".lg-slot--input")return inputRows;
|
||
if(selector===".lg-slot--output")return outputRows;
|
||
if(selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]')return widgetRows;
|
||
return[];
|
||
}};
|
||
const node={id:78,inputs:detailerPlanInventory.ports.filter(port=>port.direction==="input").map(port=>({name:port.name,link:port.connected?7:null})),outputs:detailerPlanInventory.ports.filter(port=>port.direction==="output").map(port=>({name:port.name,links:null})),widgets:detailerPlanInventory.widgets};
|
||
projectNodes2NodePresentation(node,"BV Regional Detailer Plan",{querySelector:()=>element},{legacyDebug:false});
|
||
assert.deepEqual(inputRows.map(row=>[row.style.display,row.style.visibility]),[["",""],["","hidden"],["",""],["",""]]);
|
||
assert.deepEqual(inputRows.slice(2).map(row=>[row.dataset.bvPresentationProviderAnchor,row.dataset.bvPresentationProviderVisible]),[["input","false"],["input","false"]]);
|
||
assert.deepEqual(outputRows.map(row=>row.style.display),["","",""]);
|
||
assert.deepEqual(widgetRows.map(row=>row.style.display),["none",""]);
|
||
projectNodes2NodePresentation(node,"BV Regional Detailer Plan",{querySelector:()=>element},{legacyDebug:true});
|
||
assert.equal(inputRows[1].style.visibility,"");
|
||
assert.deepEqual(inputRows.slice(2).map(row=>[row.style.display,row.dataset.bvPresentationProviderVisible]),[["","true"],["","true"]]);
|
||
});
|
||
|
||
test("Nodes 2.0 distinguishes a visible selector from hidden state sharing the same label",()=>{
|
||
const widgetRows=[nodes2Row("scope"),nodes2Row("region")];
|
||
const element={dataset:{},querySelectorAll(selector){return selector==='[data-testid="node-widgets"] > [data-testid="node-widget"]'?widgetRows:[]}};
|
||
const node={id:79,inputs:[],outputs:[],widgets:[
|
||
{name:"scope",label:"scope"},{name:"region",label:"region"},{name:"region_selector",label:"region"},
|
||
]};
|
||
projectNodes2NodePresentation(node,"BV Regional Select",{querySelector:()=>element},{legacyDebug:false});
|
||
assert.deepEqual(widgetRows.map(row=>row.style.display),["",""]);
|
||
});
|
||
|
||
test("Regional Detailer Plan Classic publishes one stable final height across debug toggles",()=>{
|
||
const sizes=[];
|
||
const node={size:[300,260],properties:{},inputs:detailerPlanInventory.ports.filter(port=>port.direction==="input").map(port=>({name:port.name,link:null})),outputs:detailerPlanInventory.ports.filter(port=>port.direction==="output").map(port=>({name:port.name,links:null})),widgets:[{name:"config_json",computeSize:()=>[0,60]},{name:"configure_detailer_plan",type:"button",computeSize:()=>[0,20]}],computeSize(){const ports=Math.max(this.inputs.filter(port=>!port.hidden).length,this.outputs.filter(port=>!port.hidden).length);const widgets=this.widgets.reduce((height,widget)=>height+Math.max(0,Number(widget.computeSize?.()[1]??20)),0);return[280,40+ports*20+widgets]},setSize(size){sizes.push([...size]);this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
|
||
applyClassicNodePresentation(node,"BV Regional Detailer Plan",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,120]);
|
||
assert.equal(sizes.length,1);
|
||
applyClassicNodePresentation(node,"BV Regional Detailer Plan",{legacyDebug:true});
|
||
applyClassicNodePresentation(node,"BV Regional Detailer Plan",{legacyDebug:false});
|
||
assert.deepEqual(node.size,[300,120]);
|
||
assert.equal(sizes.length,1);
|
||
});
|
||
|
||
test("Nodes 2.0 presentation releases its node-owned callback on removal",()=>{
|
||
const node={id:9,graph:{}};
|
||
const documentLike={querySelector:()=>null};
|
||
installNodes2NodePresentation(node,"BV Regional Prompt",documentLike);
|
||
assert.equal(typeof node.__bvApplyNodes2Presentation,"function");
|
||
removeNodes2NodePresentation(node);
|
||
assert.equal(node.__bvApplyNodes2Presentation,undefined);
|
||
});
|
||
|
||
test("LUT node lifecycle and widget manipulation is owned by the central presentation installer",()=>{
|
||
assert.match(indexSource,/import \{ installLutNodePresentation \} from "\.\/regional\/lutNodePresentation"/);
|
||
assert.match(indexSource,/if\(installLutNodePresentation\(nodeType,nodeData,/);
|
||
const legacyLutBlock=indexSource.slice(indexSource.indexOf('if(nodeData.name==="BV LUT Registry")'),indexSource.indexOf('if(["BV Control Center"'));
|
||
assert.equal(legacyLutBlock,"","index.tsx must not retain LUT-specific lifecycle or widget manipulation blocks");
|
||
assert.doesNotMatch(lutV3CatalogSource,/const hide=|hide\(item\)/,"the LUT domain catalog must not own widget presentation");
|
||
assert.doesNotMatch(lutV3CatalogSource,/\.hidden\s*=/,"the LUT domain catalog must not mutate port visibility directly");
|
||
assert.match(lutV3CatalogSource,/markProjectedProvider\(output\)/,"provider output presentation must use the central projection helper");
|
||
});
|
||
|
||
const lutPresentationDeps=(overrides={})=>({
|
||
api:{},graphOwner:node=>node.graph,scopedNodeKey:node=>String(node.id),workflowNodesOfType:()=>[],windowMenuVisible:()=>true,
|
||
switchView:()=>{},sourceDocument:()=>null,detectorCollectors:()=>[],openRegistry:async()=>{},openPlan:()=>{},openDownload:()=>{},...overrides,
|
||
});
|
||
|
||
test("central LUT installer owns exactly the four LUT lifecycle node types",()=>{
|
||
for(const name of ["BV LUT Registry","BV LUT Loader","BV LUT Loop Start","BV Regional LUT Plan"]){
|
||
class NodeType{}
|
||
assert.equal(installLutNodePresentation(NodeType,{name},lutPresentationDeps()),true,name);
|
||
}
|
||
class ForeignNode{}
|
||
assert.equal(installLutNodePresentation(ForeignNode,{name:"BV Regional Prompt"},lutPresentationDeps()),false);
|
||
});
|
||
|
||
test("central LUT loader preserves lifecycle semantics and guards repeated preparation",async()=>{
|
||
let createdCalls=0,removedCalls=0,downloadCallback,callbackValues=[],dirtyCalls=0;
|
||
class LoaderNode{}
|
||
LoaderNode.prototype.onNodeCreated=function(value){createdCalls++;assert.equal(this.marker,"loader");return `created:${value}`};
|
||
LoaderNode.prototype.onConfigure=function(){return "configured"};
|
||
LoaderNode.prototype.onRemoved=function(value){removedCalls++;return `removed:${value}`};
|
||
installLutNodePresentation(LoaderNode,{name:"BV LUT Loader"},lutPresentationDeps({openDownload:callback=>{downloadCallback=callback}}));
|
||
const combo={name:"lut_name",value:"Built-in: Identity",options:{values:["Built-in: Identity","downloaded\\Existing.cube","Download more LUTs…"]},callback(value){callbackValues.push(value)}};
|
||
let secondDirtyCalls=0;
|
||
const secondCombo={name:"lut_name",value:"Built-in: Identity",options:{values:["Built-in: Identity","Download more LUTs…"]},callback(){}};
|
||
const node=new LoaderNode();Object.assign(node,{marker:"loader",widgets:[combo],graph:{setDirtyCanvas(){dirtyCalls++}}});
|
||
const secondNode=new LoaderNode();Object.assign(secondNode,{marker:"loader",widgets:[secondCombo],graph:{setDirtyCanvas(){secondDirtyCalls++}}});
|
||
assert.equal(node.onNodeCreated("x"),"created:x");
|
||
assert.equal(secondNode.onNodeCreated("y"),"created:y");
|
||
await new Promise(resolve=>queueMicrotask(resolve));
|
||
assert.ok(combo.options.values.includes("downloaded/Existing.cube"));
|
||
assert.ok(!combo.options.values.includes("downloaded\\Existing.cube"));
|
||
const wrapped=combo.callback;
|
||
combo.value="Built-in: Warm Contrast";combo.callback("Built-in: Warm Contrast");
|
||
combo.value="Download more LUTs…";
|
||
combo.callback("Download more LUTs…");
|
||
assert.equal(combo.value,"Built-in: Warm Contrast");
|
||
lutLibrary.publish("downloaded/External.cube");
|
||
assert.ok(combo.options.values.includes("downloaded/External.cube"));
|
||
assert.ok(secondCombo.options.values.includes("downloaded/External.cube"));
|
||
assert.equal(combo.value,"Built-in: Warm Contrast","external installs must not change selection");
|
||
assert.equal(secondCombo.value,"Built-in: Identity","external installs must not change another loader selection");
|
||
lutLibrary.publish("downloaded/Test.cube");
|
||
downloadCallback("downloaded/Test.cube");
|
||
assert.ok(combo.options.values.includes("downloaded/Test.cube"));
|
||
assert.equal(combo.value,"downloaded/Test.cube");
|
||
assert.deepEqual(callbackValues,["Built-in: Warm Contrast","downloaded/Test.cube"]);
|
||
downloadCallback("downloaded/Test.cube");
|
||
assert.equal(combo.options.values.filter(value=>value==="downloaded/Test.cube").length,1,"slash variants must not create duplicate entries");
|
||
assert.deepEqual(callbackValues,["Built-in: Warm Contrast","downloaded/Test.cube","downloaded/Test.cube"]);
|
||
assert.equal(node.onConfigure(),"configured");
|
||
await new Promise(resolve=>queueMicrotask(resolve));
|
||
assert.equal(combo.callback,wrapped,"configure must not wrap the callback twice");
|
||
assert.equal(createdCalls,2);
|
||
assert.equal(node.onRemoved("x"),"removed:x");assert.equal(removedCalls,1);
|
||
secondNode.onConfigure();secondNode.onConfigure();await new Promise(resolve=>queueMicrotask(resolve));
|
||
const secondDirtyBeforePublish=secondDirtyCalls;
|
||
lutLibrary.publish("downloaded/AfterRemoval.cube");
|
||
assert.ok(!combo.options.values.includes("downloaded/AfterRemoval.cube"),"removed loaders must unsubscribe");
|
||
assert.ok(secondCombo.options.values.includes("downloaded/AfterRemoval.cube"),"remaining loaders stay subscribed");
|
||
assert.equal(secondDirtyCalls,secondDirtyBeforePublish+1,"repeated configure must not duplicate the subscription");
|
||
secondNode.onRemoved("y");
|
||
assert.ok(dirtyCalls>=2);
|
||
});
|
||
|
||
test("central LUT registry normalizes once, reuses its action, and exposes save and navigation",async()=>{
|
||
const previousWindow=globalThis.window,previousCustomEvent=globalThis.CustomEvent;
|
||
globalThis.window={dispatchEvent(){}};globalThis.CustomEvent=class{constructor(type,init){this.type=type;this.detail=init?.detail}};
|
||
try{
|
||
let registryArgs,dirtyCalls=0,hiddenCallbacks=0,switched=0;
|
||
class RegistryNode{}
|
||
RegistryNode.prototype.onNodeCreated=function(){return "registry-created"};
|
||
const targetAction={callback(){}};const target={id:2,title:"Target",widgets:[{name:"configure_lut_registry",...targetAction}]};
|
||
installLutNodePresentation(RegistryNode,{name:"BV LUT Registry"},lutPresentationDeps({workflowNodesOfType:()=>[target],openRegistry:async(...args)=>{registryArgs=args},switchView:()=>{switched++}}));
|
||
const hidden={name:"config_json",value:JSON.stringify({schema:"old",version:0,collector_id:"11111111-1111-4111-8111-111111111111",luts:[{id:"a",lut_name:"One"}]}),callback(){hiddenCallbacks++}};
|
||
const node=new RegistryNode();Object.assign(node,{id:1,comfyClass:"BV LUT Registry",widgets:[hidden],outputs:[],graph:{nodes:[],setDirtyCanvas(){dirtyCalls++}},addOutput(name,type){this.outputs.push({name,type})},addWidget(_type,name,_value,callback){const widget={name,callback};this.widgets.push(widget);return widget}});node.graph.nodes=[node,target];
|
||
assert.equal(node.onNodeCreated(),"registry-created");await new Promise(resolve=>queueMicrotask(resolve));
|
||
const action=node.widgets.find(item=>item.name==="configure_lut_registry");
|
||
assert.equal(hidden.hidden,true);assert.match(hidden.value,/"schema":"bv\.lut_registry_config"/);assert.equal(hiddenCallbacks,1);assert.equal(action.label,"Configure LUT Registry · 1 LUT");assert.equal(action.serialize,false);
|
||
node.onConfigure?.();await new Promise(resolve=>queueMicrotask(resolve));assert.equal(node.widgets.filter(item=>item.name==="configure_lut_registry").length,1);
|
||
action.callback();await new Promise(resolve=>queueMicrotask(resolve));
|
||
const save=registryArgs[2],navigate=registryArgs[5];save(JSON.stringify({schema:"bv.lut_registry_config",version:1,collector_id:"11111111-1111-4111-8111-111111111111",luts:[]}));
|
||
assert.match(action.label,/0 LUTs/);navigate("2",false);assert.equal(switched,1);assert.ok(dirtyCalls>=2);
|
||
}finally{globalThis.window=previousWindow;globalThis.CustomEvent=previousCustomEvent}
|
||
});
|
||
|
||
test("central LUT loop chains all lifecycle paths and preserves returns",async()=>{
|
||
class LoopNode{}
|
||
LoopNode.prototype.onNodeCreated=function(value){this.calls=(this.calls??0)+1;return `created:${value}`};
|
||
LoopNode.prototype.onConfigure=function(){this.calls++;return "configured"};
|
||
LoopNode.prototype.onConnectionsChange=function(){this.calls++;return "changed"};
|
||
installLutNodePresentation(LoopNode,{name:"BV LUT Loop Start"},lutPresentationDeps());
|
||
const node=new LoopNode();Object.assign(node,{comfyClass:"BV LUT Loop Start",widgets:[],inputs:[],outputs:[],graph:{nodes:[]}});node.graph.nodes=[node];
|
||
assert.equal(node.onNodeCreated("x"),"created:x");assert.equal(node.onConfigure(),"configured");assert.equal(node.onConnectionsChange(),"changed");await new Promise(resolve=>queueMicrotask(resolve));
|
||
assert.equal(node.calls,3);assert.equal(node.__bvPresentationManaged,true);
|
||
});
|
||
|
||
test("central definition presentation lifecycle composes callbacks and installs only once",async()=>{
|
||
class PolicyNode{}
|
||
PolicyNode.prototype.onNodeCreated=function(value){this.calls.push(["created",value]);return `created:${value}`};
|
||
PolicyNode.prototype.onConfigure=function(value){this.calls.push(["configured",value]);return `configured:${value}`};
|
||
PolicyNode.prototype.onConnectionsChange=function(value){this.calls.push(["connections",value]);return `connections:${value}`};
|
||
PolicyNode.prototype.onRemoved=function(value){this.calls.push(["removed",value]);return `removed:${value}`};
|
||
assert.equal(installNodePresentationLifecycle(PolicyNode,{name:"BV LUT Loop End"}),true);
|
||
const installed={created:PolicyNode.prototype.onNodeCreated,configured:PolicyNode.prototype.onConfigure,connections:PolicyNode.prototype.onConnectionsChange,removed:PolicyNode.prototype.onRemoved};
|
||
assert.equal(installNodePresentationLifecycle(PolicyNode,{name:"BV LUT Loop End"}),true);
|
||
assert.deepEqual([PolicyNode.prototype.onNodeCreated,PolicyNode.prototype.onConfigure,PolicyNode.prototype.onConnectionsChange,PolicyNode.prototype.onRemoved],[installed.created,installed.configured,installed.connections,installed.removed]);
|
||
const node=new PolicyNode();Object.assign(node,{calls:[],size:[320,108],properties:{},inputs:[{name:"flow",type:"*",link:null},{name:"processed_image",type:"IMAGE",link:null}],outputs:[{name:"final_image",type:"IMAGE",links:null}],widgets:[],graph:{setDirtyCanvas(){},afterChange(){}},computeSize(){return[320,108]},setSize(size){this.size=[...size]}});
|
||
assert.equal(node.onNodeCreated("a"),"created:a");assert.equal(node.onConfigure("b"),"configured:b");assert.equal(node.onConnectionsChange("c"),"connections:c");await new Promise(resolve=>queueMicrotask(resolve));
|
||
assert.deepEqual(node.calls,[["created","a"],["configured","b"],["connections","c"]]);assert.equal(node.__bvPresentationManaged,true);
|
||
assert.equal(node.onRemoved("d"),"removed:d");assert.deepEqual(node.calls.at(-1),["removed","d"]);
|
||
});
|
||
|
||
test("fresh Nodes 2 LUT and Detailer loop definitions share one port-only height contract",async()=>{
|
||
const previous={document:globalThis.document,MutationObserver:globalThis.MutationObserver,requestAnimationFrame:globalThis.requestAnimationFrame,cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||
class Observer{observe(){}disconnect(){}}
|
||
const roots=new Map(),styleElement={id:"",textContent:""};
|
||
const documentLike={
|
||
querySelector(selector){const match=String(selector).match(/data-node-id="([^"]+)"/);return match?roots.get(match[1])??null:null},
|
||
getElementById(){return null},createElement(){return styleElement},head:{append(){}},body:{},
|
||
};
|
||
globalThis.document=documentLike;globalThis.MutationObserver=Observer;
|
||
globalThis.requestAnimationFrame=callback=>{queueMicrotask(callback);return 1};globalThis.cancelAnimationFrame=()=>{};
|
||
const root=(id,inputs,outputs)=>{
|
||
const inputRows=inputs.map((name,index)=>nodes2IndexedRow(name,`${id}-in-${index}`));
|
||
const outputRows=outputs.map((name,index)=>nodes2IndexedRow(name,`${id}-out-${index}`));
|
||
for(const row of[...inputRows,...outputRows])row.offsetHeight=20;
|
||
const style=nodes2Row().style;style.setProperty("--node-height",id==="1"?"110px":"108px");
|
||
return{dataset:{},style,querySelectorAll(selector){if(selector===".lg-slot--input")return inputRows;if(selector===".lg-slot--output")return outputRows;return[]}};
|
||
};
|
||
const makeType=className=>class{
|
||
constructor(id,nativeHeight,inputs,outputs){this.id=id;this.comfyClass=className;this.size=[320,nativeHeight];this.properties={};this.widgets=[];this.calls=[];this.inputs=inputs.map(({name,type})=>({name,type,link:{id:`${id}:${name}`}}));this.outputs=outputs.map(({name,type})=>({name,type,links:[{id:`${id}:${name}`}]}));}
|
||
computeSize(){return[320,40+Math.max(this.inputs.filter(slot=>!slot.hidden).length,this.outputs.filter(slot=>!slot.hidden).length)*20]}
|
||
setSize(size){this.size=[...size]}
|
||
onNodeCreated(){this.calls.push("created");return `${this.comfyClass}:created`}
|
||
onConfigure(){this.calls.push("configured");return `${this.comfyClass}:configured`}
|
||
};
|
||
const families=[
|
||
{name:"LUT",startName:"BV LUT Loop Start",endName:"BV LUT Loop End",startInputs:[{name:"plan",type:"*"},{name:"initial_image",type:"IMAGE"}],startOutputs:[{name:"flow",type:"FLOW_CONTROL"},{name:"loop_state",type:"BV_LUT_LOOP_STATE"}],endInputs:[{name:"flow",type:"FLOW_CONTROL"},{name:"processed_image",type:"IMAGE"}],endOutputs:[{name:"final_image",type:"IMAGE"}],installFeature:true},
|
||
{name:"Detailer",startName:"BV Detailer Loop Start",endName:"BV Detailer Loop End",startInputs:[{name:"detailer_plan",type:"BV_DETAILER_PLAN"},{name:"initial_image",type:"IMAGE"}],startOutputs:[{name:"flow",type:"FLOW_CONTROL"},{name:"loop_state",type:"BV_DETAILER_LOOP_STATE"}],endInputs:[{name:"flow",type:"FLOW_CONTROL"},{name:"processed_image",type:"IMAGE"}],endOutputs:[{name:"final_image",type:"IMAGE"}],installFeature:false},
|
||
];
|
||
const pairs=[];let id=1;
|
||
for(const family of families){
|
||
const StartType=makeType(family.startName),EndType=makeType(family.endName);
|
||
installNodePresentationLifecycle(StartType,{name:family.startName});installNodePresentationLifecycle(EndType,{name:family.endName});
|
||
if(family.installFeature){installLutNodePresentation(StartType,{name:family.startName},lutPresentationDeps());installLutNodePresentation(EndType,{name:family.endName},lutPresentationDeps())}
|
||
const startId=String(id++),endId=String(id++),start=new StartType(startId,110,family.startInputs,family.startOutputs),end=new EndType(endId,108,family.endInputs,family.endOutputs);
|
||
const graph={nodes:[start,end],_nodes:[start,end],setDirtyCanvas(){},afterChange(){}};start.graph=graph;end.graph=graph;
|
||
pairs.push({family,start,end,startId,endId,startInputRefs:[...start.inputs],startOutputLinks:start.outputs.map(slot=>slot.links),endInputRefs:[...end.inputs],endOutputLinks:end.outputs.map(slot=>slot.links)});
|
||
}
|
||
try{
|
||
for(const{family,start,end}of pairs){assert.equal(start.onNodeCreated(),`${family.startName}:created`);assert.equal(end.onNodeCreated(),`${family.endName}:created`);assert.equal(start.onConfigure(),`${family.startName}:configured`);assert.equal(end.onConfigure(),`${family.endName}:configured`)}
|
||
await new Promise(resolve=>queueMicrotask(resolve));
|
||
for(const{family,start,end}of pairs){assert.equal(start.size[1],80,`${family.name} Classic start height`);assert.equal(end.size[1],80,`${family.name} Classic end height`)}
|
||
for(const{family,startId,endId}of pairs){roots.set(startId,root(startId,family.startInputs.map(slot=>slot.name),family.startOutputs.map(slot=>slot.name)));roots.set(endId,root(endId,family.endInputs.map(slot=>slot.name),family.endOutputs.map(slot=>slot.name)))}
|
||
await new Promise(resolve=>setTimeout(resolve,175));
|
||
for(const{family,start,end,startId,endId,startInputRefs,startOutputLinks,endInputRefs,endOutputLinks}of pairs){
|
||
assert.equal(start.size[1],end.size[1],`${family.name} pair diverged: start=${start.size[1]}, end=${end.size[1]}`);assert.equal(start.size[1],76);
|
||
assert.equal(roots.get(startId).style.getPropertyValue("--node-height"),roots.get(endId).style.getPropertyValue("--node-height"));assert.equal(roots.get(startId).style.getPropertyValue("--node-height"),"76px");
|
||
assert.deepEqual(start.calls,["created","configured"]);assert.deepEqual(end.calls,["created","configured"]);
|
||
assert.deepEqual(start.inputs.slice(0,startInputRefs.length),startInputRefs);assert.deepEqual(end.inputs,endInputRefs);
|
||
start.outputs.forEach((slot,index)=>assert.equal(slot.links,startOutputLinks[index]));end.outputs.forEach((slot,index)=>assert.equal(slot.links,endOutputLinks[index]));
|
||
assert.deepEqual(start.inputs.slice(0,family.startInputs.length).map(slot=>[slot.name,slot.type]),family.startInputs.map(slot=>[slot.name,slot.type]));assert.deepEqual(end.inputs.map(slot=>[slot.name,slot.type]),family.endInputs.map(slot=>[slot.name,slot.type]));
|
||
}
|
||
}finally{
|
||
for(const{start,end}of pairs){removeNodePresentation(start);removeNodePresentation(end)}
|
||
for(const[key,value]of Object.entries(previous))value===undefined?delete globalThis[key]:globalThis[key]=value;
|
||
}
|
||
});
|
||
|
||
test("Nodes 2 port-only geometry handles late rows, collapse and root replacement without a loop",()=>{
|
||
const previous={ResizeObserver:globalThis.ResizeObserver,requestAnimationFrame:globalThis.requestAnimationFrame,cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||
const frames=[],observers=[];let disconnected=0,writes=0;
|
||
class Observer{constructor(callback){this.callback=callback;this.targets=new Set();observers.push(this)}observe(target){this.targets.add(target)}unobserve(target){this.targets.delete(target)}disconnect(){disconnected++;this.targets.clear()}}
|
||
globalThis.ResizeObserver=Observer;globalThis.requestAnimationFrame=callback=>{frames.push(callback);return frames.length};globalThis.cancelAnimationFrame=()=>{};
|
||
const inputs=[nodes2IndexedRow("flow","late-in-0"),nodes2IndexedRow("processed_image","late-in-1")],outputs=[nodes2IndexedRow("final_image","late-out-0")];
|
||
for(const row of[...inputs,...outputs])row.offsetHeight=0;
|
||
const makeRoot=(inputRows=inputs,outputRows=outputs)=>({dataset:{},style:nodes2Row().style,querySelectorAll(selector){if(selector===".lg-slot--input")return inputRows;if(selector===".lg-slot--output")return outputRows;return[]}});
|
||
let currentRoot=makeRoot();
|
||
const documentLike={querySelector:()=>currentRoot},node={id:"late",size:[320,108],properties:{},inputs:[{name:"flow",type:"*",link:null},{name:"processed_image",type:"IMAGE",link:null}],outputs:[{name:"final_image",type:"IMAGE",links:null}],widgets:[],graph:{afterChange(){}},setSize(size){writes++;this.size=[...size]}};
|
||
try{
|
||
installNodes2NodePresentation(node,"BV LUT Loop End",documentLike);assert.equal(observers.length,1);assert.equal(writes,0);
|
||
for(const row of[...inputs,...outputs])row.offsetHeight=20;
|
||
observers[0].callback();assert.equal(frames.length,1);frames.shift()();assert.deepEqual(node.size,[320,76]);assert.equal(writes,1);
|
||
observers[0].callback();assert.equal(frames.length,1);frames.shift()();assert.equal(writes,1);
|
||
for(const row of[...inputs,...outputs])row.offsetHeight=0;
|
||
observers[0].callback();frames.shift()();assert.deepEqual(node.size,[320,76]);assert.equal(node.properties.bvPresentationUserHeight,undefined);assert.equal(writes,1);
|
||
for(const row of[...inputs,...outputs])row.offsetHeight=20;
|
||
observers[0].callback();frames.shift()();assert.deepEqual(node.size,[320,76]);assert.equal(writes,1);
|
||
const replacementInputs=[nodes2IndexedRow("flow","replacement-in-0"),nodes2IndexedRow("processed_image","replacement-in-1")],replacementOutputs=[nodes2IndexedRow("final_image","replacement-out-0")];for(const row of[...replacementInputs,...replacementOutputs])row.offsetHeight=20;
|
||
currentRoot=makeRoot(replacementInputs,replacementOutputs);node.__bvApplyNodes2Presentation();assert.equal(observers.length,2);assert.equal(disconnected,1);assert.equal(currentRoot.style.getPropertyValue("--node-height"),"76px");assert.equal(writes,1);
|
||
removeNodes2NodePresentation(node);assert.equal(disconnected,2);
|
||
}finally{removeNodes2NodePresentation(node);for(const[key,value]of Object.entries(previous))value===undefined?delete globalThis[key]:globalThis[key]=value}
|
||
});
|
||
|
||
test("central LUT plan handles disconnected, connected, save and navigation states idempotently",async()=>{
|
||
let document=null,planOptions,switched=0,dirtyCalls=0;
|
||
class PlanNode{}
|
||
PlanNode.prototype.onNodeCreated=function(){return "plan-created"};
|
||
const targetAction={callback(){}};const target={id:9,title:"Other Plan",widgets:[{name:"configure_lut_plan",...targetAction}]};
|
||
installLutNodePresentation(PlanNode,{name:"BV Regional LUT Plan"},lutPresentationDeps({sourceDocument:()=>document,workflowNodesOfType:()=>[target],openPlan:options=>{planOptions=options},switchView:()=>{switched++}}));
|
||
const hidden={name:"config_json",value:'{"version":1,"jobs":[]}',callback(){}};
|
||
const node=new PlanNode();Object.assign(node,{id:3,comfyClass:"BV Regional LUT Plan",widgets:[hidden],inputs:[],outputs:[],graph:{nodes:[],setDirtyCanvas(){dirtyCalls++}},addWidget(_type,name,_value,callback){const widget={name,callback};this.widgets.push(widget);return widget}});node.graph.nodes=[node,target];
|
||
assert.equal(node.onNodeCreated(),"plan-created");await new Promise(resolve=>queueMicrotask(resolve));
|
||
const action=node.widgets.find(item=>item.name==="configure_lut_plan");assert.equal(action.label,"Connect a BV Regional Prompt");assert.equal(action.disabled,true);
|
||
document={regions:[{id:"r1",name:"Region 1",enabled:true}]};node.onConfigure?.();await new Promise(resolve=>queueMicrotask(resolve));
|
||
assert.equal(node.widgets.filter(item=>item.name==="configure_lut_plan").length,1);assert.equal(action.label,"Configure LUT Plan · 0 Jobs");assert.equal(action.disabled,false);
|
||
action.callback();assert.equal(planOptions.nodeId,"3");planOptions.save('{"version":1,"jobs":[]}');planOptions.onNavigate("9",true);assert.equal(switched,1);assert.ok(dirtyCalls>=2);
|
||
});
|
||
|
||
test("Nodes 2.0 projection is idempotent after its final visibility state",()=>{
|
||
let hidden=false,writes=0;
|
||
const row=nodes2Row("regional");
|
||
Object.defineProperty(row,"hidden",{get:()=>hidden,set:value=>{hidden=Boolean(value);writes++},configurable:true});
|
||
const element={dataset:{},querySelectorAll(selector){
|
||
if(selector===".lg-slot--output")return[row];
|
||
return[];
|
||
}};
|
||
const node={id:10,inputs:[],outputs:[{name:"regional",links:null}],widgets:[]};
|
||
const documentLike={querySelector:()=>element};
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
const settledWrites=writes;
|
||
projectNodes2NodePresentation(node,"BV Regional Prompt",documentLike,{legacyDebug:false});
|
||
assert.equal(writes,settledWrites);
|
||
});
|
||
|
||
test("Nodes 2.0 observer disconnects when its last managed node is removed",()=>{
|
||
const previous={document:globalThis.document,MutationObserver:globalThis.MutationObserver,requestAnimationFrame:globalThis.requestAnimationFrame,cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||
let disconnected=0;
|
||
class Observer{observe(){}disconnect(){disconnected++}}
|
||
const documentLike={querySelector:()=>null,getElementById:()=>null,createElement:()=>({}),head:{append(){}},body:{}};
|
||
globalThis.document=documentLike;
|
||
globalThis.MutationObserver=Observer;
|
||
globalThis.requestAnimationFrame=()=>1;
|
||
globalThis.cancelAnimationFrame=()=>{};
|
||
try{
|
||
const node={id:11};
|
||
installNodes2NodePresentation(node,"BV Regional Prompt",documentLike);
|
||
removeNodes2NodePresentation(node);
|
||
assert.equal(disconnected,1);
|
||
}finally{
|
||
for(const[key,value]of Object.entries(previous))value===undefined?delete globalThis[key]:globalThis[key]=value;
|
||
}
|
||
});
|
||
|
||
test("Nodes 2.0 observer reapplies Classic presentation when the DOM renderer disappears",()=>{
|
||
const previous={document:globalThis.document,MutationObserver:globalThis.MutationObserver,requestAnimationFrame:globalThis.requestAnimationFrame,cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||
let observerCallback,frameCallback,classic=0;
|
||
class Observer{constructor(callback){observerCallback=callback}observe(){}disconnect(){}}
|
||
const documentLike={querySelector:()=>null,getElementById:()=>null,createElement:()=>({}),head:{append(){}},body:{}};
|
||
globalThis.document=documentLike;
|
||
globalThis.MutationObserver=Observer;
|
||
globalThis.requestAnimationFrame=callback=>{frameCallback=callback;return 1};
|
||
globalThis.cancelAnimationFrame=()=>{};
|
||
try{
|
||
const node={id:12,graph:{},__bvApplyPresentation(){classic++}};
|
||
installNodes2NodePresentation(node,"BV Regional Detailer Plan",documentLike);
|
||
observerCallback();frameCallback();
|
||
assert.equal(classic,1);
|
||
observerCallback();frameCallback();
|
||
assert.equal(classic,1);
|
||
removeNodes2NodePresentation(node);
|
||
}finally{
|
||
for(const[key,value]of Object.entries(previous))value===undefined?delete globalThis[key]:globalThis[key]=value;
|
||
}
|
||
});
|
||
|
||
test("Nodes 2.0 observer releases graphless managed nodes",()=>{
|
||
const previous={document:globalThis.document,MutationObserver:globalThis.MutationObserver,requestAnimationFrame:globalThis.requestAnimationFrame,cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||
let observerCallback,frameCallback,disconnected=0;
|
||
class Observer{constructor(callback){observerCallback=callback}observe(){}disconnect(){disconnected++}}
|
||
const documentLike={querySelector:()=>null,getElementById:()=>null,createElement:()=>({}),head:{append(){}},body:{}};
|
||
globalThis.document=documentLike;globalThis.MutationObserver=Observer;
|
||
globalThis.requestAnimationFrame=callback=>{frameCallback=callback;return 1};globalThis.cancelAnimationFrame=()=>{};
|
||
try{
|
||
const node={id:13,graph:{}};
|
||
installNodes2NodePresentation(node,"BV Detector Registry",documentLike);
|
||
node.graph=null;observerCallback();frameCallback();
|
||
assert.equal(node.__bvApplyNodes2Presentation,undefined);
|
||
assert.equal(disconnected,1);
|
||
}finally{for(const[key,value]of Object.entries(previous))value===undefined?delete globalThis[key]:globalThis[key]=value}
|
||
});
|
||
|
||
test("managed Regional Prompt never receives a delayed projected-port resize",async()=>{
|
||
let writes=0;
|
||
const node={__bvPresentationManaged:true,setSize(){writes++}};
|
||
scheduleCompactLoraConsumerNode(node);
|
||
await new Promise(resolve=>setTimeout(resolve,175));
|
||
assert.equal(writes,0);
|
||
});
|
||
|
||
test("removing presentation cancels pending projected-port layout callbacks",async()=>{
|
||
let writes=0;
|
||
const provider={name:"resource_provider",type:"BV_RUNTIME_RESOURCE_PROVIDER",link:null};markProjectedProvider(provider);
|
||
const node={size:[280,160],properties:{},inputs:[provider],outputs:[],widgets:[],computeSize(){return[280,100]},setSize(size){writes++;this.size=[...size]}};
|
||
scheduleProjectedPortLayout(node);
|
||
removeNodePresentation(node);
|
||
await new Promise(resolve=>setTimeout(resolve,175));
|
||
assert.equal(writes,0);
|
||
assert.equal(node.__bvProjectedPortLayoutScheduled,undefined);
|
||
assert.equal(node.__bvRefreshProviderAnchors,undefined);
|
||
assert.equal(Object.hasOwn(provider,"pos"),false);
|
||
});
|
||
|
||
test('internal state widget inputs never become visible public anchors',()=>{
|
||
for(const surface of ['classic','nodes2','ghost']){
|
||
const result=resolveNodePresentation('BV Regional Prompt',{ports:[{direction:'input',name:'regional_json',type:'STRING'},{direction:'input',name:'canvas_image',type:'IMAGE'},{direction:'output',name:'regional_json',type:'STRING'}],widgets:[{name:'regional_json'}]},{surface,legacyDebug:true});
|
||
assert.equal(result.ports[0].role,'internalState');assert.equal(result.ports[0].visible,false);assert.equal(result.ports[1].visible,true);assert.equal(result.ports[2].visible,true);
|
||
}
|
||
});
|
||
test('internal state input hitboxes are gated even when provider predicate does not own them',()=>{
|
||
const slot={__bvPresentationRole:'internalState'},node={getSlotInPosition(){return{input:slot}}};
|
||
installProjectedPortInteraction(node,()=>false);assert.equal(node.getSlotInPosition(10,10),undefined);
|
||
slot.__bvPresentationRole='public';assert.equal(node.getSlotInPosition(10,10).input,slot);
|
||
});
|
||
|
||
test('overlapping internal inputs are transparent to native picking and restore geometry even on failure',()=>{
|
||
const internal={__bvPresentationRole:'internalState',boundingRect:[840,190,20,20]},image={name:'canvas_image',boundingRect:[840,194,20,20]};
|
||
const inputs=[internal,image];
|
||
const node={inputs,outputs:[],getInputPos(i){return[850,i?204:200]},getSlotInPosition(x,y){for(const [i,input]of this.inputs.entries()){const p=this.getInputPos(i);if(Math.abs(x-p[0])<=10&&Math.abs(y-p[1])<=10)return{input,slot:i}}}};
|
||
const getPos=node.getInputPos;
|
||
installProjectedPortInteraction(node,()=>false);
|
||
assert.equal(node.getSlotInPosition(850,200).input,image);
|
||
assert.equal(node.getSlotInPosition(850,200).slot,1);
|
||
assert.equal(node.getSlotInPosition(850,191),undefined);
|
||
let selected;
|
||
const canvas={pointer:{},_processNodeClick(e,_ctrl,node){selected=node.inputs.find(s=>e.canvasX>=s.boundingRect[0]&&e.canvasX<=s.boundingRect[0]+20&&e.canvasY>=s.boundingRect[1]&&e.canvasY<=s.boundingRect[1]+20);if(e.fail)throw Error('native failure')}};
|
||
installProjectedPortCanvasInteraction(canvas);
|
||
canvas._processNodeClick({canvasX:850,canvasY:200},false,node);assert.equal(selected,image);
|
||
assert.throws(()=>canvas._processNodeClick({canvasX:850,canvasY:200,fail:true},false,node),/native failure/);
|
||
assert.equal(node.inputs,inputs);assert.equal(node.getInputPos,getPos);assert.deepEqual(internal.boundingRect,[840,190,20,20]);assert.deepEqual(image.boundingRect,[840,194,20,20]);
|
||
});
|