mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 00:14:55 +00:00
Compare commits
13 Commits
v1.46.2
...
drjkl/worl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b906c30ad | ||
|
|
858fe23859 | ||
|
|
e364d69c4d | ||
|
|
01d4192f79 | ||
|
|
0e206118d3 | ||
|
|
9b0e47f72d | ||
|
|
137b3664e2 | ||
|
|
20cbe7c83a | ||
|
|
d45f13cf61 | ||
|
|
36a9b6467c | ||
|
|
8523a523d8 | ||
|
|
ec472d3e54 | ||
|
|
b35d1f3b58 |
@@ -77,14 +77,42 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
|
||||
})
|
||||
|
||||
test('unknown converted widget', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_nodes_converted_widget'
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'missing_nodes_converted_widget.png'
|
||||
)
|
||||
})
|
||||
test(
|
||||
'unknown converted widget',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_nodes_converted_widget'
|
||||
)
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
// Title reflects the unknown node type
|
||||
await expect(node).toContainText('UNKNOWN NODE')
|
||||
|
||||
// Inputs include the regular IMAGE input and the converted-widget "foo"
|
||||
// (which is what differentiates this fixture from a plain missing node).
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
const inputs = (await nodeRef.getProperty('inputs')) as {
|
||||
name: string
|
||||
type: string
|
||||
widget?: { name: string }
|
||||
}[]
|
||||
expect(inputs.map((i) => i.name)).toEqual(['image', 'foo'])
|
||||
|
||||
const fooInput = inputs.find((i) => i.name === 'foo')
|
||||
expect(fooInput?.type).toBe('STRING')
|
||||
expect(fooInput?.widget?.name).toBe('foo')
|
||||
|
||||
// Per-node DOM screenshot localizes any visual regression to this node.
|
||||
// A canvas-wide screenshot turns a 1-row label drift into a "763 px diff"
|
||||
// with no signal about which widget is responsible.
|
||||
await expect(node).toHaveScreenshot(
|
||||
'missing_nodes_converted_widget_node.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('dynamically added input', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')
|
||||
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user