mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
Compare commits
1 Commits
ext-api/i-
...
drjkl/brav
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35d1f3b58 |
@@ -1,9 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { asGraphId } from '@/world/entityIds'
|
||||
import { registerWidgetInWorld } from '@/world/widgetWorldBridge'
|
||||
import { getWorld, resetWorldInstance } from '@/world/worldInstance'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
import {
|
||||
boundsExtractor,
|
||||
singleValueExtractor,
|
||||
useUpstreamValue
|
||||
} from './useUpstreamValue'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: { rootGraph: { id: '00000000-0000-0000-0000-000000000001' } }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
@@ -116,3 +132,36 @@ describe('boundsExtractor', () => {
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpstreamValue (World-backed read path)', () => {
|
||||
it('reads upstream node widgets via the World, not the Pinia store', () => {
|
||||
resetWorldInstance()
|
||||
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
|
||||
const state = reactive<WidgetState>({
|
||||
nodeId: 'upstream-1' as NodeId,
|
||||
name: 'value',
|
||||
type: 'number',
|
||||
value: 7,
|
||||
options: {}
|
||||
})
|
||||
registerWidgetInWorld(getWorld(), graphId, state)
|
||||
|
||||
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', () => {
|
||||
resetWorldInstance()
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => undefined,
|
||||
singleValueExtractor((v): v is number => typeof v === 'number')
|
||||
)
|
||||
expect(upstreamValue.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
import { asGraphId } from '@/world/entityIds'
|
||||
import { getNodeWidgetsThroughWorld } from '@/world/widgetWorldBridge'
|
||||
import { getWorld } from '@/world/worldInstance'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
@@ -16,14 +18,17 @@ export function useUpstreamValue<T>(
|
||||
extractValue: ValueExtractor<T>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
return computed(() => {
|
||||
const upstream = getLinkedUpstream()
|
||||
if (!upstream) return undefined
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
if (!graphId) return undefined
|
||||
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
|
||||
const widgets = getNodeWidgetsThroughWorld(
|
||||
getWorld(),
|
||||
asGraphId(graphId),
|
||||
upstream.nodeId
|
||||
)
|
||||
return extractValue(widgets, upstream.outputName)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import type {
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { asGraphId } from '@/world/entityIds'
|
||||
import { registerWidgetInWorld } from '@/world/widgetWorldBridge'
|
||||
import { getWorld } from '@/world/worldInstance'
|
||||
|
||||
export interface DrawWidgetOptions {
|
||||
/** The width of the node where this widget will be displayed. */
|
||||
@@ -149,6 +152,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
value: this.value,
|
||||
nodeId
|
||||
})
|
||||
registerWidgetInWorld(
|
||||
getWorld(),
|
||||
asGraphId(graphId),
|
||||
this._state as WidgetState
|
||||
)
|
||||
}
|
||||
|
||||
constructor(widget: TWidget & { node: LGraphNode })
|
||||
|
||||
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 }
|
||||
21
src/world/componentKey.ts
Normal file
21
src/world/componentKey.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { EntityId } from './entityIds'
|
||||
|
||||
declare const componentKeyData: unique symbol
|
||||
declare const componentKeyEntity: 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).
|
||||
*/
|
||||
export interface ComponentKey<TData, TEntity extends EntityId> {
|
||||
readonly name: string
|
||||
readonly [componentKeyData]?: TData
|
||||
readonly [componentKeyEntity]?: TEntity
|
||||
}
|
||||
|
||||
export function defineComponentKey<TData, TEntity extends EntityId>(
|
||||
name: string
|
||||
): ComponentKey<TData, TEntity> {
|
||||
return { name } as ComponentKey<TData, TEntity>
|
||||
}
|
||||
16
src/world/components/WidgetContainer.ts
Normal file
16
src/world/components/WidgetContainer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineComponentKey } from '../componentKey'
|
||||
import type { NodeEntityId, WidgetEntityId } from '../entityIds'
|
||||
|
||||
/**
|
||||
* Node-side list of widget entity ids. Replaces a back-reference from
|
||||
* `WidgetIdentity` to its parent node. Reverse lookup
|
||||
* (`widget → node`) goes through `WorldIndex.widgetParent()`.
|
||||
*/
|
||||
export interface WidgetContainer {
|
||||
widgetIds: WidgetEntityId[]
|
||||
}
|
||||
|
||||
export const WidgetContainerComponent = defineComponentKey<
|
||||
WidgetContainer,
|
||||
NodeEntityId
|
||||
>('WidgetContainer')
|
||||
12
src/world/components/WidgetDisplayState.ts
Normal file
12
src/world/components/WidgetDisplayState.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineComponentKey } from '../componentKey'
|
||||
import type { WidgetEntityId } from '../entityIds'
|
||||
|
||||
export interface WidgetDisplayState {
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const WidgetDisplayStateComponent = defineComponentKey<
|
||||
WidgetDisplayState,
|
||||
WidgetEntityId
|
||||
>('WidgetDisplayState')
|
||||
17
src/world/components/WidgetIdentity.ts
Normal file
17
src/world/components/WidgetIdentity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineComponentKey } from '../componentKey'
|
||||
import type { WidgetEntityId } from '../entityIds'
|
||||
|
||||
/**
|
||||
* Static identity for a widget entity. Intentionally has NO `parentNodeId`
|
||||
* back-reference — reverse lookup goes via the node-side `WidgetContainer`
|
||||
* component plus `WorldIndex.widgetParent()`. See ADR 0008.
|
||||
*/
|
||||
export interface WidgetIdentity {
|
||||
name: string
|
||||
widgetType: string
|
||||
}
|
||||
|
||||
export const WidgetIdentityComponent = defineComponentKey<
|
||||
WidgetIdentity,
|
||||
WidgetEntityId
|
||||
>('WidgetIdentity')
|
||||
14
src/world/components/WidgetSchema.ts
Normal file
14
src/world/components/WidgetSchema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { defineComponentKey } from '../componentKey'
|
||||
import type { WidgetEntityId } from '../entityIds'
|
||||
|
||||
export interface WidgetSchema {
|
||||
options: IWidgetOptions
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
export const WidgetSchemaComponent = defineComponentKey<
|
||||
WidgetSchema,
|
||||
WidgetEntityId
|
||||
>('WidgetSchema')
|
||||
16
src/world/components/WidgetValue.ts
Normal file
16
src/world/components/WidgetValue.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineComponentKey } from '../componentKey'
|
||||
import type { WidgetEntityId } from '../entityIds'
|
||||
|
||||
/**
|
||||
* Per-widget value. The bridge to `WidgetValueStore` shares the same
|
||||
* reactive object reference so Vue tracking is preserved across both
|
||||
* read paths during the migration window.
|
||||
*/
|
||||
export interface WidgetValue<T = unknown> {
|
||||
value: T
|
||||
}
|
||||
|
||||
export const WidgetValueComponent = defineComponentKey<
|
||||
WidgetValue,
|
||||
WidgetEntityId
|
||||
>('WidgetValue')
|
||||
40
src/world/entityIds.ts
Normal file
40
src/world/entityIds.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import type { Brand } from './brand'
|
||||
|
||||
export type GraphId = Brand<UUID, 'GraphId'>
|
||||
export type NodeEntityId = Brand<string, 'NodeEntityId'>
|
||||
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
|
||||
export type EntityId = NodeEntityId | WidgetEntityId
|
||||
|
||||
/**
|
||||
* Cast a UUID into a `GraphId`. Pinned at the seam so the rest of the world
|
||||
* code does not have to know about UUID-vs-GraphId.
|
||||
*/
|
||||
export function asGraphId(id: UUID): GraphId {
|
||||
return id as GraphId
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic widget-entity id derived from `(rootGraphId, nodeId, name)`.
|
||||
*
|
||||
* Matches the existing `WidgetValueStore` keying contract: nodes viewed at
|
||||
* different subgraph depths share the same widget state because consumers
|
||||
* pass `rootGraph.id`. See [widgetValueStore.ts](../stores/widgetValueStore.ts).
|
||||
*/
|
||||
export function widgetEntityId(
|
||||
graphId: GraphId,
|
||||
nodeId: NodeId,
|
||||
name: string
|
||||
): WidgetEntityId {
|
||||
return `widget:${graphId}:${nodeId}:${name}` as WidgetEntityId
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic node-entity id derived from `(rootGraphId, nodeId)`.
|
||||
*/
|
||||
export function nodeEntityId(graphId: GraphId, nodeId: NodeId): NodeEntityId {
|
||||
return `node:${graphId}:${nodeId}` as NodeEntityId
|
||||
}
|
||||
126
src/world/widgetWorldBridge.test.ts
Normal file
126
src/world/widgetWorldBridge.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
|
||||
import { WidgetContainerComponent } from './components/WidgetContainer'
|
||||
import { WidgetValueComponent } from './components/WidgetValue'
|
||||
import { asGraphId, nodeEntityId, widgetEntityId } from './entityIds'
|
||||
import { createWorld } from './world'
|
||||
import { worldIndex } from './worldIndex'
|
||||
import {
|
||||
getNodeWidgetsThroughWorld,
|
||||
registerWidgetInWorld,
|
||||
unregisterWidgetInWorld
|
||||
} from './widgetWorldBridge'
|
||||
|
||||
function makeState(nodeId: string, name: string, value: unknown): WidgetState {
|
||||
return { nodeId, name, type: 'number', value, options: {} }
|
||||
}
|
||||
|
||||
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
|
||||
|
||||
describe('registerWidgetInWorld', () => {
|
||||
it('writes WidgetValue and updates WidgetContainer on the node', () => {
|
||||
const world = createWorld()
|
||||
const state = makeState('node-1', 'seed', 100)
|
||||
|
||||
registerWidgetInWorld(world, graphId, state)
|
||||
|
||||
const widgetId = widgetEntityId(graphId, 'node-1', 'seed')
|
||||
const nodeId = nodeEntityId(graphId, 'node-1')
|
||||
expect(world.getComponent(widgetId, WidgetValueComponent)?.value).toBe(100)
|
||||
expect(
|
||||
world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
|
||||
).toEqual([widgetId])
|
||||
})
|
||||
|
||||
it('shares object identity with the registered state (reactive bridge)', () => {
|
||||
const world = createWorld()
|
||||
const state = makeState('node-1', 'seed', 100)
|
||||
registerWidgetInWorld(world, graphId, state)
|
||||
|
||||
state.value = 200
|
||||
const widgetId = widgetEntityId(graphId, 'node-1', 'seed')
|
||||
expect(world.getComponent(widgetId, WidgetValueComponent)?.value).toBe(200)
|
||||
})
|
||||
|
||||
it('appends additional widgets to the same node container', () => {
|
||||
const world = createWorld()
|
||||
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
|
||||
registerWidgetInWorld(world, graphId, makeState('node-1', 'cfg', 7))
|
||||
|
||||
const nodeId = nodeEntityId(graphId, 'node-1')
|
||||
const ids = world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
|
||||
expect(ids).toEqual([
|
||||
widgetEntityId(graphId, 'node-1', 'seed'),
|
||||
widgetEntityId(graphId, 'node-1', 'cfg')
|
||||
])
|
||||
})
|
||||
|
||||
it('does not duplicate widgetIds when the same widget re-registers', () => {
|
||||
const world = createWorld()
|
||||
const state = makeState('node-1', 'seed', 1)
|
||||
registerWidgetInWorld(world, graphId, state)
|
||||
registerWidgetInWorld(world, graphId, state)
|
||||
|
||||
const nodeId = nodeEntityId(graphId, 'node-1')
|
||||
expect(
|
||||
world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregisterWidgetInWorld', () => {
|
||||
it('removes the widget value and updates the container', () => {
|
||||
const world = createWorld()
|
||||
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
|
||||
registerWidgetInWorld(world, graphId, makeState('node-1', 'cfg', 7))
|
||||
|
||||
unregisterWidgetInWorld(world, graphId, 'node-1', 'seed')
|
||||
|
||||
expect(
|
||||
world.getComponent(
|
||||
widgetEntityId(graphId, 'node-1', 'seed'),
|
||||
WidgetValueComponent
|
||||
)
|
||||
).toBeUndefined()
|
||||
const nodeId = nodeEntityId(graphId, 'node-1')
|
||||
expect(
|
||||
world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
|
||||
).toEqual([widgetEntityId(graphId, 'node-1', 'cfg')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeWidgetsThroughWorld', () => {
|
||||
it('returns all widget states attached to a node', () => {
|
||||
const world = createWorld()
|
||||
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
|
||||
registerWidgetInWorld(world, graphId, makeState('node-1', 'cfg', 7))
|
||||
registerWidgetInWorld(world, graphId, makeState('node-2', 'seed', 99))
|
||||
|
||||
const widgets = getNodeWidgetsThroughWorld(world, graphId, 'node-1')
|
||||
expect(widgets.map((w) => w.name).sort()).toEqual(['cfg', 'seed'])
|
||||
})
|
||||
|
||||
it('returns an empty array for unknown nodes', () => {
|
||||
const world = createWorld()
|
||||
expect(getNodeWidgetsThroughWorld(world, graphId, 'missing')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('worldIndex.widgetParent', () => {
|
||||
it('returns the owning node entity for a widget', () => {
|
||||
const world = createWorld()
|
||||
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
|
||||
const widgetId = widgetEntityId(graphId, 'node-1', 'seed')
|
||||
expect(worldIndex.widgetParent(world, widgetId)).toBe(
|
||||
nodeEntityId(graphId, 'node-1')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined when no container references the widget', () => {
|
||||
const world = createWorld()
|
||||
const widgetId = widgetEntityId(graphId, 'orphan', 'seed')
|
||||
expect(worldIndex.widgetParent(world, widgetId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
76
src/world/widgetWorldBridge.ts
Normal file
76
src/world/widgetWorldBridge.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
|
||||
import { WidgetContainerComponent } from './components/WidgetContainer'
|
||||
import type { WidgetValue } from './components/WidgetValue'
|
||||
import { WidgetValueComponent } from './components/WidgetValue'
|
||||
import type { GraphId } from './entityIds'
|
||||
import { nodeEntityId, widgetEntityId } from './entityIds'
|
||||
import type { World } from './world'
|
||||
|
||||
/**
|
||||
* Slice 1 bridge: writes widget entities into the World whenever
|
||||
* `WidgetValueStore.registerWidget` runs. The `state` argument is the
|
||||
* SAME reactive object the store holds — sharing identity preserves Vue
|
||||
* tracking across both read paths.
|
||||
*/
|
||||
export function registerWidgetInWorld(
|
||||
world: World,
|
||||
graphId: GraphId,
|
||||
state: WidgetState
|
||||
): void {
|
||||
const widgetId = widgetEntityId(graphId, state.nodeId, state.name)
|
||||
// `state` IS the reactive object owned by `WidgetValueStore`; sharing the
|
||||
// reference is intentional — Vue tracking flows through both read paths
|
||||
// during the slice-1 bridge window. The wider WidgetState shape collapses
|
||||
// to `WidgetValue` at the component-key boundary.
|
||||
world.setComponent(widgetId, WidgetValueComponent, state as WidgetValue)
|
||||
|
||||
const nodeId = nodeEntityId(graphId, state.nodeId)
|
||||
const container = world.getComponent(nodeId, WidgetContainerComponent)
|
||||
if (!container) {
|
||||
world.setComponent(nodeId, WidgetContainerComponent, {
|
||||
widgetIds: [widgetId]
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!container.widgetIds.includes(widgetId)) {
|
||||
container.widgetIds.push(widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterWidgetInWorld(
|
||||
world: World,
|
||||
graphId: GraphId,
|
||||
nodeId: NodeId,
|
||||
name: string
|
||||
): void {
|
||||
const widgetId = widgetEntityId(graphId, nodeId, name)
|
||||
world.removeComponent(widgetId, WidgetValueComponent)
|
||||
|
||||
const owner = nodeEntityId(graphId, nodeId)
|
||||
const container = world.getComponent(owner, WidgetContainerComponent)
|
||||
if (!container) return
|
||||
const idx = container.widgetIds.indexOf(widgetId)
|
||||
if (idx >= 0) container.widgetIds.splice(idx, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up all widget value states attached to a node, going through the
|
||||
* World rather than the Pinia store. Used by `useUpstreamValue`.
|
||||
*/
|
||||
export function getNodeWidgetsThroughWorld(
|
||||
world: World,
|
||||
graphId: GraphId,
|
||||
nodeId: NodeId
|
||||
): WidgetState[] {
|
||||
const owner = nodeEntityId(graphId, nodeId)
|
||||
const container = world.getComponent(owner, WidgetContainerComponent)
|
||||
if (!container) return []
|
||||
const widgets: WidgetState[] = []
|
||||
for (const widgetId of container.widgetIds) {
|
||||
const value = world.getComponent(widgetId, WidgetValueComponent)
|
||||
if (value) widgets.push(value as unknown as WidgetState)
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
79
src/world/world.test.ts
Normal file
79
src/world/world.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
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 / has / remove', () => {
|
||||
const world = createWorld()
|
||||
const widgetId = widgetEntityId(graphId, 1, 'seed')
|
||||
|
||||
expect(world.hasComponent(widgetId, TestWidgetThing)).toBe(false)
|
||||
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
|
||||
|
||||
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
|
||||
expect(world.hasComponent(widgetId, TestWidgetThing)).toBe(true)
|
||||
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(42)
|
||||
|
||||
world.removeComponent(widgetId, TestWidgetThing)
|
||||
expect(world.hasComponent(widgetId, TestWidgetThing)).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves shared object identity (reactive bridging)', () => {
|
||||
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('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 a compile error, asserted via @ts-expect-error
|
||||
// @ts-expect-error WidgetEntityId is not assignable to NodeEntityId
|
||||
world.getComponent(widgetEntityId(graphId, 1, 'x'), TestNodeThing)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
81
src/world/world.ts
Normal file
81
src/world/world.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import type { ComponentKey } from './componentKey'
|
||||
import type { EntityId } from './entityIds'
|
||||
|
||||
/**
|
||||
* Minimal ECS world surface for slice 1. Exposes plain
|
||||
* `getComponent`/`setComponent`/`removeComponent` plumbing only.
|
||||
*
|
||||
* Deferred (later slices): commands, transactions, undo, scope filtering,
|
||||
* iteration helpers beyond `entitiesWith`.
|
||||
*/
|
||||
export interface World {
|
||||
getComponent<TData, TEntity extends EntityId>(
|
||||
id: TEntity,
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): TData | undefined
|
||||
hasComponent<TData, TEntity extends EntityId>(
|
||||
id: TEntity,
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): boolean
|
||||
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>
|
||||
): IterableIterator<TEntity>
|
||||
}
|
||||
|
||||
interface InternalStore {
|
||||
store: Map<string, Map<EntityId, unknown>>
|
||||
}
|
||||
|
||||
function getOrCreateMap(
|
||||
internal: InternalStore,
|
||||
keyName: string
|
||||
): Map<EntityId, unknown> {
|
||||
const existing = internal.store.get(keyName)
|
||||
if (existing) return existing
|
||||
const next = reactive(new Map<EntityId, unknown>()) as Map<EntityId, unknown>
|
||||
internal.store.set(keyName, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function createWorld(): World {
|
||||
const internal: InternalStore = { store: new Map() }
|
||||
|
||||
return {
|
||||
getComponent<TData, TEntity extends EntityId>(
|
||||
id: TEntity,
|
||||
key: ComponentKey<TData, TEntity>
|
||||
): TData | undefined {
|
||||
const map = internal.store.get(key.name)
|
||||
if (!map) return undefined
|
||||
return map.get(id) as TData | undefined
|
||||
},
|
||||
hasComponent(id, key) {
|
||||
const map = internal.store.get(key.name)
|
||||
return map ? map.has(id) : false
|
||||
},
|
||||
setComponent(id, key, data) {
|
||||
const map = getOrCreateMap(internal, key.name)
|
||||
map.set(id, data)
|
||||
},
|
||||
removeComponent(id, key) {
|
||||
const map = internal.store.get(key.name)
|
||||
if (map) map.delete(id)
|
||||
},
|
||||
*entitiesWith(key) {
|
||||
const map = internal.store.get(key.name)
|
||||
if (!map) return
|
||||
for (const id of map.keys()) yield id as never
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/world/worldIndex.ts
Normal file
21
src/world/worldIndex.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { WidgetContainerComponent } from './components/WidgetContainer'
|
||||
import type { NodeEntityId, WidgetEntityId } from './entityIds'
|
||||
import type { World } from './world'
|
||||
|
||||
/**
|
||||
* Read-side helpers that compute reverse lookups from canonical components.
|
||||
* Slice 1 implements `widgetParent` only. Optimisations (denormalised
|
||||
* caches) are deferred until profiling demands them.
|
||||
*/
|
||||
export const worldIndex = {
|
||||
widgetParent(
|
||||
world: World,
|
||||
widgetId: WidgetEntityId
|
||||
): NodeEntityId | undefined {
|
||||
for (const nodeId of world.entitiesWith(WidgetContainerComponent)) {
|
||||
const container = world.getComponent(nodeId, WidgetContainerComponent)
|
||||
if (container?.widgetIds.includes(widgetId)) return nodeId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
17
src/world/worldInstance.ts
Normal file
17
src/world/worldInstance.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { World } from './world'
|
||||
import { createWorld } from './world'
|
||||
|
||||
/**
|
||||
* Module-singleton `World` for the editor process. Tests can call
|
||||
* `resetWorldInstance()` to start clean.
|
||||
*/
|
||||
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