Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/core/src/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,35 @@ describe('nodeRegistry', () => {
registerNode(b)
expect(nodeRegistry.schemas()).toEqual([a.schema, b.schema])
})

test('_snapshot() restores definitions and plugin bookkeeping', async () => {
const kept = makeDefinition('kept')
registerNode(kept)
await loadPlugin({
id: 'test:kept-plugin',
apiVersion: 1,
nodes: [makeDefinition('kept-plugin-kind')],
} as Plugin)

const restore = nodeRegistry._snapshot()

// Mutate every kind of registry state a test can leak: a throwaway
// definition, a full reset, and a plugin load with its kind bookkeeping.
registerNode(makeDefinition('leaked'))
nodeRegistry._reset()
await loadPlugin({
id: 'test:leaked-plugin',
apiVersion: 1,
nodes: [makeDefinition('leaked-plugin-kind')],
} as Plugin)

restore()

expect(Array.from(nodeRegistry.entries(), ([k]) => k)).toEqual(['kept', 'kept-plugin-kind'])
expect(nodeRegistry.get('kept')).toBe(kept)
expect(getNodePluginId('kept-plugin-kind')).toBe('test:kept-plugin')
expect(getNodePluginId('leaked-plugin-kind')).toBeUndefined()
})
})

describe('isPresettable', () => {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,37 @@ class NodeRegistryImpl implements NodeRegistry {
inspectorExtensionsByKind.clear()
notifyRegistryChanged()
}

// Test-only — captures the registry (definitions + plugin bookkeeping) and
// returns a restore function. The registry is a module singleton and bun
// runs a package's test files sequentially in ONE process, so a test that
// registers a throwaway kind (or `_reset()`s) without restoring leaks that
// state into every later test FILE — and file order varies by platform
// (macOS vs CI Linux), which turns the leak into an order-dependent flake.
// Wrap registry mutations in `const restore = nodeRegistry._snapshot()`
// + `restore()` in `afterEach`/`finally`.
_snapshot(): () => void {
const defs = new Map(this.defs)
const pluginIds = new Map(pluginIdsByKind)
const extensions = new Map(
Array.from(inspectorExtensionsByKind, ([kind, list]) => [kind, [...list]] as const),
)
return () => {
this.defs.clear()
for (const [kind, def] of defs) this.defs.set(kind, def)
pluginIdsByKind.clear()
for (const [kind, id] of pluginIds) pluginIdsByKind.set(kind, id)
inspectorExtensionsByKind.clear()
for (const [kind, list] of extensions) inspectorExtensionsByKind.set(kind, [...list])
notifyRegistryChanged()
}
}
}

export const nodeRegistry: NodeRegistry & {
_register: (def: AnyNodeDefinition) => void
_reset: () => void
_snapshot: () => () => void
} = new NodeRegistryImpl()

export function registerNode(def: AnyNodeDefinition): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import type {
AnyNode,
AnyNodeId,
Expand Down Expand Up @@ -447,12 +447,20 @@ describe('floorplan annotation overlay routing', () => {
})

describe('computeAffectedSiblingIds', () => {
// The cabinet fixture definitions have no `capabilities` — leaking them
// past this describe crashes any later test FILE that enumerates the
// registry (night-8 CI: pointer-support-cap.test.ts, run 32580694134).
let restoreRegistry: () => void

beforeEach(() => {
restoreRegistry = nodeRegistry._snapshot()
nodeRegistry._reset()
registerCabinetFloorplanDefinition('cabinet')
registerCabinetFloorplanDefinition('cabinet-module')
})

afterEach(() => restoreRegistry())

test('propagates cabinet live overrides through the cabinet family', () => {
const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner'])
const module = cabinetModule('cabinet-module_main', run.id)
Expand Down Expand Up @@ -545,6 +553,16 @@ describe('collectFloorplanDependencyNodes', () => {
})

describe('collectFloorplanLinkedLevelNodes', () => {
// Same containment as computeAffectedSiblingIds above: the fixture
// definition has no `capabilities`, so it must not outlive this describe.
let restoreRegistry: () => void

beforeEach(() => {
restoreRegistry = nodeRegistry._snapshot()
})

afterEach(() => restoreRegistry())

test('projects a node onto a linked destination level with its real children', () => {
nodeRegistry._reset()
registerNode({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,39 @@ describe('resolvePointerSupportSurface node tops', () => {
wallSupport.baseSegments.every((segment) => Math.abs(segment.elevation - 2) < 1e-6),
).toBe(true)
})

test('tolerates a registered definition without capabilities', () => {
// Gates the pollution class behind the night-8 CI flake (run
// 32580694134): `capabilities` is typed required, but a minimal plugin
// definition (or a leaked test fixture) can ship without it at runtime.
// The resolver enumerates every registered kind, so one capabilities-less
// entry must read as "no top surface" — not crash the election.
const restoreRegistry = nodeRegistry._snapshot()
try {
registerNode({
kind: 'plugin-minimal-capless',
schemaVersion: 1,
schema: z.object({}),
category: 'structure',
defaults: () => ({}),
// No `capabilities` — deliberately.
} as unknown as AnyNodeDefinition)
addPluginPlatform()

const camera = new PerspectiveCamera()
camera.position.set(0, 5, 0)
camera.updateMatrixWorld(true)

const support = resolvePointerSupportSurface(camera, [0, 0, 0], {
includeNodeTopSurfaces: true,
})

// No throw, and the election still works: the capabilities-less kind is
// skipped while the platform's declared top is found as usual.
expect(support?.sourceNodeId).toBe(PLATFORM_ID)
expect(support?.elevation).toBeCloseTo(2)
} finally {
restoreRegistry()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,14 @@ export function resolvePointerSupportSurface(
// lifts anything placed inside a finished room. Only the tools that build ON
// a surface (wall / column / fence / stair / block) mean that, and they say
// so. Everything else places against the floor the pointer indicates.
//
// `capabilities` is typed required on NodeDefinition, but this enumerates
// EVERY registered kind — including plugin bundles that bypass the type at
// runtime. A minimal definition without `capabilities` must read as "no top
// surface", not crash the resolver (night-8 CI, run 32580694134).
const nodeTopSurfaceKinds = options?.includeNodeTopSurfaces
? Array.from(nodeRegistry.entries())
.filter(([, definition]) => definition.capabilities.surfaces?.top !== undefined)
.filter(([, definition]) => definition.capabilities?.surfaces?.top !== undefined)
.map(([kind]) => kind)
: []
if (nodeTopSurfaceKinds.some((kind) => (sceneRegistry.byType[kind]?.size ?? 0) > 0)) {
Expand Down
15 changes: 14 additions & 1 deletion packages/editor/src/components/tools/wall/wall-drafting.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
Expand Down Expand Up @@ -143,6 +143,15 @@ function levelWalls(): WallNode[] {
}

describe('createWallOnCurrentLevel', () => {
// Set by tests that mutate the process-wide node registry; restored here
// so the mutation can't leak into later test files (order-dependent flakes).
let restoreRegistry: (() => void) | undefined

afterEach(() => {
restoreRegistry?.()
restoreRegistry = undefined
})

beforeEach(() => {
useViewer.setState({
selection: {
Expand Down Expand Up @@ -314,6 +323,10 @@ describe('createWallOnCurrentLevel', () => {
})

test('pins an existing construction source before a generated room slab can lift it', () => {
// The reset + throwaway `block` registration is scoped to this test —
// the registry is a process-wide singleton, so leaking it would leave
// later test FILES with a stripped registry (order-dependent flakes).
restoreRegistry = nodeRegistry._snapshot()
nodeRegistry._reset()
spatialGridManager.clear()
registerNode({
Expand Down
17 changes: 16 additions & 1 deletion packages/editor/src/lib/floorplan/apply-alignment.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useAlignmentGuides from '../../store/use-alignment-guides'
import { applyFloorplanAlignment } from './apply-alignment'

describe('applyFloorplanAlignment', () => {
beforeEach(() => {
// These tests assume no active building (alignment runs on world axes).
// The scene/viewer stores are process-wide singletons, so an earlier test
// FILE can leak a selected building fixture into them — under bun's
// platform-dependent file order that turned into an order-dependent
// failure (getActiveBuildingPose reading a fixture building without a
// rotation array). Pin the empty-scene context explicitly.
useScene.setState({ nodes: {} } as never)
useViewer.setState({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
} as never)
})

afterEach(() => {
useAlignmentGuides.getState().clear()
})
Expand Down
Loading