mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
16 Commits
ext-api/i-
...
drjkl/worl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbad9e58b8 | ||
|
|
945f959259 | ||
|
|
d4268e50a8 | ||
|
|
207d3f5aad | ||
|
|
7c4b44db78 | ||
|
|
c07d893fc0 | ||
|
|
8d94e89e13 | ||
|
|
faed4f9599 | ||
|
|
243d584d33 | ||
|
|
91f22f4986 | ||
|
|
419ffcc60a | ||
|
|
5aef3900b3 | ||
|
|
6101a0d310 | ||
|
|
ec1f3333db | ||
|
|
e40982cd22 | ||
|
|
9a39207b52 |
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ProxyWidgetTuple,
|
||||
SerializedProxyWidgetTuple
|
||||
} from '@/core/schemas/promotionSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -386,7 +390,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
{ hostNodeId: string; promotedWidgets: SerializedProxyWidgetTuple[] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
@@ -401,15 +405,18 @@ export class SubgraphHelper {
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
(entry): entry is ProxyWidgetTuple =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
([sourceNodeId, serializedSourceWidgetName]) =>
|
||||
[
|
||||
sourceNodeId,
|
||||
serializedSourceWidgetName
|
||||
] satisfies SerializedProxyWidgetTuple
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
test.describe(
|
||||
'Multi-instance subgraph promoted widget rendering in Vue mode',
|
||||
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Each subgraph instance renders its own promoted widget value, not the interior default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectedByNodeId: Record<string, string> = {
|
||||
'11': 'Alpha\n',
|
||||
'12': 'Beta\n',
|
||||
'13': 'Gamma\n'
|
||||
}
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes(3)
|
||||
|
||||
for (const [nodeId, expectedValue] of Object.entries(expectedByNodeId)) {
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const textarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(textarea).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -14,6 +14,34 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const LEGACY_THREE_TUPLE_WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -498,4 +526,63 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy 3-tuple proxyWidgets entries serialize back to 2-tuples after load',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_THREE_TUPLE_WORKFLOW)
|
||||
|
||||
const hostNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(hostNode).toBeVisible()
|
||||
|
||||
const promotedTextbox = hostNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(promotedTextbox).toHaveCount(1)
|
||||
await expect(promotedTextbox).toHaveValue('22222222222')
|
||||
|
||||
await expect(hostNode.getByText('text', { exact: true })).toBeVisible()
|
||||
|
||||
const serializedProxyWidgets = await comfyPage.page.evaluate(() => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const hostNode = serialized.nodes.find((node) => node.id === 4)
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return Array.isArray(proxyWidgets) ? proxyWidgets : []
|
||||
})
|
||||
|
||||
expect(serializedProxyWidgets).toEqual([['3', '3: 2: text']])
|
||||
expect(
|
||||
serializedProxyWidgets.every(
|
||||
(entry) => Array.isArray(entry) && entry.length === 2
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -239,18 +239,19 @@ The design goal is to preserve ECS modularity while keeping render throughput wi
|
||||
|
||||
Companion architecture documents that expand on the design in this ADR:
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, and Bevy — patterns adopted, departed, when to revisit |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
279
docs/architecture/appendix-ecs-pattern-survey.md
Normal file
279
docs/architecture/appendix-ecs-pattern-survey.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Appendix: ECS Pattern Survey
|
||||
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, and Bevy — captured during the world-consolidation analysis that
|
||||
shipped slice 1 of [ADR 0008](../adr/0008-entity-component-system.md). This
|
||||
appendix records which structural patterns our `src/world/` substrate adopts,
|
||||
which it deliberately departs from, and where the trade-offs are load-bearing
|
||||
rather than incidental._
|
||||
|
||||
The in-code anchors for the load-bearing constraints discussed below are the
|
||||
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
|
||||
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
|
||||
contract) — see §3 below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Survey Comparison
|
||||
|
||||
Five libraries were sampled for structural patterns: where component
|
||||
definitions live relative to the substrate, how components are declared,
|
||||
how entities are identified, and roughly how large the substrate's public
|
||||
surface is. Sources: the linked READMEs and docs.
|
||||
|
||||
| Library | Component placement | Component definition style | Entity ID type | Approx. # core exports |
|
||||
| ------------------------------------------------- | ------------------------------------ | ----------------------------- | -------------------- | ----------------------: |
|
||||
| [bitECS](https://github.com/NateTheGreatt/bitECS) | Outside the substrate; user's choice | plain arrays / objects | `number` (unbranded) | ~12 |
|
||||
| [miniplex](https://github.com/hmans/miniplex) | Colocated with the `Entity` type | properties on a TS type | plain object ref | ~5 |
|
||||
| [koota](https://github.com/pmndrs/koota) | Colocated with the consumer | `trait({...})` factory | numeric `.id()` | ~15 (core) + ~8 (react) |
|
||||
| [ECSY](https://github.com/ecsyjs/ecsy) | User's choice | `class extends Component` | `Entity` object | ~10 |
|
||||
| [Bevy](https://bevyengine.org/) (Rust, for shape) | Plugin-owned (industry std) | `#[derive(Component)] struct` | `Entity(u64)` | n/a |
|
||||
|
||||
Two structural patterns are unanimous across the surveyed libraries:
|
||||
|
||||
1. **Component definitions live with the code that owns the data**, not
|
||||
inside the substrate package. Whether by explicit recommendation
|
||||
(Bevy plugins, koota's colocation guidance) or by default (bitECS,
|
||||
miniplex), no surveyed substrate ships pre-defined component types.
|
||||
2. **Substrate surface area is small** — bitECS at ~12 exports, koota at
|
||||
~15, miniplex at ~5. ECSY is the outlier with a wider class hierarchy.
|
||||
|
||||
Our slice-1 end state — five source files under
|
||||
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
|
||||
this band.
|
||||
|
||||
---
|
||||
|
||||
## 2. Patterns We Adopt
|
||||
|
||||
### 2.1 Substrate is deep; components live in domain code
|
||||
|
||||
The mainstream convention is that the ECS substrate exposes only the
|
||||
machinery — entities, component keys, a World — and component definitions
|
||||
live next to the system, store, or feature module that owns the data.
|
||||
This is the Bevy / miniplex / koota convention by design and the bitECS /
|
||||
ECSY convention by default.
|
||||
|
||||
Our substrate follows the same shape: `src/world/` contains entity-ID
|
||||
brands, the `ComponentKey` definition primitive, and the `World`
|
||||
interface, but no domain-specific component types. Slice 1 places
|
||||
`WidgetValueComponent` and `WidgetContainerComponent` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts),
|
||||
next to [widgetValueStore.ts](../../src/stores/widgetValueStore.ts) — the
|
||||
module that already owns widget value state.
|
||||
|
||||
This keeps the substrate / domain seam crisp: the World knows how to store
|
||||
and look up arbitrary components keyed by entity ID; the domain layer
|
||||
knows what a "widget value" is. It also aligns with the AGENTS.md DDD
|
||||
guidance to group code by bounded context. Future components follow the
|
||||
same rule — `PositionComponent`, when it lands, will live with the layout
|
||||
domain rather than inside the substrate.
|
||||
|
||||
### 2.2 Small public API
|
||||
|
||||
The substrate exports ~14 names — comparable to bitECS (~12) and koota
|
||||
(~15), much smaller than ECSY's class hierarchy. This is a deliberate
|
||||
target: every exported name is a contract a contributor must understand
|
||||
before extending the World, and every export is a potential migration
|
||||
cost when the substrate evolves.
|
||||
|
||||
The `Brand` / `EntityId` / `ComponentKey` / `World` / `worldInstance`
|
||||
split keeps each module single-purpose. `Brand<T,Tag>` is 5
|
||||
LOC and shared across all branded ID kinds. `ComponentKey<TData,TEntity>`
|
||||
carries a two-parameter phantom that enables cross-kind compile-time
|
||||
checking. `asGraphId` is a single named boundary cast. The two explicit
|
||||
factories `nodeEntityId` / `widgetEntityId` are kept rather than collapsed
|
||||
into a parameterized helper because slice 2/3/4 will add factories with
|
||||
different parameter tuples (`rerouteEntityId`, `linkEntityId`,
|
||||
`slotEntityId`); the explicit-factory pattern scales linearly with new
|
||||
entity kinds without growing the helper's signature.
|
||||
|
||||
### 2.3 Reactive bridging via existing storage proxy
|
||||
|
||||
bitECS, koota, and miniplex bolt on a separate `onChange` event bus when
|
||||
a consumer wants reactive notifications. koota's React layer
|
||||
(`useTrait(entity, ComponentKey)`) is the closest analog to what
|
||||
`useUpstreamValue` and future composables want.
|
||||
|
||||
Because our World stores values inside Vue's `reactive(Map<EntityId, ...>)`,
|
||||
a plain `computed(() => world.getComponent(id, key))` already provides
|
||||
fine-grained per-`(entity, component)` tracking — no separate event bus
|
||||
is needed. **This is a real Vue-specific advantage.** The Vue tracker and
|
||||
the ECS storage are the same mechanism, so reactivity falls out of the
|
||||
storage choice rather than being layered on top.
|
||||
|
||||
### 2.4 Brand-typed entity IDs
|
||||
|
||||
No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded
|
||||
`number`, miniplex uses plain object references, koota uses a numeric
|
||||
`.id()`. Our `Brand<T, Tag>` over each entity kind enables the
|
||||
type-level cross-kind isolation assertion in
|
||||
[world.test.ts](../../src/world/world.test.ts) and documents slice-2/3/4
|
||||
entity kinds at compile time.
|
||||
|
||||
This is a deliberate departure rather than an accident. It earns its keep
|
||||
once `Position` lands on `NodeEntityId | RerouteEntityId` (slice 2) and
|
||||
`Connectivity` lands on `SlotEntityId` (slice 4); without brands, those
|
||||
component-key declarations would accept any numeric ID and silently allow
|
||||
cross-kind misuse.
|
||||
|
||||
---
|
||||
|
||||
## 3. Patterns We Explicitly Do NOT Adopt
|
||||
|
||||
Each of the following is a real industry idiom we considered and rejected
|
||||
on load-bearing grounds. None of these are pure performance trade-offs.
|
||||
|
||||
### 3.1 Replace-on-write usage idioms
|
||||
|
||||
koota's `entity.set(Position, {...})` and miniplex's `world.add(entity)`
|
||||
**replace** component values with new objects on each write. Adopting
|
||||
either would break
|
||||
[BaseWidget.\_state](../../src/lib/litegraph/src/widgets/BaseWidget.ts)
|
||||
shared reactive identity — the contract that lets DOM widget overrides,
|
||||
`useProcessedWidgets` memoization, and the 40+ extension ecosystem all
|
||||
read the same proxy. Our `setComponent(id, key, ref)` stores by reference
|
||||
and the inner `reactive(Map)` keeps a stable cached proxy per
|
||||
entity-component pair: every `getComponent` returns the same proxy,
|
||||
regardless of how many writes intervene. `widgetValueStore.registerWidget`
|
||||
returns that proxy (not the caller's input ref), so `BaseWidget._state`
|
||||
and every other reader observe the same object. Replace-on-write idioms
|
||||
would swap the cached proxy on each write and break that stability —
|
||||
the reactive-identity test in
|
||||
[widgetValueStore.test.ts](../../src/stores/widgetValueStore.test.ts)
|
||||
locks in the contract.
|
||||
|
||||
### 3.2 SoA / archetype storage
|
||||
|
||||
bitECS, koota, and miniplex use sparse-set / archetype storage internally
|
||||
for cache locality. Our `reactive(Map<EntityId, unknown>)` is closer to
|
||||
ECSY's AoS — slower iteration but **integrates natively with Vue's
|
||||
tracking**.
|
||||
|
||||
The surface trade-off is performance; the deeper trade-off is identity.
|
||||
SoA storage spreads each component's fields across parallel typed arrays,
|
||||
so the per-entity "row object" is reconstructed on read. **A future
|
||||
migration to SoA would lose the proxy on the row object** — and with it
|
||||
the shared-reactive-identity contract that `BaseWidget._state` and the
|
||||
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
|
||||
just a perf optimization decision.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/world.ts](../../src/world/world.ts) — copied here for
|
||||
proximity:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* `setComponent` stores values by reference (no clone). The inner
|
||||
* `reactive(Map)` produces a single cached Vue proxy per entity-component
|
||||
* pair: every `getComponent` call returns the same proxy, and mutations
|
||||
* through it propagate to all readers. Note that the proxy is NOT `===`
|
||||
* to the raw object passed to `setComponent` — read through `getComponent`
|
||||
* (or a `registerWidget`-style helper that does so internally) and treat
|
||||
* that proxy as canonical.
|
||||
*
|
||||
* `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
|
||||
* invariant. Replace-on-write idioms (koota's `entity.set(...)`,
|
||||
* miniplex's `world.add(entity)`) would swap the cached proxy on each
|
||||
* write and break the contract; revisiting either consumer is required
|
||||
* before changing storage semantics.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.3 Auto-generated opaque entity IDs
|
||||
|
||||
bitECS and koota assume IDs are opaque numbers — `lastId++`, with no
|
||||
external structure. miniplex uses plain object references with the same
|
||||
property.
|
||||
|
||||
Our `widgetEntityId(rootGraphId, nodeId, name)` is **deterministic and
|
||||
content-addressed**. Consumers consistently pass `rootGraph.id`, so a
|
||||
widget viewed at different subgraph depths shares identity with itself.
|
||||
Migrating to opaque numeric IDs would break cross-subgraph value sharing —
|
||||
the same widget at depth 0 and depth 2 would receive different IDs and
|
||||
diverge.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/entityIds.ts](../../src/world/entityIds.ts):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Entity IDs are deterministic, content-addressed, and string-prefix
|
||||
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
|
||||
* consumers consistently pass `rootGraph.id` so widgets viewed at
|
||||
* different subgraph depths share identity. Migrating to numeric IDs
|
||||
* would break cross-subgraph value sharing. See ADR 0008 and
|
||||
* widgetValueStore for the canonical keying contract.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.4 Substrate-side parent/child relations
|
||||
|
||||
Bevy ships `Parent` / `Children` components at the substrate layer; Flecs
|
||||
ships first-class relations. These are useful when many subsystems need
|
||||
hierarchical traversal at storage-near speeds.
|
||||
|
||||
We treat hierarchical traversal as a domain-layer concern instead. The
|
||||
only structural relation slice 1 needs is `node → widgets` forward
|
||||
lookup, expressed as a domain component (`WidgetContainer.widgetIds` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts))
|
||||
and surfaced through `getNodeWidgets()` on the
|
||||
[widget value store](../../src/stores/widgetValueStore.ts). Reverse
|
||||
`widget → node` lookup is not modeled in the World at all today —
|
||||
existing call sites already hold a widget object and read `widget.node`
|
||||
directly via the `BaseWidget` back-reference, so no substrate-side
|
||||
parent component earns its keep yet. We may revisit this if multiple
|
||||
slices need a shared traversal API; until then, keeping hierarchy
|
||||
domain-local preserves the substrate's "no domain knowledge" property.
|
||||
|
||||
---
|
||||
|
||||
## 4. When to Revisit
|
||||
|
||||
The choices in §3 are deliberate but not eternal. Each has a revisit
|
||||
threshold.
|
||||
|
||||
**SoA / archetype storage.** The break-even point against `reactive(Map)`
|
||||
iteration is roughly **>10k entities per component** in steady-state hot
|
||||
paths. ComfyUI's projected widget count through slice 4 stays well under
|
||||
that. The watch signal is whether a render-loop or solver-loop pass
|
||||
demonstrably dominates frame time on `entitiesWith(WidgetValueComponent)`
|
||||
or any successor query — not just micro-benchmarks of `Map.get`.
|
||||
|
||||
If we cross that threshold, the migration is non-trivial: SoA loses the
|
||||
proxy on the row object (see §3.2), so a SoA World must either
|
||||
reconstruct proxies on read (defeating the perf gain) or move
|
||||
shared-identity reads back to a domain-side cache. ADR 0008's
|
||||
"Render-Loop Performance Implications and Mitigations" section already
|
||||
enumerates the planned mitigations (frame-stable query caches, archetype
|
||||
buckets, profiling-gated storage upgrades behind the World API).
|
||||
|
||||
**Replace-on-write idioms.** Revisitable only if the 40+ extension
|
||||
ecosystem moves off `BaseWidget._state` shared identity entirely — a
|
||||
separate, larger slice with explicit cost analysis (re-entry, DOM widget
|
||||
options.getValue overrides, `linkedWidgets` fan-out,
|
||||
`useProcessedWidgets` memoization invalidation), out of scope for the
|
||||
current ADR 0008 implementation.
|
||||
|
||||
**Opaque entity IDs.** Revisitable only if the cross-subgraph identity
|
||||
contract is dropped. Today widget value sharing across subgraph depths
|
||||
depends on it; slice 2 may extend the same contract to `nodeEntityId`
|
||||
for spatial reads. Until the product requirement changes, opaque IDs
|
||||
would be a regression.
|
||||
|
||||
**Substrate-side parent/child relations.** Revisitable when ≥2 subsystems
|
||||
need parent traversal. At one consumer it stays domain-local.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-References
|
||||
|
||||
- [ADR 0008 — Entity Component System](../adr/0008-entity-component-system.md)
|
||||
for the full target taxonomy and migration strategy.
|
||||
- [ECS Target Architecture](./ecs-target-architecture.md) for the full
|
||||
end-state shape.
|
||||
- [ECS Migration Plan](./ecs-migration-plan.md) for shipping milestones.
|
||||
- [Appendix: Critical Analysis](./appendix-critical-analysis.md) for the
|
||||
independent verification of the architecture documents.
|
||||
637
docs/architecture/entity-id-strategy.md
Normal file
637
docs/architecture/entity-id-strategy.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# Entity ID Strategy: Opaque IDs and Normalized Identity Components
|
||||
|
||||
_A design proposal for migrating all six entity kinds in the ECS taxonomy
|
||||
of [ADR 0008](../adr/0008-entity-component-system.md) — Node, Link, Widget,
|
||||
Slot, Reroute, Group — from primitive or content-addressed identifiers to
|
||||
**opaque UUIDs**, with identity data normalized into per-entity components
|
||||
and the necessary secondary indices owned by domain stores. This document
|
||||
is a follow-on to ADR 0008 and the [ECS Pattern Survey](appendix-ecs-pattern-survey.md).
|
||||
Status: proposed, pending sign-off before implementation._
|
||||
|
||||
The motivating defect is in
|
||||
[src/world/entityIds.ts](../../src/world/entityIds.ts) (the slice-1
|
||||
identity contract for widgets and node containers), but the design
|
||||
question generalizes: **identity should be a component, not encoded into
|
||||
the entity-ID string, and entity IDs should be opaque across all kinds.**
|
||||
This document defines that strategy uniformly so subsequent ECS migration
|
||||
slices (per the [migration plan](ecs-migration-plan.md)) extend the same
|
||||
pattern instead of recreating the choice for each kind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 What ADR 0008 specifies
|
||||
|
||||
ADR 0008 §"Branded ID Design" specifies branded **numeric** IDs for all
|
||||
six entity kinds:
|
||||
|
||||
```ts
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' }
|
||||
```
|
||||
|
||||
The ADR notes that widgets and slots currently lack independent IDs and
|
||||
proposes to assign synthetic IDs at entity creation time via an
|
||||
auto-incrementing counter (matching the pattern used by `lastNodeId`,
|
||||
`lastLinkId` in `LGraphState`).
|
||||
|
||||
### 1.2 What the slice-1 implementation actually did
|
||||
|
||||
The first ECS slice ([src/world/](../../src/world/)) covers **widgets**
|
||||
and **node containers**, and it departed from ADR 0008's "numeric ID"
|
||||
contract in two ways:
|
||||
|
||||
1. IDs are **strings**, not numbers (`Brand<string, ...>`).
|
||||
2. IDs are **content-addressed** — `widgetEntityId(g, n, w)` is computed
|
||||
from `(graphId, nodeId, widgetName)`, not minted from a counter:
|
||||
|
||||
```
|
||||
node:${graphId}:${nodeId}
|
||||
widget:${graphId}:${nodeId}:${name}
|
||||
```
|
||||
|
||||
The departure was deliberate — content-addressing gives "an entity viewed
|
||||
at different subgraph depths shares state" for free — but the rationale
|
||||
was never recorded in an architecture doc, and it leaves the wire format
|
||||
unauthorized by ADR 0008.
|
||||
|
||||
### 1.3 Three problems with the slice-1 scheme
|
||||
|
||||
1. **Silent corruption when nodeId contains a colon.**
|
||||
`parseWidgetEntityId`'s regex `/^widget:([^:]+):([^:]+):(.*)$/` captures
|
||||
`nodeId` as `[^:]+`, so a string nodeId like `"sg:42"` produces
|
||||
`widget:g:sg:42:name`, which the regex mis-parses as `(g, sg, 42:name)`.
|
||||
`widgetValueStore.getNodeWidgetsByName` then keys the returned `Map` by
|
||||
the wrong name. The defect is latent today — verified: every production
|
||||
producer stringifies a bare local NodeId — but the type
|
||||
`NodeId = number | string` makes no runtime guarantee, and any future
|
||||
migration toward `NodeLocatorId` (e.g. `<subgraph-uuid>:<id>`) trips
|
||||
the bug.
|
||||
|
||||
2. **Identity is the address.** Renaming a widget mints a new entity
|
||||
(different name → different `widgetEntityId`); rewriting `nodeId`
|
||||
mints a new entity. There is no concept of "the widget formerly known
|
||||
as X" — per-aspect components attach to the address-derived ID and
|
||||
are abandoned at rename. For features that need stable identity
|
||||
across relabels (promoted widgets, subgraph reparenting, CRDT
|
||||
replication of label changes), this is a structural mismatch.
|
||||
|
||||
3. **Identity is recovered by parsing.** The regex is the only path by
|
||||
which `getNodeWidgetsByName` knows which widget has which name. The
|
||||
data has no representation in the World — it lives only in the string.
|
||||
Every consumer that needs the name must round-trip through the
|
||||
parser.
|
||||
|
||||
### 1.4 Why this generalizes
|
||||
|
||||
The same three problems apply, in principle, to every other entity kind
|
||||
that lacks an independent ID. **Slot** identity today is `(parent node,
|
||||
direction, index)`; if we encoded that as a colon-joined string, the same
|
||||
parser hazards reappear. **Link** identity is `LinkId = number` (already
|
||||
opaque), but its endpoints `(origin_id, origin_slot, target_id,
|
||||
target_slot)` are content-shaped — and any identity migration will face
|
||||
the same "do we encode this into the ID, or normalize it into a
|
||||
component?" choice. The design decision is the same across all six
|
||||
kinds; this document makes it once.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Adopt the **opaque-entity-id + identity-as-component** pattern for all
|
||||
six entity kinds. The pattern matches what mainstream ECS libraries
|
||||
converge on (Flecs `(ChildOf, parent)` pairs, Bevy `Parent`+`Children`,
|
||||
koota `relation()`, EnTT `relationship.parent`, miniplex
|
||||
`entity.livesOn`); see §5 for the survey citations.
|
||||
|
||||
### 2.1 Entity IDs are opaque UUIDs across all kinds
|
||||
|
||||
```ts
|
||||
// src/world/entityIds.ts (proposed)
|
||||
export type NodeEntityId = Brand<string, 'NodeEntityId'>
|
||||
export type LinkEntityId = Brand<string, 'LinkEntityId'>
|
||||
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
export type SlotEntityId = Brand<string, 'SlotEntityId'>
|
||||
export type RerouteEntityId = Brand<string, 'RerouteEntityId'>
|
||||
export type GroupEntityId = Brand<string, 'GroupEntityId'>
|
||||
export type EntityId =
|
||||
| NodeEntityId
|
||||
| LinkEntityId
|
||||
| WidgetEntityId
|
||||
| SlotEntityId
|
||||
| RerouteEntityId
|
||||
| GroupEntityId
|
||||
|
||||
export const mintNodeId = (): NodeEntityId =>
|
||||
crypto.randomUUID() as NodeEntityId
|
||||
export const mintLinkId = (): LinkEntityId =>
|
||||
crypto.randomUUID() as LinkEntityId
|
||||
export const mintWidgetId = (): WidgetEntityId =>
|
||||
crypto.randomUUID() as WidgetEntityId
|
||||
export const mintSlotId = (): SlotEntityId =>
|
||||
crypto.randomUUID() as SlotEntityId
|
||||
export const mintRerouteId = (): RerouteEntityId =>
|
||||
crypto.randomUUID() as RerouteEntityId
|
||||
export const mintGroupId = (): GroupEntityId =>
|
||||
crypto.randomUUID() as GroupEntityId
|
||||
```
|
||||
|
||||
UUIDs (rather than numeric counters) for three reasons. First, no
|
||||
coordination required — `crypto.randomUUID` is universally available and
|
||||
collision-resistant without `LGraphState.lastWidgetId++` bookkeeping.
|
||||
Second, they're stable across CRDT replication (per ADR 0003) without an
|
||||
ID-mapping layer. Third, the `Brand<string, ...>` type machinery in
|
||||
[src/world/brand.ts](../../src/world/brand.ts) already phantom-types over
|
||||
`string`, so retaining string concrete types keeps zero friction with
|
||||
existing component declarations.
|
||||
|
||||
This **amends ADR 0008 §"Branded ID Design"**: the `& { readonly __brand: ... }`
|
||||
discipline is preserved, but the underlying type is `string` (UUID) for
|
||||
all kinds, not `number`.
|
||||
|
||||
**Deletions from current code:** `nodeEntityId`, `widgetEntityId`,
|
||||
`parseWidgetEntityId`, `graphNodePrefix`, `graphWidgetPrefix`,
|
||||
`isNodeIdForGraph`, `isWidgetIdForGraph`, the regex. The wire-format
|
||||
concept goes away.
|
||||
|
||||
**Litegraph-numeric IDs are preserved as data** — `LGraphState.lastNodeId`
|
||||
and friends continue to assign legacy numeric IDs that workflow JSON
|
||||
serialization depends on (per ADR 0008 §"The internal ECS model and the
|
||||
serialization format are deliberately separate concerns"). The legacy
|
||||
numeric ID becomes a field on the entity's identity component (§2.2),
|
||||
not the entity's identity itself.
|
||||
|
||||
### 2.2 Identity components per entity kind
|
||||
|
||||
Each entity kind gets a "BelongsTo"-style component carrying its
|
||||
structural relationships (parent pointers) and any legacy identifiers
|
||||
needed for serialization parity. This is the canonical "parent pointer
|
||||
on child" pattern; see §3.1 and §5.
|
||||
|
||||
#### Node
|
||||
|
||||
```ts
|
||||
export const NodeBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphNodeId: NodeId // legacy `LGraphNode.id`, kept for serialization
|
||||
},
|
||||
NodeEntityId
|
||||
>('NodeBelongsTo')
|
||||
```
|
||||
|
||||
Nodes belong to a graph (their containing `LGraph` or `Subgraph`).
|
||||
`litegraphNodeId` preserves the local-to-graph numeric ID that
|
||||
`workflowSchema.json` round-tripping requires.
|
||||
|
||||
#### Link
|
||||
|
||||
```ts
|
||||
export const LinkBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphLinkId: LinkId // legacy `LLink.id`
|
||||
},
|
||||
LinkEntityId
|
||||
>('LinkBelongsTo')
|
||||
|
||||
export const LinkEndpoints = defineComponentKey<
|
||||
{
|
||||
origin: SlotEntityId
|
||||
target: SlotEntityId
|
||||
type: ISlotType
|
||||
},
|
||||
LinkEntityId
|
||||
>('LinkEndpoints')
|
||||
```
|
||||
|
||||
`LinkEndpoints` (already proposed in ADR 0008 §"Component Decomposition")
|
||||
becomes the structural relationship; under the new scheme its fields are
|
||||
opaque `SlotEntityId`s, not `(node_id, slot_index)` tuples.
|
||||
|
||||
#### Widget
|
||||
|
||||
```ts
|
||||
export const WidgetBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
nodeId: NodeEntityId // parent pointer
|
||||
name: string // identity within the parent
|
||||
},
|
||||
WidgetEntityId
|
||||
>('WidgetBelongsTo')
|
||||
```
|
||||
|
||||
The widget-on-node parent pointer is the bug-relevant case. `name`
|
||||
identifies the widget within its parent node and replaces the
|
||||
parser-recovered name in `widgetEntityId`'s wire format.
|
||||
|
||||
#### Slot
|
||||
|
||||
```ts
|
||||
export const SlotBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
nodeId: NodeEntityId // parent pointer
|
||||
direction: 'input' | 'output'
|
||||
index: number // ordinal within the parent's input/output array
|
||||
name: string
|
||||
},
|
||||
SlotEntityId
|
||||
>('SlotBelongsTo')
|
||||
```
|
||||
|
||||
Slots are an extreme case of "identity is currently the address" —
|
||||
today's identity is literally `(node, direction, index)`, with the index
|
||||
shifting whenever a slot is inserted or removed. Under opaque UUIDs, slot
|
||||
identity persists across reorderings; the `index` field becomes a
|
||||
position in an ordered children list, not a name.
|
||||
|
||||
#### Reroute
|
||||
|
||||
```ts
|
||||
export const RerouteBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
parentRerouteId: RerouteEntityId | null // optional reroute parent
|
||||
litegraphRerouteId: RerouteId // legacy `Reroute.id`
|
||||
},
|
||||
RerouteEntityId
|
||||
>('RerouteBelongsTo')
|
||||
```
|
||||
|
||||
Reroutes can chain (`Reroute.parentId`); the parent-pointer pattern
|
||||
applies recursively. Reroutes also reference the link they belong to via
|
||||
`RerouteLinks` (ADR 0008 §"Reroute"); under opaque IDs that becomes a
|
||||
list of `LinkEntityId`s.
|
||||
|
||||
#### Group
|
||||
|
||||
```ts
|
||||
export const GroupBelongsTo = defineComponentKey<
|
||||
{
|
||||
graphId: GraphId
|
||||
litegraphGroupId: number // legacy `LGraphGroup` numeric id
|
||||
},
|
||||
GroupEntityId
|
||||
>('GroupBelongsTo')
|
||||
|
||||
export const GroupChildren = defineComponentKey<
|
||||
{ entityIds: (NodeEntityId | RerouteEntityId)[] },
|
||||
GroupEntityId
|
||||
>('GroupChildren')
|
||||
```
|
||||
|
||||
Groups own a heterogeneous children list (nodes and reroutes within
|
||||
their bounds). `GroupChildren` is the denormalized cache (§2.3); each
|
||||
member also carries an optional `MemberOf` back-pointer if downward
|
||||
iteration is hot (it isn't currently).
|
||||
|
||||
### 2.3 Children lists on parents — denormalized caches
|
||||
|
||||
Where downward iteration is hot, the parent gets a denormalized
|
||||
children-list component. The existing
|
||||
[`WidgetComponentContainer`](../../src/world/widgets/widgetComponents.ts)
|
||||
on the node side is the canonical example; this generalizes to:
|
||||
|
||||
| Parent | Children component | Hot path justifying it |
|
||||
| ------ | ------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| Node | `WidgetComponentContainer { widgetIds: [...] }` | Per-frame Vue-node rendering |
|
||||
| Node | `SlotChildren { inputs: [...], outputs: [...] }` | Connection drawing, hit-testing |
|
||||
| Graph | `GraphMembers { nodeIds: [...], linkIds: [...], rerouteIds: [...], groupIds: [...] }` | Top-level rendering, `clearGraph` |
|
||||
| Group | `GroupChildren { entityIds: [...] }` | Group-bounding-box render |
|
||||
| Link | `RerouteLinks { rerouteIds: [...] }` | Path rendering along reroute chain |
|
||||
|
||||
These are caches **derived from** the per-child `*BelongsTo` parent
|
||||
pointer. They exist for performance and are maintained by the same
|
||||
mutation API that owns the parent pointer (§2.4); callers outside that
|
||||
API never write to them directly. This is the Bevy
|
||||
`Parent`+`Children`-symmetric-management discipline.
|
||||
|
||||
### 2.4 Secondary indices live in domain stores
|
||||
|
||||
Under opaque UUIDs, the question "given some content-shaped key, find
|
||||
the entity" is no longer answered by computing a string. Each domain
|
||||
store keeps a private forward index whose key is built with
|
||||
`makeCompositeKey` (already in
|
||||
[src/utils/compositeKey.ts](../../src/utils/compositeKey.ts)) so the
|
||||
encoding is injective by construction:
|
||||
|
||||
| Store | Forward index | Key shape |
|
||||
| --------------------- | -------------------- | ------------------------------------------- |
|
||||
| `widgetValueStore` | `widgetByAddress` | `ckey([graphId, nodeId, widgetName])` |
|
||||
| `widgetValueStore` | `nodeByAddress` | `ckey([graphId, litegraphNodeId])` |
|
||||
| (slot store, future) | `slotByAddress` | `ckey([graphId, nodeId, direction, index])` |
|
||||
| (link store, future) | `linkByLitegraphId` | `ckey([graphId, litegraphLinkId])` |
|
||||
| (group store, future) | `groupByLitegraphId` | `ckey([graphId, litegraphGroupId])` |
|
||||
|
||||
Plus per-graph entity sets for O(set-size) bulk clear, replacing the
|
||||
slice-1 `isWidgetIdForGraph` startsWith-scan:
|
||||
|
||||
```ts
|
||||
const widgetsByGraph = new Map<GraphId, Set<WidgetEntityId>>()
|
||||
const nodesByGraph = new Map<GraphId, Set<NodeEntityId>>()
|
||||
// (and analogous per-kind sets in each store as kinds migrate)
|
||||
```
|
||||
|
||||
All indices are private and mutated only by the owning store's public
|
||||
methods (`registerWidget` / `clearGraph` / a future `unregisterWidget`).
|
||||
This is the centralized-mutation discipline that Bevy's hierarchy
|
||||
plugin enforces through `Commands` extension methods — and that the ECS
|
||||
literature unanimously identifies as the precondition for safely
|
||||
denormalizing hierarchy state.
|
||||
|
||||
### 2.5 Surface area summary
|
||||
|
||||
| Layer | Primitive | Role |
|
||||
| ----------------------------- | --------------------------------------------- | -------------------------------------- |
|
||||
| `entityIds.ts` | `mint{Node,Link,Widget,Slot,Reroute,Group}Id` | Opaque ID generation |
|
||||
| domain `*Components.ts` files | `*BelongsTo` components | Parent pointers (identity) |
|
||||
| domain `*Components.ts` files | `*Children` / `*Container` components | Denormalized caches |
|
||||
| domain stores | `*ByAddress` indices | Forward content-address lookup |
|
||||
| domain stores | `*ByGraph` indices | Per-graph bulk clear |
|
||||
| domain store public methods | `register*` / `clearGraph` / `unregister*` | Sole writers to indices and components |
|
||||
|
||||
---
|
||||
|
||||
## 3. Why this is the right shape
|
||||
|
||||
### 3.1 It's what the ECS literature converges on
|
||||
|
||||
Three independent surveys (Flecs/Bevy/EnTT, koota/miniplex, and
|
||||
normalization-tradeoff literature) agree on the same finding:
|
||||
**store the parent pointer on the child, add a denormalized children
|
||||
list on the parent only when downward iteration is hot, and centralize
|
||||
all mutations behind a small API**. This document proposes exactly that
|
||||
pattern — uniformly across all six entity kinds.
|
||||
|
||||
### 3.2 It removes parsers as an attack surface — for every kind
|
||||
|
||||
`parseWidgetEntityId` does not exist in the new scheme. The temptation
|
||||
to write a `parseSlotEntityId` (which would face the same hazards on
|
||||
slot names) is removed before it appears. Identity is read structurally
|
||||
from components.
|
||||
|
||||
### 3.3 It decouples address from identity
|
||||
|
||||
Today, `widget(g, n, "old-name")` and `widget(g, n, "new-name")` are
|
||||
different entities. Slot reordering today changes slot identity
|
||||
(because identity = ordinal index). Reparenting today rotates entity IDs
|
||||
across graphs. Under opaque UUIDs none of these mutations rotates
|
||||
identity — they're component edits. This unlocks features that need
|
||||
identity continuity (promoted-widget identity preservation,
|
||||
slot-reorder-without-reconnection, label-swapping in CRDT replication)
|
||||
without touching the substrate.
|
||||
|
||||
### 3.4 It aligns with ADRs 0003 and 0008
|
||||
|
||||
ADR 0003's command pattern requires that all mutations be serializable
|
||||
and replayable. Opaque UUIDs are easier to serialize coherently — they
|
||||
don't carry a graphId-prefix that needs rewriting on subgraph remount,
|
||||
and they don't depend on the serialization format encoding identity
|
||||
tuples identically across versions. Component-resident identity makes
|
||||
command shapes ("set `WidgetBelongsTo.name` on `<uuid>`") trivially
|
||||
expressible.
|
||||
|
||||
ADR 0008's "world is the source of truth, serialization is a
|
||||
translation" principle is reinforced: the World holds opaque UUIDs and
|
||||
identity components; the SerializationSystem maps them to/from the
|
||||
`workflowSchema.json` format that uses litegraph-numeric IDs.
|
||||
|
||||
### 3.5 It absorbs the colon-collision fix as a side effect
|
||||
|
||||
The bug that motivates this document is solved twice over by the design:
|
||||
the parser is gone, AND the address index uses `makeCompositeKey`
|
||||
(already injective). No assert, no regex, no caveat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Costs and risks
|
||||
|
||||
### 4.1 Index hygiene — across more stores
|
||||
|
||||
For each migrated kind, the per-store invariant is the same: indices
|
||||
must stay in lockstep with the World. The discipline that makes this
|
||||
safe is API-confinement: **all mutation goes through the owning store's
|
||||
public methods**. Direct `world.setComponent` calls bypass the indices
|
||||
and corrupt them.
|
||||
|
||||
Mitigations (apply per-store):
|
||||
|
||||
- Document the contract in the file's top doc-comment.
|
||||
- Add a unit test that asserts the indices match the World contents
|
||||
after a randomized sequence of register/clear operations.
|
||||
- Optionally, add a debug-build-only `world.assertConsistentIndices()`
|
||||
check.
|
||||
|
||||
### 4.2 Debuggability of opaque IDs
|
||||
|
||||
UUIDs in stack traces require reading the corresponding `*BelongsTo`
|
||||
component to recover the human-meaningful identity. This applies to
|
||||
every kind, not just widgets.
|
||||
|
||||
Mitigations:
|
||||
|
||||
- Provide a `describeEntity(id)` helper per store that returns the
|
||||
identity tuple for logging.
|
||||
- Vue DevTools / Pinia inspector should surface the `*BelongsTo`
|
||||
components for registered entities.
|
||||
|
||||
### 4.3 Migration effort, by kind
|
||||
|
||||
The slices below mirror the [migration plan](ecs-migration-plan.md)'s
|
||||
incremental approach. Each kind ships independently:
|
||||
|
||||
| Kind | Status today | Migration cost |
|
||||
| -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Widget | In World (slice 1, content-addressed strings) | High: rewrite `entityIds.ts`, `widgetValueStore.ts`, ~6 test files. ~+250/-80 prod, ~+100 test. |
|
||||
| Node container | In World (slice 1, content-addressed strings) | Bundled with widget migration; same surface area. |
|
||||
| Slot | Not in World; lives on `LGraphNode.{inputs,outputs}` | Per ADR 0008 migration plan. New: `slotEntityId`, `SlotBelongsTo`, slot store. |
|
||||
| Link | Not in World; lives on `LGraph.links` | New: `linkEntityId`, `LinkBelongsTo`, `LinkEndpoints` (slot-keyed). |
|
||||
| Reroute | Not in World; lives on `LGraph.reroutes` | New: `rerouteEntityId`, `RerouteBelongsTo`, `RerouteLinks`. |
|
||||
| Group | Not in World; lives on `LGraph._groups` | New: `groupEntityId`, `GroupBelongsTo`, `GroupChildren`. |
|
||||
|
||||
The widget+node migration is the only slice that touches existing World
|
||||
code and existing tests. All other kinds are greenfield extensions — the
|
||||
strategy is fixed by this document so each slice executes mechanically
|
||||
without re-litigating identity-format choices.
|
||||
|
||||
### 4.4 Re-registration after `clearGraph`
|
||||
|
||||
If `clearGraph(g)` cleans World components but leaves stale entries in
|
||||
the per-store address indices, a subsequent `register*(g, ...)` returns
|
||||
an old UUID and re-binds components on a "ghost" entity. This is
|
||||
functionally equivalent to today's content-addressed re-use, but the
|
||||
discipline must be enforced: **`clearGraph(g)` MUST clear all index
|
||||
entries for graph `g` before returning.** Per-store unit tests should
|
||||
include a `clear → re-register` round-trip case.
|
||||
|
||||
### 4.5 Tests that hand-construct entity IDs
|
||||
|
||||
Slice-1 tests synthesize `WidgetEntityId`/`NodeEntityId` strings directly
|
||||
(e.g. `defineComponentKey<...>` typing assertions in
|
||||
[world.test.ts](../../src/world/world.test.ts)). These will need to mint
|
||||
UUIDs instead. The change is mechanical but touches several test files
|
||||
and will recur for each subsequent slice.
|
||||
|
||||
### 4.6 Litegraph-numeric ID coexistence
|
||||
|
||||
`workflowSchema.json` round-tripping requires preserving the numeric IDs
|
||||
emitted by `LGraphState.lastNodeId++` etc. Under opaque-UUID identity
|
||||
those numbers become **data** on the `*BelongsTo` component
|
||||
(`litegraphNodeId`, `litegraphLinkId`, `litegraphGroupId`,
|
||||
`litegraphRerouteId`). The SerializationSystem reads them when emitting
|
||||
JSON and assigns matching values when re-hydrating. This is the same
|
||||
"World ID ≠ wire ID" decoupling that ADR 0008 requires.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-references
|
||||
|
||||
### 5.1 ECS library survey
|
||||
|
||||
Detailed comparison in [appendix-ecs-pattern-survey.md](appendix-ecs-pattern-survey.md).
|
||||
The pattern this document proposes — opaque IDs + parent-pointer-on-child
|
||||
|
||||
- denormalized children + centralized mutation — is what koota,
|
||||
miniplex, EnTT, and Bevy all converge on for hierarchy modeling. Flecs
|
||||
goes further by encoding the parent into the archetype, which JS ECSes
|
||||
don't have access to without significant substrate work.
|
||||
|
||||
### 5.2 Source citations for §2 and §3.1
|
||||
|
||||
- Flecs hierarchies and pairs:
|
||||
[Hierarchies Manual](https://www.flecs.dev/flecs/md_docs_2HierarchiesManual.html),
|
||||
[Relationships Manual](https://www.flecs.dev/flecs/md_docs_2Relationships.html)
|
||||
- Bevy parent/children components:
|
||||
[bevy_hierarchy docs](https://docs.rs/bevy_hierarchy),
|
||||
[Bevy Cheat Book hierarchy](https://bevy-cheatbook.github.io/fundamentals/hierarchy.html)
|
||||
- EnTT relationship guidance:
|
||||
skypjack, [ECS back and forth, part 4](https://skypjack.github.io/2019-09-25-ecs_baf_part_4/)
|
||||
- koota relation primitive:
|
||||
[koota/relation source](https://github.com/pmndrs/koota/blob/main/packages/core/src/relation/relation.ts)
|
||||
- miniplex entity-reference pattern:
|
||||
[miniplex README](https://github.com/hmans/miniplex)
|
||||
- Normalization tradeoffs:
|
||||
Mertens, [Building Games in ECS with Entity Relationships](https://ajmmertens.medium.com/building-games-in-ecs-with-entity-relationships-657275ba2c6c);
|
||||
IceFall Games,
|
||||
[Managing game object hierarchy in an ECS](https://mtnphil.wordpress.com/2014/06/09/managing-game-object-hierarchy-in-an-entity-component-system/)
|
||||
|
||||
### 5.3 Related ADRs and architecture docs
|
||||
|
||||
- [ADR 0003: Centralized Layout Management with CRDT](../adr/0003-crdt-based-layout-system.md) —
|
||||
command-pattern requirement that opaque IDs simplify
|
||||
- [ADR 0008: Entity Component System](../adr/0008-entity-component-system.md) —
|
||||
this document amends §"Branded ID Design" by reinterpreting "numeric"
|
||||
as "opaque UUID string" across all six entity kinds, and adds
|
||||
`*BelongsTo` identity components to §"Component Decomposition"
|
||||
- [ECS Pattern Survey](appendix-ecs-pattern-survey.md) — substrate-level
|
||||
comparison
|
||||
- [ECS Lifecycle Scenarios](ecs-lifecycle-scenarios.md) — concrete
|
||||
before/after walkthroughs that this document's identity model affects
|
||||
- [ECS Migration Plan](ecs-migration-plan.md) — the slice progression
|
||||
this document's per-kind sections track against
|
||||
- [ECS Target Architecture](ecs-target-architecture.md) — the world-shape
|
||||
this document refines for identity
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration plan
|
||||
|
||||
Two horizons: a near-term implementation slice that ships now, and a
|
||||
strategy locked in for subsequent ADR 0008 slices.
|
||||
|
||||
### 6.1 Near-term: widgets and node containers (slice 1.5)
|
||||
|
||||
Ship as a single PR after the colon-collision consolidation
|
||||
([entityIds-consolidation.md](../../temp/plans/entityIds-consolidation.md))
|
||||
lands and is verified. Sequencing rationale: the consolidation is a
|
||||
defensive 1-file fix that does not commit to this larger architectural
|
||||
direction. If this proposal is rejected or amended, the consolidation
|
||||
still stands as a strict improvement over the current state.
|
||||
|
||||
Phases inside the PR (single commit boundary, but reviewable as separate
|
||||
hunks):
|
||||
|
||||
1. Add `WidgetBelongsTo`, `NodeBelongsTo` components and the four
|
||||
indices in `widgetValueStore`. Populate them in `registerWidget`
|
||||
alongside the existing entity-ID derivation. The string-format
|
||||
`widgetEntityId` continues to exist; the new components are written
|
||||
redundantly. All tests still pass.
|
||||
2. Switch `getWidget` / `getNodeWidgets` / `getNodeWidgetsByName` to
|
||||
read identity from `WidgetBelongsTo` instead of parsing the entity
|
||||
ID. Delete `parseWidgetEntityId`. Delete the regex.
|
||||
3. Switch `clearGraph` to consume `widgetsByGraph` / `nodesByGraph`
|
||||
instead of `entitiesWith` + `isWidgetIdForGraph`. Delete the
|
||||
`isXForGraph` helpers.
|
||||
4. Replace `widgetEntityId` / `nodeEntityId` constructors with
|
||||
`mintWidgetId` / `mintNodeId`. Switch the entity-ID format from
|
||||
content-addressed strings to UUIDs.
|
||||
5. Update test fixtures that hand-construct entity-ID strings.
|
||||
|
||||
Each phase ends in a green test suite. Quality gates:
|
||||
`pnpm test:unit && pnpm typecheck && pnpm lint && pnpm knip`.
|
||||
|
||||
### 6.2 Subsequent slices: slots, links, reroutes, groups
|
||||
|
||||
Per the [migration plan](ecs-migration-plan.md), each remaining kind
|
||||
moves into the World in its own slice. For each, the strategy is fixed
|
||||
by this document:
|
||||
|
||||
1. Add the kind's `mint*Id` to `entityIds.ts` and the `*EntityId` brand.
|
||||
2. Define the `*BelongsTo` identity component plus any `*Children` /
|
||||
`*Endpoints` structural components per §2.2.
|
||||
3. Create (or extend) the domain store that owns the per-graph and
|
||||
forward-address indices, with the centralized-mutation API discipline.
|
||||
4. Add the bridge layer (per ADR 0008 §"Migration Strategy" step 2) so
|
||||
existing OOP consumers continue to read from the litegraph class
|
||||
while ECS readers read from the World.
|
||||
5. Migrate consumers piecewise per ADR 0008 §"Migration Strategy"
|
||||
steps 3–5.
|
||||
|
||||
No new identity-format decisions are made per slice. The colon-collision
|
||||
risk class is closed for the entire ECS, not just for widgets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
1. **Should widgets and node containers ship in one PR or two?** Widget
|
||||
identity is the bug-relevant case; node identity is symmetric but not
|
||||
load-bearing. Splitting reduces review surface but leaves the
|
||||
substrate temporarily inconsistent (widgets opaque, node containers
|
||||
content-addressed). Recommendation: one PR, since both live in
|
||||
`widgetValueStore` and the indices interlock.
|
||||
|
||||
2. **Should `*BelongsTo.name` / `*BelongsTo.index` be mutable post-creation?**
|
||||
Today, rename mints a new entity; reorder rotates slot identity.
|
||||
Under opaque IDs these mutations could either (a) preserve identity
|
||||
(mutate component, rewrite address index) or (b) preserve current
|
||||
semantics (delete + remint). (a) is the new capability this refactor
|
||||
unlocks, but adopting it changes downstream behavior; should be a
|
||||
deliberate follow-up per kind.
|
||||
|
||||
3. **Do we want explicit `Is{Widget,Node,Slot,…}` marker components?**
|
||||
koota uses tag traits this way so `world.entitiesWith(IsWidget)` is
|
||||
the canonical "iterate all widgets" query, replacing the implicit
|
||||
`entitiesWith(WidgetComponentValue)` pattern. Optional ergonomic
|
||||
improvement, not required by the bug fix; decide per slice.
|
||||
|
||||
4. **Where does name/ordinal uniqueness within a parent belong?**
|
||||
For widgets: `(graphId, nodeId, name)` uniqueness is enforced by
|
||||
`widgetByAddress`. For slots: `(graphId, nodeId, direction, index)`
|
||||
uniqueness is enforced by `slotByAddress`. We should decide per kind
|
||||
whether second-registration is a no-op (`getOrRegister` semantics),
|
||||
an overwrite, or an error.
|
||||
|
||||
5. **Counter-based vs UUID-based minting.** ADR 0008 originally proposed
|
||||
counter-based (`lastWidgetId++`). UUIDs are simpler (no shared state,
|
||||
no CRDT mapping) but slightly larger and unpleasant in stack traces.
|
||||
Recommendation: UUIDs for now (matching this document's §2.1); revisit
|
||||
if profiling or debuggability demands otherwise.
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
@@ -187,7 +188,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
|
||||
it('updates slotMetadata for promoted widgets where SafeWidgetData.displayName differs from input.widget.name', async () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
@@ -201,7 +202,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
// sets SafeWidgetData.displayName to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
@@ -224,7 +225,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// SafeWidgetData.displayName is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
@@ -471,10 +472,15 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.storeName).toBe('picker')
|
||||
expect(mappedWidget?.storeNodeId).toBe(
|
||||
expect(mappedWidget?.name).toBe('picker')
|
||||
expect(mappedWidget?.sourceNodeLocatorId).toBe(
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
expect(mappedWidget?.source).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'picker',
|
||||
disambiguatingSourceNodeId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
@@ -516,7 +522,7 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
@@ -580,7 +586,7 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
||||
@@ -590,6 +596,98 @@ describe('Nested promoted widget mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeWidgetMapper per-instance widget identity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('populates nodeId with the SubgraphNode instance id and instanceWidgetName with the view storeName for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
interiorNode.addWidget('text', 'text', '', () => undefined, {})
|
||||
interiorInput.widget = { name: 'text' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedView = subgraphNode.widgets[0]
|
||||
if (!promotedView || !isPromotedWidgetView(promotedView)) {
|
||||
throw new Error('Expected first widget to be a promoted view')
|
||||
}
|
||||
const expectedStoreName = promotedView.storeName
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(String(subgraphNode.id))
|
||||
?.widgets?.find((w) => w.name === 'text')
|
||||
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.nodeId).toBe(String(subgraphNode.id))
|
||||
expect(widgetData?.instanceWidgetName).toBe(expectedStoreName)
|
||||
})
|
||||
|
||||
it('does not set nodeId or instanceWidgetName for non-promoted widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(String(node.id))
|
||||
?.widgets?.find((w) => w.name === 'steps')
|
||||
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.nodeId).toBeUndefined()
|
||||
expect(widgetData?.instanceWidgetName).toBeUndefined()
|
||||
})
|
||||
|
||||
it('produces distinct nodeId values for two SubgraphNode instances of one definition', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
interiorNode.addWidget('text', 'text', '', () => undefined, {})
|
||||
interiorInput.widget = { name: 'text' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const instanceA = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
instanceA._internalConfigureAfterSlots()
|
||||
const graph = instanceA.graph as LGraph
|
||||
graph.add(instanceA)
|
||||
|
||||
const instanceB = createTestSubgraphNode(subgraph, {
|
||||
id: 200,
|
||||
parentGraph: graph
|
||||
})
|
||||
instanceB._internalConfigureAfterSlots()
|
||||
graph.add(instanceB)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetA = vueNodeData
|
||||
.get(String(instanceA.id))
|
||||
?.widgets?.find((w) => w.name === 'text')
|
||||
const widgetB = vueNodeData
|
||||
.get(String(instanceB.id))
|
||||
?.widgets?.find((w) => w.name === 'text')
|
||||
|
||||
expect(widgetA?.nodeId).toBe('100')
|
||||
expect(widgetB?.nodeId).toBe('200')
|
||||
// Both share the same definition so instanceWidgetName matches —
|
||||
// only nodeId distinguishes them.
|
||||
expect(widgetA?.instanceWidgetName).toBe(widgetB?.instanceWidgetName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface SafeWidgetData {
|
||||
storeNodeId?: NodeId
|
||||
name: string
|
||||
storeName?: string
|
||||
instanceWidgetName?: string
|
||||
source?: PromotedWidgetSource
|
||||
sourceNodeLocatorId?: string
|
||||
type: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
|
||||
@@ -66,7 +66,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getNodeWidgets: vi.fn(() => [])
|
||||
getNodeWidgets: vi.fn(() => []),
|
||||
getNodeWidgetsByName: vi.fn(() => new Map())
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { resetWorldInstance } from '@/world/worldInstance'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
import {
|
||||
boundsExtractor,
|
||||
singleValueExtractor,
|
||||
useUpstreamValue
|
||||
} from './useUpstreamValue'
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: { rootGraph: { id: '00000000-0000-0000-0000-000000000001' } }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function widgetState(value: unknown): WidgetState {
|
||||
return {
|
||||
type: 'INPUT',
|
||||
value,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: undefined,
|
||||
disabled: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function widgetMap(
|
||||
...entries: Array<[string, unknown]>
|
||||
): Map<string, WidgetState> {
|
||||
return new Map(entries.map(([name, value]) => [name, widgetState(value)]))
|
||||
}
|
||||
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
@@ -15,37 +45,37 @@ describe('singleValueExtractor', () => {
|
||||
const extract = singleValueExtractor(isNumber)
|
||||
|
||||
it('matches widget by outputName', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 42])
|
||||
expect(extract(widgets, 'b')).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when outputName widget has invalid value', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 'not a number'])
|
||||
expect(extract(widgets, 'b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when outputName has no match', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 42])
|
||||
expect(extract(widgets, 'missing')).toBe(42)
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when no outputName provided', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 42])
|
||||
expect(extract(widgets, undefined)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when multiple widgets have valid values', () => {
|
||||
const widgets = [widget('a', 1), widget('b', 2)]
|
||||
const widgets = widgetMap(['a', 1], ['b', 2])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when no widgets have valid values', () => {
|
||||
const widgets = [widget('a', 'text')]
|
||||
const widgets = widgetMap(['a', 'text'])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
expect(extract(new Map(), undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,23 +84,23 @@ describe('boundsExtractor', () => {
|
||||
|
||||
it('extracts a single bounds object widget', () => {
|
||||
const bounds = { x: 10, y: 20, width: 100, height: 200 }
|
||||
const widgets = [widget('crop', bounds)]
|
||||
const widgets = widgetMap(['crop', bounds])
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('matches bounds widget by outputName', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [widget('other', 'text'), widget('crop', bounds)]
|
||||
const widgets = widgetMap(['other', 'text'], ['crop', bounds])
|
||||
expect(extract(widgets, 'crop')).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('assembles bounds from individual x/y/width/height widgets', () => {
|
||||
const widgets = [
|
||||
widget('x', 10),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
const widgets = widgetMap(
|
||||
['x', 10],
|
||||
['y', 20],
|
||||
['width', 100],
|
||||
['height', 200]
|
||||
)
|
||||
expect(extract(widgets, undefined)).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
@@ -80,39 +110,74 @@ describe('boundsExtractor', () => {
|
||||
})
|
||||
|
||||
it('returns undefined when some bound components are missing', () => {
|
||||
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
|
||||
const widgets = widgetMap(['x', 10], ['y', 20], ['width', 100])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when bound components have wrong types', () => {
|
||||
const widgets = [
|
||||
widget('x', '10'),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
const widgets = widgetMap(
|
||||
['x', '10'],
|
||||
['y', 20],
|
||||
['width', 100],
|
||||
['height', 200]
|
||||
)
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
expect(extract(new Map(), undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects partial bounds objects', () => {
|
||||
const partial = { x: 10, y: 20 }
|
||||
const widgets = [widget('crop', partial)]
|
||||
const widgets = widgetMap(['crop', partial])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers single bounds object over individual widgets', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [
|
||||
widget('crop', bounds),
|
||||
widget('x', 99),
|
||||
widget('y', 99),
|
||||
widget('width', 99),
|
||||
widget('height', 99)
|
||||
]
|
||||
const widgets = widgetMap(
|
||||
['crop', bounds],
|
||||
['x', 99],
|
||||
['y', 99],
|
||||
['width', 99],
|
||||
['height', 99]
|
||||
)
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpstreamValue (store-backed read path)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetWorldInstance()
|
||||
})
|
||||
|
||||
it('reads upstream node widgets via the widget value store', () => {
|
||||
const graphId = '00000000-0000-0000-0000-000000000001' as UUID
|
||||
const state = useWidgetValueStore().registerWidget(graphId, {
|
||||
nodeId: 'upstream-1' as NodeId,
|
||||
name: 'value',
|
||||
type: 'number',
|
||||
value: 7,
|
||||
options: {}
|
||||
})
|
||||
|
||||
const upstreamValue = useUpstreamValue<number>(
|
||||
() => ({ nodeId: 'upstream-1', outputName: 'value' }),
|
||||
singleValueExtractor((v): v is number => typeof v === 'number')
|
||||
)
|
||||
|
||||
expect(upstreamValue.value).toBe(7)
|
||||
state.value = 11
|
||||
expect(upstreamValue.value).toBe(11)
|
||||
})
|
||||
|
||||
it('returns undefined when no upstream linkage is provided', () => {
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => undefined,
|
||||
singleValueExtractor((v): v is number => typeof v === 'number')
|
||||
)
|
||||
expect(upstreamValue.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
widgets: Map<string, WidgetState>,
|
||||
outputName: string | undefined
|
||||
) => T | undefined
|
||||
|
||||
@@ -23,7 +23,10 @@ export function useUpstreamValue<T>(
|
||||
if (!upstream) return undefined
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
if (!graphId) return undefined
|
||||
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
|
||||
const widgets = widgetValueStore.getNodeWidgetsByName(
|
||||
graphId,
|
||||
upstream.nodeId
|
||||
)
|
||||
return extractValue(widgets, upstream.outputName)
|
||||
})
|
||||
}
|
||||
@@ -33,10 +36,12 @@ export function singleValueExtractor<T>(
|
||||
): ValueExtractor<T> {
|
||||
return (widgets, outputName) => {
|
||||
if (outputName) {
|
||||
const matched = widgets.find((w) => w.name === outputName)
|
||||
const matched = widgets.get(outputName)
|
||||
if (matched && isValid(matched.value)) return matched.value
|
||||
}
|
||||
const validValues = widgets.map((w) => w.value).filter(isValid)
|
||||
const validValues = [...widgets.values()]
|
||||
.map((w) => w.value)
|
||||
.filter(isValid)
|
||||
return validValues.length === 1 ? validValues[0] : undefined
|
||||
}
|
||||
}
|
||||
@@ -60,7 +65,7 @@ export function boundsExtractor(): ValueExtractor<Bounds> {
|
||||
|
||||
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
|
||||
const getNum = (name: string): number | undefined => {
|
||||
const w = widgets.find((w) => w.name === name)
|
||||
const w = widgets.get(name)
|
||||
return typeof w?.value === 'number' ? w.value : undefined
|
||||
}
|
||||
const x = getNum('x')
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface PromotedWidgetView extends IBaseWidget {
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
readonly storeName: string
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -21,11 +21,9 @@ import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVi
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestRootGraph,
|
||||
@@ -75,7 +73,7 @@ function setupSubgraph(
|
||||
|
||||
function setPromotions(
|
||||
subgraphNode: SubgraphNode,
|
||||
entries: [string, string][]
|
||||
entries: SerializedProxyWidgetTuple[]
|
||||
) {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
@@ -263,7 +261,7 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(view.linkedWidgets?.[0].name).toBe('control_after_generate')
|
||||
})
|
||||
|
||||
test('value is store-backed via widgetValueStore', () => {
|
||||
test('value reads from per-instance store cell, leaving interior unchanged', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'myWidget', 'initial', () => {})
|
||||
@@ -273,18 +271,19 @@ describe(createPromotedWidgetView, () => {
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
// Value should read from the store (which was populated by addWidget)
|
||||
// Value should read from interior default initially
|
||||
expect(view.value).toBe('initial')
|
||||
|
||||
// Setting value through the view updates the store
|
||||
// Setting value through the view writes to the per-instance cell
|
||||
view.value = 'updated'
|
||||
expect(view.value).toBe('updated')
|
||||
|
||||
// The interior widget reads from the same store
|
||||
expect(innerNode.widgets![0].value).toBe('updated')
|
||||
// Interior widget is NOT mutated by promoted-view writes —
|
||||
// each instance has its own override; the interior remains the default.
|
||||
expect(innerNode.widgets![0].value).toBe('initial')
|
||||
})
|
||||
|
||||
test('value falls back to interior widget when store entry is missing', () => {
|
||||
test('value falls back to interior widget when no store entry exists', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const fallbackWidgetShape = {
|
||||
@@ -305,12 +304,11 @@ describe(createPromotedWidgetView, () => {
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
// Read falls back to the interior widget when no store cell exists
|
||||
expect(view.value).toBe('initial')
|
||||
view.value = 'updated'
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value setter falls back to host widget when linked states are unavailable', () => {
|
||||
test('value setter writes to per-instance store cell without mutating the interior', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
@@ -327,12 +325,12 @@ describe(createPromotedWidgetView, () => {
|
||||
const linkedView = promotedWidgets(subgraphNode)[0]
|
||||
if (!linkedView) throw new Error('Expected a linked promoted widget')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
linkedView.value = 'updated'
|
||||
|
||||
expect(linkedNode.widgets?.[0].value).toBe('updated')
|
||||
// Per-instance read returns the override
|
||||
expect(linkedView.value).toBe('updated')
|
||||
// Interior widget stays at its original default
|
||||
expect(linkedNode.widgets?.[0].value).toBe('initial')
|
||||
})
|
||||
|
||||
test('label falls back to displayName then widgetName', () => {
|
||||
@@ -475,17 +473,80 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(view.hidden).toBe(true)
|
||||
})
|
||||
|
||||
test('label setter persists to widget state', () => {
|
||||
test('label setter propagates to the bound subgraph slot', () => {
|
||||
// Real promotion fixture: produce a view with a genuine slot binding
|
||||
// (input._widget === view AND input._subgraphSlot set), exercising the
|
||||
// production path rather than asserting the rejected widget-state
|
||||
// implementation detail. Mirrors the pattern at "defers promotions
|
||||
// while subgraph node id is -1 and flushes on add" earlier in this file.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'myWidget', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
const innerInput = innerNode.addInput('myWidget', '*')
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
innerInput.widget = { name: 'myWidget' }
|
||||
subgraph.add(innerNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
|
||||
const view = subgraphNode.widgets[0] as PromotedWidgetView | undefined
|
||||
if (!view) throw new Error('Expected a promoted view')
|
||||
|
||||
view.label = 'Renamed'
|
||||
|
||||
expect(view.label).toBe('Renamed')
|
||||
// Slot-side persistence is the durable home for label state.
|
||||
const slot = subgraphNode.inputs.find(
|
||||
(i) => i._widget === view
|
||||
)?._subgraphSlot
|
||||
expect(slot?.label).toBe('Renamed')
|
||||
})
|
||||
|
||||
test('label binding uses the exact promoted view instance when same source widget is promoted twice', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
const view = createPromotedWidgetView(
|
||||
innerNode.addWidget('text', 'shared', 'value', () => {})
|
||||
|
||||
subgraphNode.addInput('slot_a', '*')
|
||||
subgraphNode.addInput('slot_b', '*')
|
||||
|
||||
const viewA = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
'shared',
|
||||
'Slot A'
|
||||
)
|
||||
view.label = 'Renamed'
|
||||
expect(view.label).toBe('Renamed')
|
||||
const viewB = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'shared',
|
||||
'Slot B'
|
||||
)
|
||||
|
||||
if (!subgraphNode.inputs[0] || !subgraphNode.inputs[1]) {
|
||||
throw new Error('Expected two subgraph inputs')
|
||||
}
|
||||
|
||||
subgraphNode.inputs[0]._widget = viewA
|
||||
Object.defineProperty(subgraphNode.inputs[0], '_subgraphSlot', {
|
||||
value: { name: 'slot_a', label: 'A' },
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
subgraphNode.inputs[1]._widget = viewB
|
||||
Object.defineProperty(subgraphNode.inputs[1], '_subgraphSlot', {
|
||||
value: { name: 'slot_b', label: 'B' },
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
|
||||
expect(viewA.label).toBe('A')
|
||||
expect(viewB.label).toBe('B')
|
||||
})
|
||||
|
||||
test('value getter handles number values via isWidgetValue', () => {
|
||||
@@ -515,16 +576,7 @@ describe(createPromotedWidgetView, () => {
|
||||
test('value setter handles object values via isWidgetValue', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const fallbackWidget = {
|
||||
name: 'objWidget',
|
||||
type: 'text',
|
||||
value: 'old',
|
||||
options: {}
|
||||
} as unknown as IBaseWidget
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
innerNode.addWidget('text', 'objWidget', 'old', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
@@ -534,7 +586,10 @@ describe(createPromotedWidgetView, () => {
|
||||
|
||||
const objValue = { key: 'data' }
|
||||
view.value = objValue
|
||||
expect(fallbackWidget.value).toBe(objValue)
|
||||
// Per-instance cell holds the new object value
|
||||
expect(view.value).toEqual(objValue)
|
||||
// Interior widget unchanged by promoted-view write
|
||||
expect(innerNode.widgets![0].value).toBe('old')
|
||||
})
|
||||
|
||||
test('onPointerDown returns true when interior widget onPointerDown handles it', () => {
|
||||
@@ -876,17 +931,21 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
// Both linked nodes share the same SubgraphInput slot, so the value
|
||||
// propagates to all connected widgets via getLinkedInputWidgets().
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
// Per-instance promoted-view writes do NOT mutate any interior
|
||||
// widget — interior cells remain the immutable defaults, and the
|
||||
// promoted view reads its per-instance store cell.
|
||||
expect(linkedView.value).toBe('shared-value')
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('a')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
|
||||
expect(promotedView.value).toBe('independent-updated')
|
||||
expect(linkedView.value).toBe('shared-value')
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('a')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
|
||||
})
|
||||
|
||||
test('duplicate-name promoted views map slot linkage by view identity', () => {
|
||||
@@ -1223,13 +1282,19 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
firstView.value = 'first-updated'
|
||||
secondView.value = 'second-updated'
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
// Per-instance views carry distinct overrides
|
||||
expect(firstView.value).toBe('first-updated')
|
||||
expect(secondView.value).toBe('second-updated')
|
||||
// Interior widgets remain at their originally-registered defaults
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-initial')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-initial')
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
expect(firstView.value).toBe('first-updated')
|
||||
expect(secondView.value).toBe('second-updated')
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-initial')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-initial')
|
||||
})
|
||||
|
||||
test('renaming an input updates linked promoted view display names', () => {
|
||||
@@ -1548,14 +1613,22 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
independentView.value = 'independent-value'
|
||||
linkedView.value = 'shared-linked'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const getValue = (nodeId: string) =>
|
||||
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
|
||||
?.value
|
||||
// Per-instance views carry their own values; both linked and
|
||||
// independent promoted views read from their per-instance store cells
|
||||
// and do not contaminate each other.
|
||||
expect(linkedView.value).toBe('shared-linked')
|
||||
expect(independentView.value).toBe('independent-value')
|
||||
|
||||
expect(getValue('20')).toBe('shared-linked')
|
||||
expect(getValue('18')).toBe('shared-linked')
|
||||
expect(getValue('19')).toBe('independent-value')
|
||||
// Per-instance cells are owned by the SubgraphNode itself at
|
||||
// `(graphId, hostNode.id, *)`. Each PromotedWidgetView is a first-class
|
||||
// widget on the SubgraphNode, so writes land in the natural
|
||||
// (graphId, nodeId, *) namespace and are isolated from interior cells.
|
||||
const cellValues = useWidgetValueStore()
|
||||
.getNodeWidgets(graph.id, hostNode.id)
|
||||
.map((cell) => cell.value)
|
||||
expect(cellValues).toEqual(
|
||||
expect.arrayContaining(['shared-linked', 'independent-value'])
|
||||
)
|
||||
})
|
||||
|
||||
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
|
||||
@@ -2022,14 +2095,17 @@ describe('three-level nested value propagation', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('value set at outermost level propagates to concrete widget', () => {
|
||||
test('value set at outermost level is visible through the promoted view', () => {
|
||||
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(100)
|
||||
|
||||
subgraphNodeA.widgets[0].value = 200
|
||||
expect(concreteNode.widgets![0].value).toBe(200)
|
||||
// Per-instance read returns the override
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(200)
|
||||
// Concrete interior widget remains the unmutated default
|
||||
expect(concreteNode.widgets![0].value).toBe(100)
|
||||
})
|
||||
|
||||
test('type resolves correctly through all three layers', () => {
|
||||
@@ -2108,8 +2184,10 @@ describe('three-level nested value propagation', () => {
|
||||
|
||||
widgets[1].value = 'updated-second'
|
||||
|
||||
// Interior widgets are not mutated by promoted-view writes
|
||||
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
|
||||
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
|
||||
expect(secondTextNode.widgets?.[0]?.value).toBe('22222222222')
|
||||
// Per-instance disambiguated views read correctly from their own cells
|
||||
expect(widgets[0].value).toBe('11111111111')
|
||||
expect(widgets[1].value).toBe('updated-second')
|
||||
})
|
||||
@@ -2156,13 +2234,14 @@ describe('multi-link representative determinism for input-based promotion', () =
|
||||
// Read returns the first link's value
|
||||
expect(widgets[0].value).toBe('first-val')
|
||||
|
||||
// Write propagates to all linked nodes
|
||||
// Write goes to the per-instance store cell only — interior
|
||||
// widgets stay at their original construction defaults.
|
||||
widgets[0].value = 'updated'
|
||||
expect(firstNode.widgets![0].value).toBe('updated')
|
||||
expect(secondNode.widgets![0].value).toBe('updated')
|
||||
expect(thirdNode.widgets![0].value).toBe('updated')
|
||||
expect(firstNode.widgets![0].value).toBe('first-val')
|
||||
expect(secondNode.widgets![0].value).toBe('second-val')
|
||||
expect(thirdNode.widgets![0].value).toBe('third-val')
|
||||
|
||||
// Repeated reads are still deterministic
|
||||
// Repeated reads through the view return the per-instance override
|
||||
expect(widgets[0].value).toBe('updated')
|
||||
})
|
||||
})
|
||||
@@ -2277,14 +2356,17 @@ describe('promoted combo rendering', () => {
|
||||
expect(renderedText).toContain('a')
|
||||
})
|
||||
|
||||
test('value updates propagate through two promoted input layers', () => {
|
||||
test('value updates are visible at the outer layer without mutating the interior', () => {
|
||||
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
comboWidget.computedDisabled = true
|
||||
const promotedWidget = subgraphNodeB.widgets[0]
|
||||
|
||||
expect(promotedWidget.value).toBe('a')
|
||||
promotedWidget.value = 'b'
|
||||
expect(comboWidget.value).toBe('b')
|
||||
// Per-instance read returns the override
|
||||
expect(promotedWidget.value).toBe('b')
|
||||
// The deepest interior combo widget remains at its default
|
||||
expect(comboWidget.value).toBe('a')
|
||||
|
||||
const fillText = vi.fn()
|
||||
const ctx = createInspectableCanvasContext(fillText)
|
||||
@@ -2677,4 +2759,134 @@ describe('DOM widget promotion', () => {
|
||||
'dom-widget-widgetB'
|
||||
)
|
||||
})
|
||||
|
||||
test('storeName uses a delimiter that cannot collide with widget names containing forward slashes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const inner = firstInnerNode(innerNodes)
|
||||
inner.addWidget('text', 'a/b', 'first', () => {})
|
||||
inner.addWidget('text', 'a', 'second', () => {})
|
||||
|
||||
// Same source node ⇒ identical prefix. With a `/` separator the
|
||||
// suffix `a/b/c` (widget="a/b", disambig="c") collides with
|
||||
// `a/b/c` (widget="a", disambig="b/c") — they are distinct widgets
|
||||
// but produce the same storeName key.
|
||||
const viewA = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(inner.id),
|
||||
'a/b',
|
||||
undefined,
|
||||
'c'
|
||||
)
|
||||
|
||||
const viewB = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(inner.id),
|
||||
'a',
|
||||
undefined,
|
||||
'b/c'
|
||||
)
|
||||
|
||||
const storeNameA = (viewA as unknown as { storeName: string }).storeName
|
||||
const storeNameB = (viewB as unknown as { storeName: string }).storeName
|
||||
expect(storeNameA).not.toBe(storeNameB)
|
||||
})
|
||||
|
||||
test('value setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'preAttach', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'preAttach'
|
||||
)
|
||||
|
||||
// Detach the SubgraphNode so its id reverts to the pre-attach sentinel.
|
||||
Object.assign(subgraphNode, { id: -1 })
|
||||
|
||||
view.value = 'should-not-persist'
|
||||
|
||||
// No cell registered at id -1
|
||||
const cells = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
-1
|
||||
)
|
||||
expect(cells).toHaveLength(0)
|
||||
// Read still falls back to the interior default
|
||||
expect(view.value).toBe('initial')
|
||||
})
|
||||
|
||||
test('label setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'preAttachLabel', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'preAttachLabel'
|
||||
)
|
||||
|
||||
Object.assign(subgraphNode, { id: -1 })
|
||||
|
||||
view.label = 'My Label'
|
||||
|
||||
const cells = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
-1
|
||||
)
|
||||
expect(cells).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('label setter materializes a per-instance cell when no slot is bound', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'labelOnly', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'labelOnly'
|
||||
)
|
||||
|
||||
view.label = 'My Label'
|
||||
|
||||
// Without a bound subgraph slot the per-instance cell is the only
|
||||
// place the new label can live; the setter must materialize it so
|
||||
// the rename actually takes effect (the getter falls back to
|
||||
// state?.label when no slot is found).
|
||||
const cells = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(cells).toHaveLength(1)
|
||||
expect(cells[0].label).toBe('My Label')
|
||||
expect(view.label).toBe('My Label')
|
||||
})
|
||||
|
||||
test('label setter updates an existing per-instance cell when a value override is present', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'valueAndLabel', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'valueAndLabel'
|
||||
)
|
||||
|
||||
// Triggering a value write materialises the cell (legitimate ownership).
|
||||
view.value = 'override'
|
||||
|
||||
// Now setting a label should update the existing cell, not create a new one.
|
||||
view.label = 'My Label'
|
||||
|
||||
const cells = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(cells).toHaveLength(1)
|
||||
expect(cells[0].label).toBe('My Label')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,6 +116,10 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get storeName(): string {
|
||||
return this.name
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this.yValue
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
test('Promoted view falls back to interior; promoted writes do not mutate interior', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -113,10 +113,16 @@ describe('Subgraph proxyWidgets', () => {
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
|
||||
// Direct interior edits remain visible through the view (no override)
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
|
||||
// Promoted-view writes route to the per-instance cell only —
|
||||
// the interior widget is NOT mutated.
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
expect(subgraphNode.widgets[0].value).toBe('test2')
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
@@ -253,7 +259,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -265,9 +271,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
@@ -291,6 +295,28 @@ describe('Subgraph proxyWidgets', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('serialize() does not mutate the live properties.proxyWidgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const before = subgraphNode.properties.proxyWidgets
|
||||
const beforeSnapshot = JSON.parse(JSON.stringify(before))
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(subgraphNode.properties.proxyWidgets).toBe(before)
|
||||
expect(subgraphNode.properties.proxyWidgets).toStrictEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('multi-link representative is deterministic across repeated reads', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared_input', type: '*' }]
|
||||
@@ -371,9 +397,11 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(42)
|
||||
|
||||
// Setting value at outermost level propagates to concrete widget
|
||||
// Value set at outermost level lives in the per-instance cell.
|
||||
// Read returns the override; concrete interior widget stays unchanged.
|
||||
subgraphNodeA.widgets[0].value = 99
|
||||
expect(concreteNode.widgets![0].value).toBe(99)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(99)
|
||||
expect(concreteNode.widgets![0].value).toBe(42)
|
||||
})
|
||||
|
||||
test('removeWidget cleans up promotion and input, then re-promote works', () => {
|
||||
|
||||
@@ -3,10 +3,20 @@ import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export const serializedProxyWidgetTupleSchema = z.tuple([
|
||||
z.string(),
|
||||
z.string()
|
||||
])
|
||||
export type SerializedProxyWidgetTuple = z.infer<
|
||||
typeof serializedProxyWidgetTupleSchema
|
||||
>
|
||||
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
z.tuple([z.string(), z.string(), z.string()]),
|
||||
z.tuple([z.string(), z.string()])
|
||||
serializedProxyWidgetTupleSchema
|
||||
])
|
||||
export type ProxyWidgetTuple = z.infer<typeof proxyWidgetTupleSchema>
|
||||
|
||||
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
|
||||
@@ -1027,4 +1027,29 @@ describe('Zero UUID handling in configure', () => {
|
||||
subgraph.configure(subgraphData)
|
||||
expect(subgraph.id).toBe(zeroUuid)
|
||||
})
|
||||
|
||||
describe('trigger() events bus', () => {
|
||||
it('dispatches node:slot-label:changed on graph.events when trigger() is called', () => {
|
||||
const graph = new LGraph()
|
||||
const received: Array<{ type: string; nodeId: NodeId }> = []
|
||||
graph.events.addEventListener('node:slot-label:changed', (e) => {
|
||||
received.push(e.detail)
|
||||
})
|
||||
graph.trigger('node:slot-label:changed', { nodeId: 42 })
|
||||
expect(received.length).toBe(1)
|
||||
expect(received[0]).toEqual({
|
||||
type: 'node:slot-label:changed',
|
||||
nodeId: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('still invokes the legacy onTrigger field for backward compatibility', () => {
|
||||
const graph = new LGraph()
|
||||
const received: Array<{ type: string }> = []
|
||||
graph.onTrigger = (event) => received.push(event)
|
||||
graph.trigger('node:slot-label:changed', { nodeId: 7 })
|
||||
expect(received.length).toBe(1)
|
||||
expect(received[0].type).toBe('node:slot-label:changed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
|
||||
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type {
|
||||
@@ -11,7 +12,7 @@ import type {
|
||||
function createSerialisedNode(
|
||||
id: number,
|
||||
type: string,
|
||||
proxyWidgets?: Array<[string, string]>
|
||||
proxyWidgets?: SerializedProxyWidgetTuple[]
|
||||
): ISerialisedNode {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisedGraph,
|
||||
@@ -9,6 +10,23 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
export interface LGraphEventMap {
|
||||
'node:slot-label:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:slot-label:changed' }
|
||||
>
|
||||
'node:slot-links:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:slot-links:changed' }
|
||||
>
|
||||
'node:slot-errors:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:slot-errors:changed' }
|
||||
>
|
||||
'node:property:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:property:changed' }
|
||||
>
|
||||
|
||||
configuring: {
|
||||
/** The data that was used to configure the graph. */
|
||||
data: ISerialisedGraph | SerialisableGraph
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISlotType,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
/**
|
||||
* Registers a minimal SubgraphNode subclass for a subgraph definition so that
|
||||
* `LiteGraph.createNode(subgraphId)` (which is invoked by `LGraphNode.clone`)
|
||||
* succeeds in tests.
|
||||
*/
|
||||
function registerSubgraphNodeType(subgraph: Subgraph): void {
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
const node = class extends SubgraphNode {
|
||||
constructor() {
|
||||
super(subgraph.rootGraph, subgraph, instanceData)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(node, 'title', { value: subgraph.name })
|
||||
LiteGraph.registerNodeType(subgraph.id, node)
|
||||
}
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetValue: number = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
const registeredTypes: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const type of registeredTypes) {
|
||||
LiteGraph.unregisterNodeType(type)
|
||||
}
|
||||
registeredTypes.length = 0
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 202,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 302,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidget = restoredInstance.widgets?.[0]
|
||||
expect(restoredWidget?.value).toBe(33)
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('keeps fresh sibling instances isolated before save or reload', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 7)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
|
||||
instance1.graph!.add(instance1)
|
||||
instance2.graph!.add(instance2)
|
||||
|
||||
const widget1 = instance1.widgets?.[0]
|
||||
const widget2 = instance2.widgets?.[0]
|
||||
|
||||
expect(widget1?.value).toBe(7)
|
||||
expect(widget2?.value).toBe(7)
|
||||
|
||||
widget1!.value = 10
|
||||
|
||||
expect(widget1?.value).toBe(10)
|
||||
expect(widget2?.value).toBe(7)
|
||||
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
|
||||
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
|
||||
})
|
||||
|
||||
it('keeps per-instance override sticky when the inner source widget changes directly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
originalInstance.configure({
|
||||
id: 601,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 602,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
|
||||
widget.value = 45
|
||||
|
||||
// Override remains sticky — interior change does not leak across the
|
||||
// per-instance boundary.
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
expect(
|
||||
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
|
||||
).toBe(33)
|
||||
})
|
||||
|
||||
it('preserves per-instance values when reconfigured without widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const promotedWidget = instance.widgets?.[0]
|
||||
promotedWidget!.value = 11
|
||||
widget.value = 17
|
||||
|
||||
const serialized = instance.serialize()
|
||||
delete serialized.widgets_values
|
||||
|
||||
instance.configure({
|
||||
...serialized,
|
||||
id: instance.id,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
// Symmetric with super.configure(): absent widgets_values is a no-op
|
||||
// for widget values; the per-instance override is preserved.
|
||||
expect(instance.widgets?.[0].value).toBe(11)
|
||||
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(11)
|
||||
})
|
||||
|
||||
it('skips non-serializable source widgets during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
// Mark the source widget as non-persistent (e.g. preview widget)
|
||||
widget.serialize = false
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
instance.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores sparse widgets_values holes when restoring promoted widget instances', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('First')
|
||||
const firstInput = firstNode.addInput('first', 'number')
|
||||
const firstWidget = firstNode.addWidget('number', 'first', 5, () => {})
|
||||
firstInput.widget = { name: 'first' }
|
||||
firstWidget.serialize = false
|
||||
|
||||
const secondNode = new LGraphNode('Second')
|
||||
const secondInput = secondNode.addInput('second', 'number')
|
||||
secondNode.addWidget('number', 'second', 9, () => {})
|
||||
secondInput.widget = { name: 'second' }
|
||||
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[0].connect(firstNode.inputs[0], firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondNode.inputs[0], secondNode)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
const widgetsValues = new Array<number | undefined>(2)
|
||||
widgetsValues[1] = 11
|
||||
|
||||
instance.configure({
|
||||
id: 701,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
['-1', 'first'],
|
||||
['-1', 'second']
|
||||
]
|
||||
},
|
||||
widgets_values: widgetsValues
|
||||
})
|
||||
|
||||
expect(instance.widgets[0].value).toBe(5)
|
||||
expect(instance.widgets[1].value).toBe(11)
|
||||
})
|
||||
|
||||
it('ignores configure replay for promoted widgets whose concrete source is non-serializable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
widget.serialize = false
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 502 })
|
||||
instance.configure({
|
||||
id: 502,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
expect(instance.widgets[0].value).toBe(10)
|
||||
|
||||
widget.value = 14
|
||||
|
||||
expect(instance.widgets[0].value).toBe(14)
|
||||
expect(instance.widgets[0].serializeValue?.(instance, 0)).toBe(14)
|
||||
})
|
||||
|
||||
it('serializes nested promoted widgets from the concrete source widget serialize state', () => {
|
||||
const leafSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('value', 'number')
|
||||
const concreteWidget = concreteNode.addWidget(
|
||||
'number',
|
||||
'value',
|
||||
5,
|
||||
() => {}
|
||||
)
|
||||
concreteInput.widget = { name: 'value' }
|
||||
leafSubgraph.add(concreteNode)
|
||||
leafSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const middleNode = createTestSubgraphNode(leafSubgraph, { id: 901 })
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
middleSubgraph.add(middleNode)
|
||||
middleNode._internalConfigureAfterSlots()
|
||||
middleSubgraph.inputNode.slots[0].connect(middleNode.inputs[0], middleNode)
|
||||
|
||||
const innerHostNode = createTestSubgraphNode(middleSubgraph, { id: 902 })
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
outerSubgraph.add(innerHostNode)
|
||||
innerHostNode._internalConfigureAfterSlots()
|
||||
outerSubgraph.inputNode.slots[0].connect(
|
||||
innerHostNode.inputs[0],
|
||||
innerHostNode
|
||||
)
|
||||
|
||||
const outerHostNode = createTestSubgraphNode(outerSubgraph, { id: 903 })
|
||||
outerHostNode.graph!.add(outerHostNode)
|
||||
outerHostNode.widgets[0].value = 123
|
||||
|
||||
expect(outerHostNode.serialize().widgets_values).toEqual([123])
|
||||
|
||||
concreteWidget.serialize = false
|
||||
|
||||
expect(outerHostNode.serialize().widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not clobber super.serialize() values when a concrete source widget is non-serializable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
// Mark the concrete source widget non-serializable so the merge loop skips
|
||||
// index 0, letting super's value survive.
|
||||
widget.serialize = false
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 801 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
// Stub super.serialize to simulate a native widget contributing a
|
||||
// positional value at index 0 (a slot the promoted view would own
|
||||
// if it were serializable, but this view skips because serialize:false).
|
||||
const SuperProto = Object.getPrototypeOf(Object.getPrototypeOf(instance))
|
||||
const originalSerialize = SuperProto.serialize as () => {
|
||||
widgets_values?: unknown[]
|
||||
}
|
||||
vi.spyOn(SuperProto, 'serialize').mockImplementationOnce(
|
||||
function (this: typeof instance) {
|
||||
const out = originalSerialize.call(this)
|
||||
out.widgets_values = ['native-value']
|
||||
return out
|
||||
}
|
||||
)
|
||||
|
||||
const out = instance.serialize()
|
||||
|
||||
expect(out.widgets_values?.[0]).toBe('native-value')
|
||||
})
|
||||
|
||||
it('round-trips Date widget values via structuredClone (preserves type)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 901 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const date = new Date('2025-01-01T00:00:00.000Z')
|
||||
instance.widgets[0].value = { when: date }
|
||||
|
||||
const out = instance.serialize()
|
||||
const cloned = out.widgets_values?.[0] as { when: Date } | undefined
|
||||
expect(cloned?.when).toBeInstanceOf(Date)
|
||||
expect(cloned?.when.getTime()).toBe(date.getTime())
|
||||
})
|
||||
|
||||
it('preserves per-instance promoted widget values across LGraphNode.clone (copy/paste)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
registerSubgraphNodeType(subgraph)
|
||||
registeredTypes.push(subgraph.id)
|
||||
|
||||
const original = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
original.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: ['per-instance-value']
|
||||
})
|
||||
|
||||
expect(original.widgets[0].value).toBe('per-instance-value')
|
||||
|
||||
// LGraphNode.clone() invokes LiteGraph.createNode (id = -1), strips the id
|
||||
// from the serialized data, then calls configure(data). The clone then
|
||||
// needs to be added to a graph to receive a real id.
|
||||
const clone = original.clone() as SubgraphNode | null
|
||||
expect(clone).toBeTruthy()
|
||||
if (!clone) throw new Error('clone failed')
|
||||
|
||||
original.graph!.add(clone)
|
||||
expect(clone.id).not.toBe(-1)
|
||||
|
||||
expect(clone.widgets[0].value).toBe('per-instance-value')
|
||||
expect(clone.widgets[0].serializeValue?.(clone, 0)).toBe(
|
||||
'per-instance-value'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears deferred widget replay when reconfigured without widgets_values before attach', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const detached = createTestSubgraphNode(subgraph, { id: -1 })
|
||||
detached.configure({
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
detached.configure({
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] }
|
||||
})
|
||||
|
||||
detached.graph!.add(detached)
|
||||
|
||||
expect(detached.id).not.toBe(-1)
|
||||
expect(detached.widgets[0].value).toBe(5)
|
||||
expect(detached.widgets[0].serializeValue?.(detached, 0)).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { duplicateSubgraphNodeIds } from '@/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds'
|
||||
import {
|
||||
LGraph,
|
||||
@@ -522,15 +523,19 @@ describe('SubgraphSerialization - Data Integrity', () => {
|
||||
const subgraphB = graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!
|
||||
const subgraphBIds = new Set(subgraphB.nodes.map((node) => String(node.id)))
|
||||
|
||||
const rootProxyWidgetsA = graph.getNodeById(102)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(rootProxyWidgetsA)).toBe(true)
|
||||
for (const entry of rootProxyWidgetsA as string[][]) {
|
||||
const rootProxyWidgetsA = parseProxyWidgets(
|
||||
graph.getNodeById(102)?.properties?.proxyWidgets
|
||||
)
|
||||
expect(rootProxyWidgetsA.length).toBeGreaterThan(0)
|
||||
for (const entry of rootProxyWidgetsA) {
|
||||
expect(subgraphAIds.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
const rootProxyWidgetsB = graph.getNodeById(103)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(rootProxyWidgetsB)).toBe(true)
|
||||
for (const entry of rootProxyWidgetsB as string[][]) {
|
||||
const rootProxyWidgetsB = parseProxyWidgets(
|
||||
graph.getNodeById(103)?.properties?.proxyWidgets
|
||||
)
|
||||
expect(rootProxyWidgetsB.length).toBeGreaterThan(0)
|
||||
for (const entry of rootProxyWidgetsB) {
|
||||
expect(subgraphBIds.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
ISlotType,
|
||||
Subgraph,
|
||||
@@ -409,7 +410,10 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
String(samplerNode.id)
|
||||
)
|
||||
expect(hostNode.properties.proxyWidgets).toStrictEqual([
|
||||
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
|
||||
[
|
||||
String(nestedNode.id),
|
||||
`${nestedNode.id}: ${samplerNode.id}: noise_seed`
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -430,8 +434,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
// serialize() syncs the promotion store into properties.proxyWidgets
|
||||
const serialized = hostNode.serialize()
|
||||
const originalProxyWidgets = serialized.properties!
|
||||
.proxyWidgets as string[][]
|
||||
const originalProxyWidgets = parseProxyWidgets(
|
||||
serialized.properties?.proxyWidgets
|
||||
)
|
||||
|
||||
expect(originalProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
@@ -441,7 +446,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
// Simulate clone: create a second SubgraphNode configured from serialized data
|
||||
const cloneNode = createTestSubgraphNode(subgraph)
|
||||
cloneNode.configure(serialized)
|
||||
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
|
||||
const cloneProxyWidgets = parseProxyWidgets(
|
||||
cloneNode.properties.proxyWidgets
|
||||
)
|
||||
|
||||
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
|
||||
@@ -130,8 +130,6 @@ describe('BaseWidget store integration', () => {
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.nodeId).toBe(1)
|
||||
expect(state?.name).toBe('autoRegWidget')
|
||||
expect(state?.type).toBe('number')
|
||||
expect(state?.value).toBe(100)
|
||||
expect(state?.label).toBe('Auto Label')
|
||||
|
||||
@@ -86,8 +86,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
computedDisabled?: boolean
|
||||
tooltip?: string
|
||||
|
||||
private _state: Omit<WidgetState, 'nodeId'> &
|
||||
Partial<Pick<WidgetState, 'nodeId'>>
|
||||
private _state: WidgetState
|
||||
|
||||
get label(): string | undefined {
|
||||
return this._state.label
|
||||
@@ -147,6 +146,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
|
||||
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
|
||||
value: this.value,
|
||||
name: this.name,
|
||||
nodeId
|
||||
})
|
||||
}
|
||||
@@ -196,7 +196,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
Object.assign(this, safeValues)
|
||||
|
||||
this._state = {
|
||||
name: this.name,
|
||||
type: this.type as TWidgetType,
|
||||
value,
|
||||
label,
|
||||
|
||||
@@ -129,25 +129,22 @@ describe('NodeWidgets', () => {
|
||||
const duplicateA = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const duplicateB = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const distinct = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:20:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
@@ -165,18 +162,16 @@ describe('NodeWidgets', () => {
|
||||
const hiddenDuplicate = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a',
|
||||
options: { hidden: true }
|
||||
})
|
||||
const visibleDuplicate = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a',
|
||||
options: { hidden: false }
|
||||
})
|
||||
@@ -194,17 +189,15 @@ describe('NodeWidgets', () => {
|
||||
const textWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const comboWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'combo',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
@@ -217,24 +210,20 @@ describe('NodeWidgets', () => {
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
|
||||
it('keeps same-name promoted entries distinct by instance widget identity', () => {
|
||||
const firstTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
name: 'string_a',
|
||||
storeName: 'string_a',
|
||||
instanceWidgetName: 'source:18:string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:18'
|
||||
type: 'text'
|
||||
})
|
||||
const secondTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
name: 'string_a',
|
||||
storeName: 'string_a',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:19'
|
||||
type: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
firstTransientEntry,
|
||||
@@ -250,17 +239,15 @@ describe('NodeWidgets', () => {
|
||||
const firstPromoted = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:1',
|
||||
storeNodeId: 'outer-subgraph:1',
|
||||
storeName: 'text',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
slotName: 'text'
|
||||
})
|
||||
const secondPromoted = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:2',
|
||||
storeNodeId: 'outer-subgraph:2',
|
||||
storeName: 'text',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'promoted:text:2',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -302,8 +289,16 @@ describe('NodeWidgets', () => {
|
||||
|
||||
it('keeps AppInput ids mapped to node identity for selection', () => {
|
||||
const nodeData = createMockNodeData('TestNode', [
|
||||
createMockWidget({ nodeId: 'test_node', name: 'seed_a', type: 'text' }),
|
||||
createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' })
|
||||
createMockWidget({
|
||||
nodeId: 'test_node',
|
||||
name: 'seed_a',
|
||||
type: 'text'
|
||||
}),
|
||||
createMockWidget({
|
||||
nodeId: 'test_node',
|
||||
name: 'seed_b',
|
||||
type: 'text'
|
||||
})
|
||||
])
|
||||
|
||||
const { container } = render(NodeWidgets, {
|
||||
@@ -333,6 +328,6 @@ describe('NodeWidgets', () => {
|
||||
el.getAttribute('data-id')
|
||||
)
|
||||
|
||||
expect(ids).toStrictEqual(['test_node', 'test_node'])
|
||||
expect(ids).toStrictEqual(['1', '1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { makeCompositeKey } from '@/utils/compositeKey'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
@@ -42,10 +43,10 @@ const createMockWidget = (
|
||||
})
|
||||
|
||||
describe('getWidgetIdentity', () => {
|
||||
it('returns stable dedupeIdentity for widgets with storeNodeId', () => {
|
||||
it('returns stable dedupeIdentity for widgets with nodeId and instanceWidgetName', () => {
|
||||
const widget = createMockWidget({
|
||||
storeNodeId: 'subgraph:19',
|
||||
storeName: 'text',
|
||||
nodeId: 'subgraph:19',
|
||||
instanceWidgetName: 'text',
|
||||
slotName: 'text',
|
||||
type: 'text'
|
||||
})
|
||||
@@ -54,24 +55,26 @@ describe('getWidgetIdentity', () => {
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
it('returns transient renderKey when neither widget nor caller provide a stable identity', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(
|
||||
widget,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
it('uses sourceExecutionId for identity when no node id is available', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: '65:18'
|
||||
})
|
||||
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
|
||||
const { dedupeIdentity } = getWidgetIdentity(widget, undefined, 0)
|
||||
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
|
||||
})
|
||||
})
|
||||
@@ -211,9 +214,9 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
nodeId: '3',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -247,13 +250,58 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses disambiguatingSourceNodeId when checking promoted border styling', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '3',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
source: {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
},
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '3',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [promotedWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(
|
||||
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
|
||||
).toBe(true)
|
||||
expect(result[0]?.id).toBe('3')
|
||||
})
|
||||
|
||||
it('does not apply promoted border styling to outermost widgets', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
nodeId: '4',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -323,8 +371,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
storeNodeId: '1',
|
||||
storeName: 'text',
|
||||
instanceWidgetName: 'text',
|
||||
slotName: 'text',
|
||||
options: { hidden: true }
|
||||
})
|
||||
@@ -333,8 +380,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
storeNodeId: '1',
|
||||
storeName: 'text',
|
||||
instanceWidgetName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -362,6 +408,162 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-instance value lookup for promoted widgets', () => {
|
||||
const GRAPH_ID = 'graph-test'
|
||||
const INTERIOR_NODE_ID = '5'
|
||||
const INTERIOR_WIDGET_NAME = 'text'
|
||||
const STORE_NAME = makeCompositeKey([
|
||||
INTERIOR_NODE_ID,
|
||||
INTERIOR_WIDGET_NAME,
|
||||
''
|
||||
])
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function buildPromotedWidget(instanceId: string): SafeWidgetData {
|
||||
return createMockWidget({
|
||||
name: INTERIOR_WIDGET_NAME,
|
||||
type: 'text',
|
||||
slotName: INTERIOR_WIDGET_NAME,
|
||||
nodeId: instanceId,
|
||||
instanceWidgetName: STORE_NAME,
|
||||
source: {
|
||||
sourceNodeId: INTERIOR_NODE_ID,
|
||||
sourceWidgetName: INTERIOR_WIDGET_NAME
|
||||
},
|
||||
sourceNodeLocatorId: `subgraph-def:${INTERIOR_NODE_ID}`
|
||||
})
|
||||
}
|
||||
|
||||
function processInstance(
|
||||
instanceId: string,
|
||||
widget: SafeWidgetData
|
||||
): ReturnType<typeof computeProcessedWidgets> {
|
||||
return computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: instanceId,
|
||||
type: 'SubgraphNode',
|
||||
widgets: [widget],
|
||||
title: 'Subgraph Instance',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: GRAPH_ID,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
}
|
||||
|
||||
it('reads per-instance values from cellNodeId/cellName cells when multiple instances share a definition', () => {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: '100',
|
||||
name: STORE_NAME,
|
||||
type: 'text',
|
||||
value: 'valueA',
|
||||
options: {}
|
||||
})
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: '200',
|
||||
name: STORE_NAME,
|
||||
type: 'text',
|
||||
value: 'valueB',
|
||||
options: {}
|
||||
})
|
||||
|
||||
const [instanceA] = processInstance('100', buildPromotedWidget('100'))
|
||||
const [instanceB] = processInstance('200', buildPromotedWidget('200'))
|
||||
|
||||
expect(instanceA.value).toBe('valueA')
|
||||
expect(instanceB.value).toBe('valueB')
|
||||
})
|
||||
|
||||
it('produces distinct dedupe identities per instance so duplicate-pruning does not collapse them', () => {
|
||||
const widgetA = buildPromotedWidget('100')
|
||||
const widgetB = buildPromotedWidget('200')
|
||||
|
||||
const identityA = getWidgetIdentity(widgetA, '100', 0)
|
||||
const identityB = getWidgetIdentity(widgetB, '200', 0)
|
||||
|
||||
expect(identityA.dedupeIdentity).not.toBe(identityB.dedupeIdentity)
|
||||
})
|
||||
|
||||
it('falls back to interior cell value when per-instance cell is absent for a promoted widget', () => {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: INTERIOR_NODE_ID,
|
||||
name: INTERIOR_WIDGET_NAME,
|
||||
type: 'text',
|
||||
value: 'interior-value',
|
||||
options: {}
|
||||
})
|
||||
|
||||
const promotedWidget = createMockWidget({
|
||||
name: INTERIOR_WIDGET_NAME,
|
||||
type: 'text',
|
||||
slotName: INTERIOR_WIDGET_NAME,
|
||||
nodeId: '57',
|
||||
instanceWidgetName: 'STORE',
|
||||
source: {
|
||||
sourceNodeId: INTERIOR_NODE_ID,
|
||||
sourceWidgetName: INTERIOR_WIDGET_NAME
|
||||
}
|
||||
})
|
||||
|
||||
const [result] = processInstance('57', promotedWidget)
|
||||
|
||||
expect(result.value).toBe('interior-value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ordinary widget dedupe', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('does not collapse duplicate ordinary widget refs in node.widgets[]', () => {
|
||||
// Ordinary widgets do NOT carry their own identity (no widget.nodeId,
|
||||
// no instanceWidgetName). When a host node legitimately renders the
|
||||
// same widget twice, both must produce distinct entries with distinct
|
||||
// renderKeys.
|
||||
const ordinaryWidget = createMockWidget({
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
nodeId: undefined,
|
||||
instanceWidgetName: undefined
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'TestNode',
|
||||
widgets: [ordinaryWidget, ordinaryWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].renderKey).not.toBe(result[1].renderKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
const GRAPH_ID = 'graph-test'
|
||||
const NODE_ID = '1'
|
||||
|
||||
@@ -4,12 +4,22 @@ import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LGraphNode as LiteGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
@@ -26,7 +36,12 @@ const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { extra: {}, nodes: [{ id: 1 }], events: new EventTarget() }
|
||||
rootGraph: {
|
||||
extra: {},
|
||||
nodes: [{ id: 1, isSubgraphNode: () => false }],
|
||||
events: new EventTarget(),
|
||||
getNodeById: () => undefined
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -47,6 +62,16 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
|
||||
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
widgetStates: new Map(),
|
||||
setPositionOverride: vi.fn(),
|
||||
clearPositionOverride: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
const mockSettings = vi.hoisted(() => {
|
||||
const store: Record<string, unknown> = {}
|
||||
@@ -68,6 +93,35 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
|
||||
import { useAppModeStore } from './appModeStore'
|
||||
|
||||
function createPromotedWidgetFixture(hostId: number): {
|
||||
graph: LGraph
|
||||
host: SubgraphNode
|
||||
promoted: PromotedWidgetView
|
||||
} {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: '*' }]
|
||||
})
|
||||
const inner = new LiteGraphNode('Inner')
|
||||
const input = inner.addInput('value', '*')
|
||||
inner.addWidget('text', 'value', 'a', () => {})
|
||||
input.widget = { name: 'value' }
|
||||
subgraph.add(inner)
|
||||
subgraph.inputNode.slots[0].connect(input, inner)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host._internalConfigureAfterSlots()
|
||||
host.graph!.add(host)
|
||||
|
||||
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
|
||||
const promoted = host.widgets.find(isPromotedWidgetView)
|
||||
if (!promoted) throw new Error('Expected promoted widget view')
|
||||
|
||||
return { graph: host.graph!, host, promoted }
|
||||
}
|
||||
|
||||
function createBuilderWorkflow(
|
||||
activeMode: string = 'builder:inputs'
|
||||
): LoadedComfyWorkflow {
|
||||
@@ -106,10 +160,13 @@ describe('appModeStore', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
mockSettings.reset()
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
vi.mocked(app.rootGraph).nodes = [
|
||||
{ id: 1, isSubgraphNode: () => false } as LGraphNode
|
||||
]
|
||||
workflowStore = useWorkflowStore()
|
||||
store = useAppModeStore()
|
||||
vi.clearAllMocks()
|
||||
@@ -258,6 +315,105 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedInputs).toEqual([[1, 'prompt', { height: 150 }]])
|
||||
})
|
||||
|
||||
it('loadSelections rewrites legacy promoted tuples to host node id and storeName', async () => {
|
||||
const { graph, host, promoted } = createPromotedWidgetFixture(500)
|
||||
const { resolveNode: actualResolveNode } = (await vi.importActual(
|
||||
'@/utils/litegraphUtil'
|
||||
)) as {
|
||||
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
|
||||
}
|
||||
const originalRootGraph = app.rootGraph
|
||||
|
||||
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
|
||||
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
|
||||
|
||||
try {
|
||||
store.loadSelections({
|
||||
inputs: [[promoted.sourceNodeId, promoted.sourceWidgetName]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[host.id, promoted.storeName]])
|
||||
} finally {
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves selected promoted-widget identity per instance across save/reload', async () => {
|
||||
// Build one subgraph definition with one promoted widget,
|
||||
// then create two SubgraphNode instances of that definition.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: '*' }]
|
||||
})
|
||||
const inner = new LiteGraphNode('Inner')
|
||||
const input = inner.addInput('value', '*')
|
||||
inner.addWidget('text', 'value', 'a', () => {})
|
||||
input.widget = { name: 'value' }
|
||||
subgraph.add(inner)
|
||||
subgraph.inputNode.slots[0].connect(input, inner)
|
||||
|
||||
const hostA = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
hostA._internalConfigureAfterSlots()
|
||||
hostA.graph!.add(hostA)
|
||||
|
||||
const hostB = createTestSubgraphNode(subgraph, { id: 702 })
|
||||
hostB._internalConfigureAfterSlots()
|
||||
hostB.graph!.add(hostB)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.setPromotions(hostA.rootGraph.id, hostA.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
promotionStore.setPromotions(hostB.rootGraph.id, hostB.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
|
||||
const promotedA = hostA.widgets.find(isPromotedWidgetView)
|
||||
const promotedB = hostB.widgets.find(isPromotedWidgetView)
|
||||
if (!promotedA || !promotedB) throw new Error('Expected promoted views')
|
||||
|
||||
// Precondition: storeNames are equal (interior identity matches),
|
||||
// host ids differ.
|
||||
expect(promotedA.storeName).toBe(promotedB.storeName)
|
||||
expect(hostA.id).not.toBe(hostB.id)
|
||||
|
||||
const { resolveNode: actualResolveNode } = (await vi.importActual(
|
||||
'@/utils/litegraphUtil'
|
||||
)) as {
|
||||
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
|
||||
}
|
||||
const graph = hostA.graph!
|
||||
const originalRootGraph = app.rootGraph
|
||||
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
|
||||
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
|
||||
|
||||
try {
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[hostA.id, promotedA.storeName],
|
||||
[hostB.id, promotedB.storeName]
|
||||
],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toHaveLength(2)
|
||||
expect(store.selectedInputs).toEqual(
|
||||
expect.arrayContaining([
|
||||
[hostA.id, promotedA.storeName],
|
||||
[hostB.id, promotedB.storeName]
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps inputs for existing nodes even if widget is missing', async () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
@@ -443,6 +599,18 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSelectedInput', () => {
|
||||
it('uses host node id and promoted storeName', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(601)
|
||||
|
||||
store.selectedInputs = [[host.id, promoted.storeName]]
|
||||
|
||||
store.removeSelectedInput(promoted, host)
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
it('enables Vue nodes when entering select mode with them disabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
|
||||
@@ -2,20 +2,38 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { asGraphId, widgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
WidgetComponentDisplay,
|
||||
WidgetComponentValue
|
||||
} from '@/world/widgets/widgetComponents'
|
||||
import { getWorld, resetWorldInstance } from '@/world/worldInstance'
|
||||
|
||||
import type { WidgetState } from './widgetValueStore'
|
||||
import { useWidgetValueStore } from './widgetValueStore'
|
||||
|
||||
type WidgetInput<T = unknown> = WidgetState<T> & {
|
||||
name: string
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
function widget<T>(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
type: string,
|
||||
value: T,
|
||||
extra: Partial<
|
||||
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
|
||||
> = {}
|
||||
): WidgetState<T> {
|
||||
return { nodeId, name, type, value, options: {}, ...extra }
|
||||
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
|
||||
): WidgetInput<T> {
|
||||
return {
|
||||
nodeId: nodeId as NodeId,
|
||||
name,
|
||||
type,
|
||||
value,
|
||||
options: {},
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
describe('useWidgetValueStore', () => {
|
||||
@@ -23,12 +41,15 @@ describe('useWidgetValueStore', () => {
|
||||
const graphB = 'graph-b' as UUID
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetWorldInstance()
|
||||
})
|
||||
|
||||
describe('widgetState.value access', () => {
|
||||
it('getWidget returns undefined for unregistered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'missing' as NodeId, 'widget')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('widgetState.value can be read and written directly', () => {
|
||||
@@ -40,7 +61,9 @@ describe('useWidgetValueStore', () => {
|
||||
expect(state.value).toBe(100)
|
||||
|
||||
state.value = 200
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
|
||||
expect(store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.value).toBe(
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
it('stores different value types', () => {
|
||||
@@ -53,12 +76,18 @@ describe('useWidgetValueStore', () => {
|
||||
widget('node-1', 'array', 'combo', [1, 2, 3])
|
||||
)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
|
||||
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
|
||||
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
|
||||
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
expect(store.getWidget(graphA, 'node-1' as NodeId, 'text')?.value).toBe(
|
||||
'hello'
|
||||
)
|
||||
expect(store.getWidget(graphA, 'node-1' as NodeId, 'number')?.value).toBe(
|
||||
42
|
||||
)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1' as NodeId, 'boolean')?.value
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1' as NodeId, 'array')?.value
|
||||
).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,11 +99,9 @@ describe('useWidgetValueStore', () => {
|
||||
widget('node-1', 'seed', 'number', 12345)
|
||||
)
|
||||
|
||||
expect(state.nodeId).toBe('node-1')
|
||||
expect(state.name).toBe('seed')
|
||||
expect(state.type).toBe('number')
|
||||
expect(state.value).toBe(12345)
|
||||
expect(state.disabled).toBeUndefined()
|
||||
expect(state.disabled).toBe(false)
|
||||
expect(state.serialize).toBeUndefined()
|
||||
expect(state.options).toEqual({})
|
||||
})
|
||||
@@ -103,15 +130,17 @@ describe('useWidgetValueStore', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
|
||||
|
||||
const state = store.getWidget(graphA, 'node-1', 'seed')
|
||||
const state = store.getWidget(graphA, 'node-1' as NodeId, 'seed')
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.name).toBe('seed')
|
||||
expect(state?.type).toBe('number')
|
||||
expect(state?.value).toBe(100)
|
||||
})
|
||||
|
||||
it('getWidget returns undefined for missing widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'missing' as NodeId, 'widget')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNodeWidgets returns all widgets for a node', () => {
|
||||
@@ -120,9 +149,8 @@ describe('useWidgetValueStore', () => {
|
||||
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
|
||||
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
|
||||
|
||||
const widgets = store.getNodeWidgets(graphA, 'node-1')
|
||||
const widgets = store.getNodeWidgets(graphA, 'node-1' as NodeId)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,7 +163,9 @@ describe('useWidgetValueStore', () => {
|
||||
)
|
||||
|
||||
state.disabled = true
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.disabled
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('label can be set directly via getWidget', () => {
|
||||
@@ -146,12 +176,14 @@ describe('useWidgetValueStore', () => {
|
||||
)
|
||||
|
||||
state.label = 'Random Seed'
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
|
||||
expect(store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.label).toBe(
|
||||
'Random Seed'
|
||||
)
|
||||
|
||||
state.label = undefined
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.label
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -161,8 +193,8 @@ describe('useWidgetValueStore', () => {
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(store.getWidget(graphA, 'node-1' as NodeId, 'seed')?.value).toBe(1)
|
||||
expect(store.getWidget(graphB, 'node-1' as NodeId, 'seed')?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
@@ -172,8 +204,113 @@ describe('useWidgetValueStore', () => {
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(
|
||||
store.getWidget(graphA, 'node-1' as NodeId, 'seed')
|
||||
).toBeUndefined()
|
||||
expect(store.getWidget(graphB, 'node-1' as NodeId, 'seed')?.value).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('view contract: data semantics, not identity', () => {
|
||||
// The view is a delegating accessor object built fresh per call.
|
||||
// Identity is intentionally NOT preserved across getWidget calls. See
|
||||
// temp/plans/widget-component-decomposition.md §10.4.
|
||||
const branded = asGraphId(graphA)
|
||||
const sample = widget('node-1', 'seed', 'number', 100)
|
||||
|
||||
it('reads delegate live to the underlying components', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const view = store.registerWidget(graphA, sample)
|
||||
const widgetId = widgetEntityId(branded, sample.nodeId, sample.name)
|
||||
const valueBucket = getWorld().getComponent(
|
||||
widgetId,
|
||||
WidgetComponentValue
|
||||
)
|
||||
expect(view.value).toBe(valueBucket?.value)
|
||||
})
|
||||
|
||||
it('writes round-trip through the underlying components', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const view = store.registerWidget(graphA, sample)
|
||||
const widgetId = widgetEntityId(branded, sample.nodeId, sample.name)
|
||||
|
||||
view.value = 42
|
||||
expect(
|
||||
getWorld().getComponent(widgetId, WidgetComponentValue)?.value
|
||||
).toBe(42)
|
||||
|
||||
view.label = 'hello'
|
||||
expect(
|
||||
getWorld().getComponent(widgetId, WidgetComponentDisplay)?.label
|
||||
).toBe('hello')
|
||||
|
||||
view.disabled = true
|
||||
expect(
|
||||
getWorld().getComponent(widgetId, WidgetComponentDisplay)?.disabled
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('underlying component writes are visible through the view', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const view = store.registerWidget(graphA, sample)
|
||||
const widgetId = widgetEntityId(branded, sample.nodeId, sample.name)
|
||||
const display = getWorld().getComponent(widgetId, WidgetComponentDisplay)
|
||||
if (!display) throw new Error('display bucket missing')
|
||||
display.label = 'fresh'
|
||||
expect(view.label).toBe('fresh')
|
||||
})
|
||||
|
||||
it('setters no-op safely after clearGraph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const view = store.registerWidget(graphA, sample)
|
||||
store.clearGraph(graphA)
|
||||
// Should not throw. Subsequent getWidget remains undefined.
|
||||
view.value = 999
|
||||
view.label = 'ignored'
|
||||
view.disabled = true
|
||||
expect(
|
||||
store.getWidget(graphA, sample.nodeId, sample.name)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('view properties are enumerable for spread/objectContaining', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const view = store.registerWidget(graphA, sample)
|
||||
const keys = Object.keys(view).sort()
|
||||
expect(keys).toEqual(
|
||||
['disabled', 'label', 'options', 'serialize', 'type', 'value'].sort()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeWidgetsByName', () => {
|
||||
it('returns empty map when node has no widgets', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const map = store.getNodeWidgetsByName(graphA, 'no-such' as NodeId)
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns map keyed by widget name', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphA, widget('node-1', 'cfg', 'number', 7))
|
||||
const map = store.getNodeWidgetsByName(graphA, 'node-1' as NodeId)
|
||||
expect(map.size).toBe(2)
|
||||
expect(map.get('seed')?.value).toBe(1)
|
||||
expect(map.get('cfg')?.value).toBe(7)
|
||||
expect(map.get('missing')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactivity through the view', () => {
|
||||
it('clearGraph removes data; subsequent getWidget returns undefined', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const sample = widget('node-1', 'seed', 'number', 100)
|
||||
store.registerWidget(graphA, sample)
|
||||
store.clearGraph(graphA)
|
||||
expect(
|
||||
store.getWidget(graphA, sample.nodeId, sample.name)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { ComponentKey } from '@/world/componentKey'
|
||||
import {
|
||||
asGraphId,
|
||||
isNodeIdForGraph,
|
||||
isWidgetIdForGraph,
|
||||
nodeEntityId,
|
||||
parseWidgetEntityId,
|
||||
widgetEntityId
|
||||
} from '@/world/entityIds'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
WidgetComponentContainer,
|
||||
WidgetComponentDisplay,
|
||||
WidgetComponentSchema,
|
||||
WidgetComponentSerialize,
|
||||
WidgetComponentValue
|
||||
} from '@/world/widgets/widgetComponents'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
WidgetRegistration,
|
||||
WidgetState
|
||||
} from '@/world/widgets/widgetState'
|
||||
import { getWorld } from '@/world/worldInstance'
|
||||
|
||||
/**
|
||||
* Widget state is keyed by `nodeId:widgetName` without graph context.
|
||||
* This is intentional: nodes viewed at different subgraph depths share
|
||||
* the same widget state, enabling synchronized values across the hierarchy.
|
||||
*/
|
||||
type WidgetKey = `${NodeId}:${string}`
|
||||
export type { WidgetState } from '@/world/widgets/widgetState'
|
||||
|
||||
/**
|
||||
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
|
||||
@@ -23,49 +35,92 @@ export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
|
||||
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
|
||||
}
|
||||
|
||||
export interface WidgetState<
|
||||
TValue = unknown,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
> extends Pick<
|
||||
IBaseWidget<TValue, TType, TOptions>,
|
||||
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
|
||||
> {
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
|
||||
|
||||
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
|
||||
const widgetStates = graphWidgetStates.value.get(graphId)
|
||||
if (widgetStates) return widgetStates
|
||||
|
||||
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
|
||||
graphWidgetStates.value.set(graphId, nextWidgetStates)
|
||||
return nextWidgetStates
|
||||
}
|
||||
|
||||
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
|
||||
return `${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
function registerWidget<TValue = unknown>(
|
||||
graphId: UUID,
|
||||
state: WidgetState<TValue>
|
||||
state: WidgetRegistration<TValue>
|
||||
): WidgetState<TValue> {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
widgetStates.set(key, state)
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
const world = getWorld()
|
||||
const branded = asGraphId(graphId)
|
||||
const widgetId = widgetEntityId(branded, state.nodeId, state.name)
|
||||
|
||||
world.setComponent(widgetId, WidgetComponentValue, { value: state.value })
|
||||
world.setComponent(widgetId, WidgetComponentDisplay, {
|
||||
label: state.label,
|
||||
disabled: state.disabled ?? false
|
||||
})
|
||||
world.setComponent(widgetId, WidgetComponentSchema, {
|
||||
type: state.type,
|
||||
options: state.options
|
||||
})
|
||||
world.setComponent(widgetId, WidgetComponentSerialize, {
|
||||
serialize: state.serialize
|
||||
})
|
||||
|
||||
const ownerId = nodeEntityId(branded, state.nodeId)
|
||||
const container = world.getComponent(ownerId, WidgetComponentContainer)
|
||||
if (!container) {
|
||||
world.setComponent(ownerId, WidgetComponentContainer, {
|
||||
widgetIds: [widgetId]
|
||||
})
|
||||
} else if (!container.widgetIds.includes(widgetId)) {
|
||||
container.widgetIds.push(widgetId)
|
||||
}
|
||||
|
||||
return buildView(widgetId) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${nodeId}:`
|
||||
return [...widgetStates]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, state]) => state)
|
||||
/**
|
||||
* Build a delegating view object for a widget entity. The view owns no
|
||||
* data — every accessor routes through the world. Getters assert the
|
||||
* underlying bucket exists; setters silently no-op when the bucket is
|
||||
* missing (post-`clearGraph` safety) and never re-create buckets.
|
||||
*/
|
||||
function buildView(widgetId: WidgetEntityId): WidgetState {
|
||||
const world = getWorld()
|
||||
|
||||
function read<T>(key: ComponentKey<T, WidgetEntityId>): T {
|
||||
const bucket = world.getComponent(widgetId, key)
|
||||
if (!bucket) {
|
||||
throw new Error(
|
||||
`Widget ${widgetId} missing component ${key.name}; view is invalid (likely accessed after clearGraph).`
|
||||
)
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return read(WidgetComponentValue).value
|
||||
},
|
||||
set value(v: unknown) {
|
||||
const bucket = world.getComponent(widgetId, WidgetComponentValue)
|
||||
if (bucket) bucket.value = v
|
||||
},
|
||||
get label() {
|
||||
return read(WidgetComponentDisplay).label
|
||||
},
|
||||
set label(v: string | undefined) {
|
||||
const bucket = world.getComponent(widgetId, WidgetComponentDisplay)
|
||||
if (bucket) bucket.label = v
|
||||
},
|
||||
get disabled() {
|
||||
return read(WidgetComponentDisplay).disabled
|
||||
},
|
||||
set disabled(v: boolean | undefined) {
|
||||
const bucket = world.getComponent(widgetId, WidgetComponentDisplay)
|
||||
if (bucket) bucket.disabled = v ?? false
|
||||
},
|
||||
get type() {
|
||||
return read(WidgetComponentSchema).type
|
||||
},
|
||||
get options() {
|
||||
return read(WidgetComponentSchema).options
|
||||
},
|
||||
get serialize() {
|
||||
return read(WidgetComponentSerialize).serialize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getWidget(
|
||||
@@ -73,17 +128,66 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
nodeId: NodeId,
|
||||
widgetName: string
|
||||
): WidgetState | undefined {
|
||||
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
const world = getWorld()
|
||||
const widgetId = widgetEntityId(asGraphId(graphId), nodeId, widgetName)
|
||||
if (!world.getComponent(widgetId, WidgetComponentValue)) return undefined
|
||||
return buildView(widgetId)
|
||||
}
|
||||
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
const world = getWorld()
|
||||
const ownerId = nodeEntityId(asGraphId(graphId), nodeId)
|
||||
const container = world.getComponent(ownerId, WidgetComponentContainer)
|
||||
if (!container) return []
|
||||
const widgets: WidgetState[] = []
|
||||
for (const widgetId of container.widgetIds) {
|
||||
if (world.getComponent(widgetId, WidgetComponentValue)) {
|
||||
widgets.push(buildView(widgetId))
|
||||
}
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
|
||||
function getNodeWidgetsByName(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId
|
||||
): Map<string, WidgetState> {
|
||||
const world = getWorld()
|
||||
const ownerId = nodeEntityId(asGraphId(graphId), nodeId)
|
||||
const container = world.getComponent(ownerId, WidgetComponentContainer)
|
||||
const result = new Map<string, WidgetState>()
|
||||
if (!container) return result
|
||||
for (const widgetId of container.widgetIds) {
|
||||
if (!world.getComponent(widgetId, WidgetComponentValue)) continue
|
||||
const { name } = parseWidgetEntityId(widgetId)
|
||||
result.set(name, buildView(widgetId))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphWidgetStates.value.delete(graphId)
|
||||
const world = getWorld()
|
||||
const branded = asGraphId(graphId)
|
||||
for (const widgetId of world.entitiesWith(WidgetComponentValue)) {
|
||||
if (isWidgetIdForGraph(branded, widgetId)) {
|
||||
world.removeComponent(widgetId, WidgetComponentValue)
|
||||
world.removeComponent(widgetId, WidgetComponentDisplay)
|
||||
world.removeComponent(widgetId, WidgetComponentSchema)
|
||||
world.removeComponent(widgetId, WidgetComponentSerialize)
|
||||
}
|
||||
}
|
||||
for (const nodeId of world.entitiesWith(WidgetComponentContainer)) {
|
||||
if (isNodeIdForGraph(branded, nodeId)) {
|
||||
world.removeComponent(nodeId, WidgetComponentContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registerWidget,
|
||||
getWidget,
|
||||
getNodeWidgets,
|
||||
getNodeWidgetsByName,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
|
||||
25
src/utils/compositeKey.test.ts
Normal file
25
src/utils/compositeKey.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { makeCompositeKey } from './compositeKey'
|
||||
|
||||
describe('makeCompositeKey', () => {
|
||||
it('produces a stable string for a tuple of values', () => {
|
||||
expect(makeCompositeKey(['a', 'b', 'c'])).toBe('["a","b","c"]')
|
||||
})
|
||||
|
||||
it('distinguishes tuples whose joined parts collide', () => {
|
||||
// Without an injective encoding, ['ab', 'c'] and ['a', 'bc'] could collide.
|
||||
expect(makeCompositeKey(['ab', 'c'])).not.toBe(
|
||||
makeCompositeKey(['a', 'bc'])
|
||||
)
|
||||
})
|
||||
|
||||
it('handles empty parts and undefined slots', () => {
|
||||
expect(makeCompositeKey(['x', '', 'y'])).toBe('["x","","y"]')
|
||||
expect(makeCompositeKey(['x', undefined, 'y'])).toBe('["x",null,"y"]')
|
||||
})
|
||||
|
||||
it('preserves part order', () => {
|
||||
expect(makeCompositeKey(['1', '2'])).not.toBe(makeCompositeKey(['2', '1']))
|
||||
})
|
||||
})
|
||||
10
src/utils/compositeKey.ts
Normal file
10
src/utils/compositeKey.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Build an opaque composite-key string from a tuple of values, suitable for
|
||||
* use as a Map or Set key. Uses JSON.stringify so the encoding is injective
|
||||
* across arbitrary string inputs (no separator collision possible). Keep the
|
||||
* format opaque at consumer boundaries — do not parse it externally except
|
||||
* in modules that own the round-trip (e.g. favoritedWidgetsStore).
|
||||
*/
|
||||
export function makeCompositeKey(parts: readonly unknown[]): string {
|
||||
return JSON.stringify(parts)
|
||||
}
|
||||
@@ -1,11 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { resolveNode } from './litegraphUtil'
|
||||
import { resolveNode, resolveNodeWidget } from './litegraphUtil'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
widgetStates: new Map(),
|
||||
setPositionOverride: vi.fn(),
|
||||
clearPositionOverride: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createPromotedWidgetFixture(hostId: number) {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: '*' }]
|
||||
})
|
||||
const inner = new LGraphNode('Inner')
|
||||
const input = inner.addInput('value', '*')
|
||||
inner.addWidget('text', 'value', 'a', () => {})
|
||||
input.widget = { name: 'value' }
|
||||
subgraph.add(inner)
|
||||
subgraph.inputNode.slots[0].connect(input, inner)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host._internalConfigureAfterSlots()
|
||||
host.graph!.add(host)
|
||||
|
||||
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
|
||||
const promoted = host.widgets.find(isPromotedWidgetView)
|
||||
if (!promoted) throw new Error('Expected promoted widget view')
|
||||
|
||||
return { host, promoted }
|
||||
}
|
||||
|
||||
describe('resolveNode', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
it('returns undefined when graph is null', () => {
|
||||
expect(resolveNode(1, null)).toBeUndefined()
|
||||
})
|
||||
@@ -67,4 +119,43 @@ describe('resolveNode', () => {
|
||||
const targetNode = sg2._nodes[0]
|
||||
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
|
||||
})
|
||||
|
||||
it('resolves promoted widget by host node id and storeName', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(501)
|
||||
|
||||
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
|
||||
host.id,
|
||||
promoted.storeName,
|
||||
host.graph!
|
||||
)
|
||||
|
||||
expect(resolvedNode).toBe(host)
|
||||
expect(resolvedWidget).toBe(promoted)
|
||||
})
|
||||
|
||||
it('keeps legacy fallback for saved promoted widget source tuples', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(502)
|
||||
|
||||
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
|
||||
promoted.sourceNodeId,
|
||||
promoted.sourceWidgetName,
|
||||
host.graph!
|
||||
)
|
||||
|
||||
expect(resolvedNode).toBe(host)
|
||||
expect(resolvedWidget).toBe(promoted)
|
||||
})
|
||||
|
||||
it('keeps legacy fallback for saved promoted widget source tuples with numeric node ids', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(503)
|
||||
|
||||
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
|
||||
Number(promoted.sourceNodeId),
|
||||
promoted.sourceWidgetName,
|
||||
host.graph!
|
||||
)
|
||||
|
||||
expect(resolvedNode).toBe(host)
|
||||
expect(resolvedWidget).toBe(promoted)
|
||||
})
|
||||
})
|
||||
|
||||
6
src/world/brand.ts
Normal file
6
src/world/brand.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Nominal-typed brand helper. Used by entity ID and component-key types so
|
||||
* mixing kinds is a compile-time error.
|
||||
*/
|
||||
declare const brand: unique symbol
|
||||
export type Brand<T, Tag extends string> = T & { readonly [brand]: Tag }
|
||||
84
src/world/componentKey.test.ts
Normal file
84
src/world/componentKey.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
|
||||
import type { ComponentKey } from './componentKey'
|
||||
import { defineComponentKey, defineComponentKeys, slot } from './componentKey'
|
||||
import type { NodeEntityId, WidgetEntityId } from './entityIds'
|
||||
|
||||
describe('defineComponentKeys', () => {
|
||||
it('synthesizes runtime names from prefix and property keys', () => {
|
||||
const keys = defineComponentKeys('Foo', {
|
||||
Bar: slot<{ x: number }, NodeEntityId>(),
|
||||
Baz: slot<{ y: string }, NodeEntityId>()
|
||||
})
|
||||
expect(keys.FooComponentBar.name).toBe('FooComponentBar')
|
||||
expect(keys.FooComponentBaz.name).toBe('FooComponentBaz')
|
||||
})
|
||||
|
||||
it('produces keys with distinct identities across calls', () => {
|
||||
// Suppress dev-time collision warning fired by defineComponentKey.
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const a = defineComponentKeys('FooA', {
|
||||
Bar: slot<Record<string, never>, NodeEntityId>()
|
||||
})
|
||||
const b = defineComponentKeys('FooA', {
|
||||
Bar: slot<Record<string, never>, NodeEntityId>()
|
||||
})
|
||||
// Two separate factory calls => two separate key objects, even with same name.
|
||||
expect(a.FooAComponentBar).not.toBe(b.FooAComponentBar)
|
||||
expect(a.FooAComponentBar.name).toBe(b.FooAComponentBar.name)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('produces keys with the expected literal-type name (compile-time check)', () => {
|
||||
const keys = defineComponentKeys('WidgetTest', {
|
||||
Value: slot<{ value: unknown }, WidgetEntityId>()
|
||||
})
|
||||
// Type-only assertion: the literal name flows through the type. If the
|
||||
// literal disappears from the return type, this assignment fails to
|
||||
// compile.
|
||||
type CheckName = (typeof keys.WidgetTestComponentValue)['name']
|
||||
const _check: CheckName = 'WidgetTestComponentValue'
|
||||
void _check
|
||||
expect(keys.WidgetTestComponentValue.name).toBe('WidgetTestComponentValue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComponentKey type shapes', () => {
|
||||
it('defineComponentKey returns ComponentKey<TData, TEntity>', () => {
|
||||
const key = defineComponentKey<{ value: number }, WidgetEntityId>(
|
||||
'TypeShapeKey'
|
||||
)
|
||||
expectTypeOf(key).toEqualTypeOf<
|
||||
ComponentKey<{ value: number }, WidgetEntityId>
|
||||
>()
|
||||
})
|
||||
|
||||
it('defineComponentKeys recovers TData/TEntity per slot', () => {
|
||||
const keys = defineComponentKeys('Demo', {
|
||||
Value: slot<{ v: number }, WidgetEntityId>(),
|
||||
Tag: slot<string, NodeEntityId>()
|
||||
})
|
||||
// Each key carries its own (TData, TEntity, full-name literal) trio.
|
||||
expectTypeOf(keys.DemoComponentValue).toEqualTypeOf<
|
||||
ComponentKey<{ v: number }, WidgetEntityId, 'DemoComponentValue'>
|
||||
>()
|
||||
expectTypeOf(keys.DemoComponentTag).toEqualTypeOf<
|
||||
ComponentKey<string, NodeEntityId, 'DemoComponentTag'>
|
||||
>()
|
||||
})
|
||||
|
||||
it('ComponentKey phantom params keep entity kinds disjoint', () => {
|
||||
// A widget-keyed ComponentKey is not assignable to a node-keyed one,
|
||||
// even when TData matches.
|
||||
expectTypeOf<
|
||||
ComponentKey<{ v: number }, WidgetEntityId> extends ComponentKey<
|
||||
{ v: number },
|
||||
NodeEntityId
|
||||
>
|
||||
? true
|
||||
: false
|
||||
>().toEqualTypeOf<false>()
|
||||
})
|
||||
})
|
||||
88
src/world/componentKey.ts
Normal file
88
src/world/componentKey.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { EntityId } from './entityIds'
|
||||
|
||||
declare const componentKeyData: unique symbol
|
||||
declare const componentKeyEntity: unique symbol
|
||||
declare const slotData: unique symbol
|
||||
declare const slotEntity: unique symbol
|
||||
|
||||
/**
|
||||
* Nominal handle for a component type. The phantom params drive
|
||||
* `world.getComponent` return-type inference and forbid cross-kind misuse
|
||||
* (e.g. reading a `WidgetValue` off a `NodeEntityId` is a type error).
|
||||
*
|
||||
* `TName` carries the registered name as a string literal type when the key
|
||||
* was produced via `defineComponentKeys`. For one-off `defineComponentKey`
|
||||
* calls it widens to `string`.
|
||||
*/
|
||||
export interface ComponentKey<
|
||||
TData,
|
||||
TEntity extends EntityId,
|
||||
TName extends string = string
|
||||
> {
|
||||
readonly name: TName
|
||||
readonly [componentKeyData]?: TData
|
||||
readonly [componentKeyEntity]?: TEntity
|
||||
}
|
||||
|
||||
/**
|
||||
* Phantom slot used as the per-property argument to `defineComponentKeys`.
|
||||
* `slot<TData, TEntity>()` returns an empty object whose phantom symbols
|
||||
* carry the data + entity types so the factory can recover them via `infer`.
|
||||
*/
|
||||
interface Slot<TData, TEntity extends EntityId> {
|
||||
readonly [slotData]?: TData
|
||||
readonly [slotEntity]?: TEntity
|
||||
}
|
||||
|
||||
export function slot<TData, TEntity extends EntityId>(): Slot<TData, TEntity> {
|
||||
return {} as Slot<TData, TEntity>
|
||||
}
|
||||
|
||||
const registeredNames = new Set<string>()
|
||||
|
||||
export function defineComponentKey<TData, TEntity extends EntityId>(
|
||||
name: string
|
||||
): ComponentKey<TData, TEntity> {
|
||||
if (import.meta.env.DEV && registeredNames.has(name)) {
|
||||
console.error(
|
||||
`[world] ComponentKey name collision: "${name}" was already registered. ` +
|
||||
`Two keys with the same name share storage and will silently overwrite each other.`
|
||||
)
|
||||
}
|
||||
registeredNames.add(name)
|
||||
return { name } as ComponentKey<TData, TEntity>
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a related set of `ComponentKey`s under a shared prefix in one call.
|
||||
*
|
||||
* The full registered name for each key is `${TPrefix}Component${ShortName}`,
|
||||
* derived from both the runtime prefix and the property keys of the slots
|
||||
* object. The literal-type return signature mirrors that string so each key
|
||||
* carries its full name as a string literal type.
|
||||
*
|
||||
* Internally calls `defineComponentKey` per slot, so the dev-time collision
|
||||
* warning still fires for factory-created keys.
|
||||
*/
|
||||
export function defineComponentKeys<
|
||||
TPrefix extends string,
|
||||
TSlots extends Record<string, Slot<unknown, EntityId>>
|
||||
>(
|
||||
prefix: TPrefix,
|
||||
slots: TSlots
|
||||
): {
|
||||
[K in keyof TSlots &
|
||||
string as `${TPrefix}Component${K}`]: TSlots[K] extends Slot<
|
||||
infer TData,
|
||||
infer TEntity
|
||||
>
|
||||
? ComponentKey<TData, TEntity, `${TPrefix}Component${K}`>
|
||||
: never
|
||||
} {
|
||||
const result = {} as Record<string, ComponentKey<unknown, EntityId>>
|
||||
for (const shortName of Object.keys(slots)) {
|
||||
const fullName = `${prefix}Component${shortName}`
|
||||
result[fullName] = defineComponentKey<unknown, EntityId>(fullName)
|
||||
}
|
||||
return result as never
|
||||
}
|
||||
97
src/world/entityIds.test.ts
Normal file
97
src/world/entityIds.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import type { NodeEntityId, WidgetEntityId } from './entityIds'
|
||||
import {
|
||||
asGraphId,
|
||||
nodeEntityId,
|
||||
parseWidgetEntityId,
|
||||
widgetEntityId
|
||||
} from './entityIds'
|
||||
|
||||
describe('parseWidgetEntityId', () => {
|
||||
const graphId = asGraphId('a3f2c1d8-4567-89ab-cdef-1234567890ab' as UUID)
|
||||
|
||||
it('round-trips a simple name', () => {
|
||||
const id = widgetEntityId(graphId, 42 as NodeId, 'seed')
|
||||
expect(parseWidgetEntityId(id)).toEqual({
|
||||
graphId,
|
||||
nodeId: '42',
|
||||
name: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves names containing colons', () => {
|
||||
const id = widgetEntityId(graphId, 7 as NodeId, 'images.image:0')
|
||||
expect(parseWidgetEntityId(id).name).toBe('images.image:0')
|
||||
})
|
||||
|
||||
it('handles string node ids', () => {
|
||||
// Documented limitation: a colon-containing nodeId would split at the
|
||||
// FIRST colon after graphId. NodeId values are scalar-shaped in
|
||||
// production, so we only assert the graphId still round-trips here.
|
||||
const id = widgetEntityId(graphId, '12:5' as NodeId, 'sub_widget')
|
||||
const parsed = parseWidgetEntityId(id)
|
||||
expect(parsed.graphId).toBe(graphId)
|
||||
})
|
||||
|
||||
it('round-trips an empty name', () => {
|
||||
const id = widgetEntityId(graphId, 1 as NodeId, '')
|
||||
expect(parseWidgetEntityId(id)).toEqual({
|
||||
graphId,
|
||||
nodeId: '1',
|
||||
name: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on missing widget: prefix', () => {
|
||||
expect(() =>
|
||||
parseWidgetEntityId(`node:${graphId}:42` as unknown as WidgetEntityId)
|
||||
).toThrow(/Malformed WidgetEntityId/)
|
||||
})
|
||||
|
||||
it('throws on too few colons', () => {
|
||||
expect(() => parseWidgetEntityId('widget:abc' as WidgetEntityId)).toThrow(
|
||||
/Malformed WidgetEntityId/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when nodeId segment is missing', () => {
|
||||
expect(() =>
|
||||
parseWidgetEntityId(`widget:${graphId}:42` as WidgetEntityId)
|
||||
).toThrow(/Malformed WidgetEntityId/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('entityIds type shapes', () => {
|
||||
type GraphId = ReturnType<typeof asGraphId>
|
||||
|
||||
it('widgetEntityId returns the WidgetEntityId brand', () => {
|
||||
expectTypeOf(widgetEntityId).returns.toEqualTypeOf<WidgetEntityId>()
|
||||
})
|
||||
|
||||
it('nodeEntityId returns the NodeEntityId brand', () => {
|
||||
expectTypeOf(nodeEntityId).returns.toEqualTypeOf<NodeEntityId>()
|
||||
})
|
||||
|
||||
it('parseWidgetEntityId returns the documented shape', () => {
|
||||
expectTypeOf(parseWidgetEntityId).returns.toEqualTypeOf<{
|
||||
graphId: GraphId
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
}>()
|
||||
})
|
||||
|
||||
it('WidgetEntityId and NodeEntityId are distinct brands', () => {
|
||||
// Brand isolation: neither direction is assignable. Both `extends`
|
||||
// checks must resolve to `never` for the brand contract to hold.
|
||||
expectTypeOf<
|
||||
WidgetEntityId extends NodeEntityId ? WidgetEntityId : never
|
||||
>().toEqualTypeOf<never>()
|
||||
expectTypeOf<
|
||||
NodeEntityId extends WidgetEntityId ? NodeEntityId : never
|
||||
>().toEqualTypeOf<never>()
|
||||
})
|
||||
})
|
||||
93
src/world/entityIds.ts
Normal file
93
src/world/entityIds.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Entity IDs are deterministic, content-addressed, string-prefixed values
|
||||
* — not opaque numerics (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* Identity is keyed by `rootGraph.id`, so an entity viewed at different
|
||||
* subgraph depths shares state. Migrating to numeric IDs would break
|
||||
* cross-subgraph value sharing. See ADR 0008 and `widgetValueStore.ts`.
|
||||
*
|
||||
* The `graph*Prefix` and `*EntityId` helpers below are the sole owners of
|
||||
* the on-the-wire format. Never hand-construct or parse these strings.
|
||||
*/
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import type { Brand } from './brand'
|
||||
|
||||
type GraphId = Brand<UUID, 'GraphId'>
|
||||
|
||||
export function asGraphId(id: UUID): GraphId {
|
||||
return id as GraphId
|
||||
}
|
||||
|
||||
export type NodeEntityId = Brand<string, 'NodeEntityId'>
|
||||
|
||||
function graphNodePrefix(graphId: GraphId): string {
|
||||
return `node:${graphId}:`
|
||||
}
|
||||
|
||||
export function nodeEntityId(graphId: GraphId, nodeId: NodeId): NodeEntityId {
|
||||
return `${graphNodePrefix(graphId)}${nodeId}` as NodeEntityId
|
||||
}
|
||||
|
||||
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
|
||||
function graphWidgetPrefix(graphId: GraphId): string {
|
||||
return `widget:${graphId}:`
|
||||
}
|
||||
|
||||
export function widgetEntityId(
|
||||
graphId: GraphId,
|
||||
nodeId: NodeId,
|
||||
name: string
|
||||
): WidgetEntityId {
|
||||
return `${graphWidgetPrefix(graphId)}${nodeId}:${name}` as WidgetEntityId
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `WidgetEntityId` into its constituent parts.
|
||||
*
|
||||
* The on-the-wire format is `widget:${graphId}:${nodeId}:${name}`. The
|
||||
* regex captures the first two colon-delimited segments as graphId and
|
||||
* nodeId, then takes the rest as the widget name. This means widget
|
||||
* names may contain colons (e.g. `images.image:0`).
|
||||
*
|
||||
* Throws on malformed input (missing prefix, too few colons) so
|
||||
* upstream type-cast bugs surface at the parse site instead of leaking
|
||||
* garbage `{graphId, nodeId, name}` triples downstream.
|
||||
*
|
||||
* Limitation: nodeIds containing colons are not supported. NodeId values
|
||||
* are always serialized scalars (numeric or short string) in production,
|
||||
* so this is a documented edge case rather than a defect.
|
||||
*/
|
||||
const WIDGET_ID_RE = /^widget:([^:]+):([^:]+):(.*)$/
|
||||
|
||||
export function parseWidgetEntityId(id: WidgetEntityId): {
|
||||
graphId: GraphId
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
} {
|
||||
const match = WIDGET_ID_RE.exec(id)
|
||||
if (!match) {
|
||||
throw new Error(`Malformed WidgetEntityId: ${id}`)
|
||||
}
|
||||
const [, graphId, nodeId, name] = match
|
||||
return {
|
||||
graphId: graphId as GraphId,
|
||||
nodeId: nodeId as NodeId,
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
export function isNodeIdForGraph(graphId: GraphId, id: NodeEntityId): boolean {
|
||||
return id.startsWith(graphNodePrefix(graphId))
|
||||
}
|
||||
|
||||
export function isWidgetIdForGraph(
|
||||
graphId: GraphId,
|
||||
id: WidgetEntityId
|
||||
): boolean {
|
||||
return id.startsWith(graphWidgetPrefix(graphId))
|
||||
}
|
||||
|
||||
export type EntityId = NodeEntityId | WidgetEntityId
|
||||
31
src/world/widgets/widgetComponents.ts
Normal file
31
src/world/widgets/widgetComponents.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { defineComponentKeys, slot } from '@/world/componentKey'
|
||||
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
/**
|
||||
* Per-bucket widget component shapes. Each bucket carves a disjoint slice
|
||||
* of {@link IBaseWidget} so the component stores stay in sync with the
|
||||
* source of truth in `src/lib/litegraph/src/types/widgets.ts`.
|
||||
*/
|
||||
type WidgetValue = Pick<IBaseWidget<unknown>, 'value'>
|
||||
type WidgetDisplay = Pick<IBaseWidget, 'label' | 'disabled'>
|
||||
type WidgetSchema = Pick<IBaseWidget, 'type' | 'options'>
|
||||
type WidgetSerialize = Pick<IBaseWidget, 'serialize'>
|
||||
|
||||
interface WidgetContainer {
|
||||
widgetIds: WidgetEntityId[]
|
||||
}
|
||||
|
||||
export const {
|
||||
WidgetComponentValue,
|
||||
WidgetComponentDisplay,
|
||||
WidgetComponentSchema,
|
||||
WidgetComponentSerialize,
|
||||
WidgetComponentContainer
|
||||
} = defineComponentKeys('Widget', {
|
||||
Value: slot<WidgetValue, WidgetEntityId>(),
|
||||
Display: slot<WidgetDisplay, WidgetEntityId>(),
|
||||
Schema: slot<WidgetSchema, WidgetEntityId>(),
|
||||
Serialize: slot<WidgetSerialize, WidgetEntityId>(),
|
||||
Container: slot<WidgetContainer, NodeEntityId>()
|
||||
})
|
||||
43
src/world/widgets/widgetState.ts
Normal file
43
src/world/widgets/widgetState.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* `WidgetState` is a *derived view* over the four widget-side components
|
||||
* (`WidgetComponentValue` / `Display` / `Schema` / `Serialize`). Property
|
||||
* accessors are installed via `Object.defineProperty` and delegate live
|
||||
* to the world; reads always hit the underlying reactive proxies, so
|
||||
* Vue tracking propagates through the view.
|
||||
*
|
||||
* Object identity is **not** preserved across `getWidget` calls — each
|
||||
* call constructs a fresh view. Data semantics round-trip; identity does
|
||||
* not. Do not cache views or rely on `===`.
|
||||
*
|
||||
* `name` and `nodeId` are not present on the view: they live in the
|
||||
* underlying `WidgetEntityId` and would be a redundant copy here. Callers
|
||||
* that need them should derive from the entity id (or from the BaseWidget
|
||||
* instance, which still owns them).
|
||||
*/
|
||||
export type WidgetState<
|
||||
TValue = unknown,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
> = Pick<
|
||||
IBaseWidget<TValue, TType, TOptions>,
|
||||
'value' | 'options' | 'label' | 'serialize' | 'disabled' | 'type'
|
||||
>
|
||||
|
||||
/**
|
||||
* Input shape for `registerWidget`: a `WidgetState` view augmented with the
|
||||
* identity fields (`name`, `nodeId`) needed to construct the widget's
|
||||
* `WidgetEntityId`. The view returned from `registerWidget` is the
|
||||
* un-augmented `WidgetState` because identity fields live in the entity id.
|
||||
*/
|
||||
export interface WidgetRegistration<
|
||||
TValue = unknown
|
||||
> extends WidgetState<TValue> {
|
||||
name: string
|
||||
nodeId: NodeId
|
||||
}
|
||||
176
src/world/world.test.ts
Normal file
176
src/world/world.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineComponentKey } from './componentKey'
|
||||
import type { NodeEntityId, WidgetEntityId } from './entityIds'
|
||||
import { asGraphId, nodeEntityId, widgetEntityId } from './entityIds'
|
||||
import { createWorld } from './world'
|
||||
|
||||
const TestWidgetThing = defineComponentKey<{ value: number }, WidgetEntityId>(
|
||||
'TestWidgetThing'
|
||||
)
|
||||
|
||||
const TestNodeThing = defineComponentKey<{ tag: string }, NodeEntityId>(
|
||||
'TestNodeThing'
|
||||
)
|
||||
|
||||
describe('createWorld', () => {
|
||||
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
|
||||
|
||||
it('round-trips set / get / remove', () => {
|
||||
const world = createWorld()
|
||||
const widgetId = widgetEntityId(graphId, 1, 'seed')
|
||||
|
||||
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
|
||||
|
||||
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
|
||||
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(42)
|
||||
|
||||
world.removeComponent(widgetId, TestWidgetThing)
|
||||
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('propagates mutations through the stored proxy', () => {
|
||||
const world = createWorld()
|
||||
const widgetId = widgetEntityId(graphId, 1, 'seed')
|
||||
const data = { value: 42 }
|
||||
world.setComponent(widgetId, TestWidgetThing, data)
|
||||
data.value = 99
|
||||
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(99)
|
||||
})
|
||||
|
||||
it('returns the same proxy across reads of the same (id, key)', () => {
|
||||
const world = createWorld()
|
||||
const widgetId = widgetEntityId(graphId, 1, 'seed')
|
||||
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
|
||||
|
||||
const a = world.getComponent(widgetId, TestWidgetThing)
|
||||
const b = world.getComponent(widgetId, TestWidgetThing)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('reacts when subscribing before the first component for a key exists', () => {
|
||||
const world = createWorld()
|
||||
const widgetId = widgetEntityId(graphId, 1, 'seed')
|
||||
const observed = computed(
|
||||
() => world.getComponent(widgetId, TestWidgetThing)?.value
|
||||
)
|
||||
|
||||
expect(observed.value).toBeUndefined()
|
||||
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
|
||||
expect(observed.value).toBe(42)
|
||||
})
|
||||
|
||||
it('iterates entities for a given component key', () => {
|
||||
const world = createWorld()
|
||||
const a = widgetEntityId(graphId, 1, 'seed')
|
||||
const b = widgetEntityId(graphId, 1, 'cfg')
|
||||
world.setComponent(a, TestWidgetThing, { value: 1 })
|
||||
world.setComponent(b, TestWidgetThing, { value: 2 })
|
||||
|
||||
const ids = world.entitiesWith(TestWidgetThing)
|
||||
expect(ids.sort()).toEqual([a, b].sort())
|
||||
})
|
||||
|
||||
it('keeps entity kinds isolated by ComponentKey phantom param', () => {
|
||||
const world = createWorld()
|
||||
const nodeId = nodeEntityId(graphId, 1)
|
||||
world.setComponent(nodeId, TestNodeThing, { tag: 'foo' })
|
||||
expect(world.getComponent(nodeId, TestNodeThing)?.tag).toBe('foo')
|
||||
|
||||
// Cross-kind access is rejected at compile time. The type-level assertion
|
||||
// below fails to compile if `widgetEntityId(...)` ever becomes assignable
|
||||
// to a parameter expecting `NodeEntityId`, locking in the brand isolation
|
||||
// contract without resorting to `@ts-expect-error`.
|
||||
type CrossKindGetComponent = Parameters<
|
||||
typeof world.getComponent<{ tag: string }, NodeEntityId>
|
||||
>[0]
|
||||
type WidgetIsNotAssignableToNode =
|
||||
WidgetEntityId extends CrossKindGetComponent ? false : true
|
||||
const _crossKindIsRejected: WidgetIsNotAssignableToNode = true
|
||||
expect(_crossKindIsRejected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widgetEntityId', () => {
|
||||
it('is deterministic across (graphId, nodeId, name)', () => {
|
||||
const g = asGraphId('00000000-0000-0000-0000-000000000001')
|
||||
expect(widgetEntityId(g, 1, 'seed')).toBe(widgetEntityId(g, 1, 'seed'))
|
||||
})
|
||||
|
||||
it('preserves cross-subgraph identity (root graph keying)', () => {
|
||||
// Same root graph + same nodeId + same name = same entity, regardless of
|
||||
// the subgraph depth from which the consumer reaches the node.
|
||||
const g = asGraphId('00000000-0000-0000-0000-000000000001')
|
||||
const fromRoot = widgetEntityId(g, 42, 'seed')
|
||||
const fromNested = widgetEntityId(g, 42, 'seed')
|
||||
expect(fromRoot).toBe(fromNested)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComponentKey identity', () => {
|
||||
it('keys component buckets by reference, not by name string', () => {
|
||||
// Suppress the dev-time collision warning emitted by defineComponentKey.
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
interface PayloadA {
|
||||
a: number
|
||||
}
|
||||
interface PayloadB {
|
||||
b: number
|
||||
}
|
||||
const keyA = defineComponentKey<PayloadA, NodeEntityId>('Collision')
|
||||
const keyB = defineComponentKey<PayloadB, NodeEntityId>('Collision')
|
||||
expect(keyA).not.toBe(keyB)
|
||||
expect(keyA.name).toBe(keyB.name)
|
||||
|
||||
const world = createWorld()
|
||||
const id = nodeEntityId(
|
||||
asGraphId('00000000-0000-0000-0000-000000000001'),
|
||||
1
|
||||
)
|
||||
|
||||
world.setComponent(id, keyA, { a: 1 })
|
||||
world.setComponent(id, keyB, { b: 2 })
|
||||
|
||||
expect(world.getComponent(id, keyA)).toEqual({ a: 1 })
|
||||
expect(world.getComponent(id, keyB)).toEqual({ b: 2 })
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('World type shapes', () => {
|
||||
const world = createWorld()
|
||||
const WidgetThing = defineComponentKey<{ value: number }, WidgetEntityId>(
|
||||
'TypeShapeWidgetThing'
|
||||
)
|
||||
|
||||
it('getComponent narrows to TData | undefined for the key', () => {
|
||||
expectTypeOf(
|
||||
world.getComponent<{ value: number }, WidgetEntityId>
|
||||
).returns.toEqualTypeOf<{ value: number } | undefined>()
|
||||
})
|
||||
|
||||
it('setComponent third parameter matches the key TData', () => {
|
||||
expectTypeOf(world.setComponent<{ value: number }, WidgetEntityId>)
|
||||
.parameter(2)
|
||||
.toEqualTypeOf<{ value: number }>()
|
||||
})
|
||||
|
||||
it('entitiesWith returns TEntity[] for the key', () => {
|
||||
expectTypeOf(
|
||||
world.entitiesWith<{ value: number }, WidgetEntityId>
|
||||
).returns.toEqualTypeOf<WidgetEntityId[]>()
|
||||
})
|
||||
|
||||
it('rejects cross-kind entity ids at the call site', () => {
|
||||
// A widget-keyed read demands a WidgetEntityId. NodeEntityId must not
|
||||
// be assignable, otherwise a NodeEntityId could be passed to a
|
||||
// ComponentKey<_, WidgetEntityId>.
|
||||
expectTypeOf<
|
||||
NodeEntityId extends WidgetEntityId ? true : false
|
||||
>().toEqualTypeOf<false>()
|
||||
void WidgetThing
|
||||
})
|
||||
})
|
||||
94
src/world/world.ts
Normal file
94
src/world/world.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import type { ComponentKey } from './componentKey'
|
||||
import type { EntityId } from './entityIds'
|
||||
|
||||
/**
|
||||
* `setComponent` stores by reference; `getComponent` returns a Vue proxy
|
||||
* cached per `(id, key)`. The proxy is stable across reads and is NOT
|
||||
* `===` to the input. Treat `getComponent` as the canonical read path.
|
||||
*
|
||||
* Component buckets are keyed by `ComponentKey` reference identity, NOT by
|
||||
* `key.name`. Two distinct keys with the same `name` string therefore do
|
||||
* not share storage. `key.name` remains useful for debugging only.
|
||||
*/
|
||||
export interface World {
|
||||
getComponent<TData, TEntity extends EntityId>(
|
||||
id: TEntity,
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): TData | undefined
|
||||
setComponent<TData, TEntity extends EntityId>(
|
||||
id: TEntity,
|
||||
key: ComponentKey<TData, TEntity>,
|
||||
data: TData
|
||||
): void
|
||||
removeComponent<TData, TEntity extends EntityId>(
|
||||
id: TEntity,
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): void
|
||||
entitiesWith<TData, TEntity extends EntityId>(
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): TEntity[]
|
||||
}
|
||||
|
||||
interface AnyComponentKey extends ComponentKey<unknown, EntityId> {}
|
||||
interface AnyBucket extends Map<EntityId, unknown> {}
|
||||
interface Bucket<TData, TEntity extends EntityId> extends Map<TEntity, TData> {}
|
||||
|
||||
export function createWorld(): World {
|
||||
// shallowReactive so first-bucket creation is observable to subscribers.
|
||||
const store = shallowReactive(new Map<AnyComponentKey, AnyBucket>())
|
||||
|
||||
/**
|
||||
* The single existential erasure boundary. The phantom `TData`/`TEntity`
|
||||
* params on `ComponentKey` are not representable in the heterogeneous outer
|
||||
* `Map`, so we erase here and reify in `getBucket`.
|
||||
*/
|
||||
function eraseKey<TData, TEntity extends EntityId>(
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): AnyComponentKey {
|
||||
return key as AnyComponentKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Invariant (audited at this boundary only): for a given
|
||||
* `ComponentKey<TData, TEntity>`, the stored bucket is absent or a
|
||||
* `Map<TEntity, TData>` created and mutated only through this world.
|
||||
*/
|
||||
function getBucket<TData, TEntity extends EntityId>(
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): Bucket<TData, TEntity> | undefined {
|
||||
return store.get(eraseKey(key)) as Bucket<TData, TEntity> | undefined
|
||||
}
|
||||
|
||||
function getOrCreateBucket<TData, TEntity extends EntityId>(
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): Bucket<TData, TEntity> {
|
||||
const existing = getBucket(key)
|
||||
if (existing) return existing
|
||||
// `reactive()` widens the bucket's value type to `UnwrapRefSimple<TData>`;
|
||||
// `TData` is a generic so TS can't prove they coincide. Cast confined here.
|
||||
const created = reactive(new Map<TEntity, TData>()) as Bucket<
|
||||
TData,
|
||||
TEntity
|
||||
>
|
||||
store.set(eraseKey(key), created as AnyBucket)
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
getComponent(id, key) {
|
||||
return getBucket(key)?.get(id)
|
||||
},
|
||||
setComponent(id, key, data) {
|
||||
getOrCreateBucket(key).set(id, data)
|
||||
},
|
||||
removeComponent(id, key) {
|
||||
getBucket(key)?.delete(id)
|
||||
},
|
||||
entitiesWith(key) {
|
||||
const bucket = getBucket(key)
|
||||
return bucket ? Array.from(bucket.keys()) : []
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/world/worldInstance.ts
Normal file
14
src/world/worldInstance.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { World } from './world'
|
||||
import { createWorld } from './world'
|
||||
|
||||
/** Module-singleton `World` for the editor process. */
|
||||
let instance: World | undefined
|
||||
|
||||
export function getWorld(): World {
|
||||
if (!instance) instance = createWorld()
|
||||
return instance
|
||||
}
|
||||
|
||||
export function resetWorldInstance(): void {
|
||||
instance = undefined
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi } from 'vitest'
|
||||
import { beforeEach, vi } from 'vitest'
|
||||
import 'vue'
|
||||
|
||||
import { resetWorldInstance } from '@/world/worldInstance'
|
||||
|
||||
// The World is a module-level singleton; reset it between tests so widget
|
||||
// state registered via the widgetValueStore facade does not leak across tests.
|
||||
beforeEach(() => {
|
||||
resetWorldInstance()
|
||||
})
|
||||
|
||||
// Mock @sparkjsdev/spark which uses WASM that doesn't work in Node.js
|
||||
vi.mock('@sparkjsdev/spark', () => ({
|
||||
SplatMesh: class SplatMesh {
|
||||
|
||||
Reference in New Issue
Block a user