Skip to content

Commit 171d2b7

Browse files
committed
cleanup
1 parent 394745c commit 171d2b7

4 files changed

Lines changed: 239 additions & 37 deletions

File tree

src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerUI.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,12 @@
9797
</div>
9898
{/if}
9999

100-
{#each ctx.plans as plan, i (plan.name)}
100+
<!--
101+
Keyed by `plan.id`, not by name. Only the upload path rejects a duplicate name; neither
102+
`addPlan` nor the `plans` prop does, and Svelte throws `each_key_duplicate` in production
103+
builds as well as dev, so two plans sharing a name would take the panel down on mount.
104+
-->
105+
{#each ctx.plans as plan, i (plan.id)}
101106
{@const isActive = ctx.activePlanIndex === i}
102107
<div
103108
class={[

src/lib/plugins/MotionPlanReplayer/__tests__/__fixtures__/ReplayerHarness.svelte

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,34 @@
33
* `provideMotionPlanReplayer` reads the world and the relationship registry off Svelte context, and
44
* `setContext` only works during component init — so the provider cannot be reached from a plain
55
* `$effect.root`. This is the smallest component that stands the three of them up together.
6+
*
7+
* The world goes back to the spec alongside the context. Everything the replayer actually *does*
8+
* lands in the world rather than on the context, so without it a spec can only assert step counts
9+
* and indices, and half this module is unobservable.
610
*/
711
12+
import type { World } from 'koota'
13+
814
import { untrack } from 'svelte'
915
10-
import { provideWorld } from '$lib/ecs'
16+
import { provideWorld, useWorld } from '$lib/ecs'
1117
import { provideRelationships } from '$lib/hooks/useRelationships.svelte'
1218
1319
import type { MotionPlanReplayerContext } from '../../useMotionPlanReplayer.svelte'
1420
1521
import { provideMotionPlanReplayer } from '../../useMotionPlanReplayer.svelte'
1622
1723
interface Props {
18-
/** Handed the live context so the spec can drive it without reaching through the DOM. */
19-
onReady: (ctx: MotionPlanReplayerContext) => void
24+
/** Handed the live context and the world it draws into, once, during init. */
25+
onReady: (ctx: MotionPlanReplayerContext, world: World) => void
2026
}
2127
2228
const { onReady }: Props = $props()
2329
2430
provideWorld()
2531
provideRelationships()
26-
// Once, at init: the context is a stable object, so re-reporting it would say nothing new.
27-
untrack(() => onReady(provideMotionPlanReplayer()))
32+
// `untrack` to say the once-at-init read of `onReady` is deliberate. Without it the compiler
33+
// warns that the reference captures only the prop's initial value, which is exactly the intent:
34+
// the context and the world are both stable, so there is nothing later to report.
35+
untrack(() => onReady(provideMotionPlanReplayer(), useWorld()))
2836
</script>
Lines changed: 218 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,94 @@
1-
import '@testing-library/jest-dom/vitest'
21
import { render } from '@testing-library/svelte'
2+
import { type Entity, type World } from 'koota'
3+
import { UuidTool } from 'uuid-tool'
34
import { describe, expect, it } from 'vitest'
45

5-
import type { Snapshot } from '$lib/buf/draw/v1/snapshot_pb'
6+
import { PoseInFrame, Transform } from '$lib/buf/common/v1/common_pb'
7+
import { Snapshot } from '$lib/buf/draw/v1/snapshot_pb'
8+
import { traits } from '$lib/ecs'
69

710
import type { MotionPlanReplayerContext } from '../useMotionPlanReplayer.svelte'
811

9-
import { parsePlan } from '../parse-plan'
10-
import { parsedPlanToSnapshots } from '../plan-to-snapshots'
1112
import gantryPlan from './__fixtures__/gantry-plan.json?raw'
1213
import ReplayerHarness from './__fixtures__/ReplayerHarness.svelte'
1314

14-
const mount = (): MotionPlanReplayerContext => {
15-
let ctx!: MotionPlanReplayerContext
16-
render(ReplayerHarness, { onReady: (c: MotionPlanReplayerContext) => (ctx = c) })
17-
return ctx
15+
interface Mounted {
16+
ctx: MotionPlanReplayerContext
17+
world: World
18+
}
19+
20+
const mount = (): Mounted => {
21+
let mounted: Mounted | undefined
22+
render(ReplayerHarness, {
23+
onReady: (ctx: MotionPlanReplayerContext, world: World) => (mounted = { ctx, world }),
24+
})
25+
if (!mounted) throw new Error('ReplayerHarness never called onReady')
26+
return mounted
1827
}
1928

2029
/**
21-
* Real snapshots, cycled to whatever length the test needs: what matters below is how many steps a
22-
* plan has relative to its neighbours, and reconcile keys on `Transform.uuid` so repeats are simply
23-
* re-applied.
30+
* Snapshots that say which plan they came from.
31+
*
32+
* This matters more than it looks. Cycling one fixture's snapshots across every plan makes each
33+
* plan's geometry byte-identical, so a test can only ever notice that it read an array of the wrong
34+
* *length*. Reading the wrong plan's array of the same length, which is the actual bug this module
35+
* had, draws a completely different robot and would go unnoticed. Naming the frame per plan and per
36+
* step is what lets the assertions below be about identity rather than about arithmetic.
2437
*/
25-
const stepsOfLength = (length: number): Snapshot[] => {
26-
const base = parsedPlanToSnapshots(parsePlan(gantryPlan))
27-
return Array.from({ length }, (_, i) => base[i % base.length]!)
28-
}
38+
const planSnapshots = (plan: string, steps: number): Snapshot[] =>
39+
Array.from(
40+
{ length: steps },
41+
(_, step) =>
42+
new Snapshot({
43+
transforms: [
44+
new Transform({
45+
referenceFrame: `${plan}-frame`,
46+
poseInObserverFrame: new PoseInFrame({ referenceFrame: 'world' }),
47+
// Stable across steps and distinct across plans, matching a real plan: reconcile keys
48+
// on this, so repeating it is what makes a scrub update rather than respawn.
49+
uuid: Uint8Array.from(
50+
UuidTool.toBytes(`${plan}-0000-4000-8000-00000000000${step % 10}`)
51+
),
52+
}),
53+
],
54+
})
55+
)
2956

3057
const addPlans = (ctx: MotionPlanReplayerContext, lengths: number[]) => {
3158
for (const [i, length] of lengths.entries()) {
32-
ctx.addPlan(`plan-${i}`, `content-${i}`, stepsOfLength(length))
59+
ctx.addPlan(`plan-${i}`, `content-${i}`, planSnapshots(`plan-${i}`, length))
3360
}
3461
}
3562

63+
/**
64+
* Which plan's geometry is actually in the world right now. The `-frame` suffix separates the
65+
* drawn transforms from the plan's own root entity, which carries the plan's name.
66+
*/
67+
const drawnFrames = (world: World): string[] =>
68+
world
69+
.query(traits.Name)
70+
.map((entity: Entity) => entity.get(traits.Name))
71+
.filter((name): name is string => typeof name === 'string' && name.endsWith('-frame'))
72+
.toSorted()
73+
74+
/** The entity drawn for a transform, as opposed to the plan root that shares the `Name` trait. */
75+
const drawnEntity = (world: World): Entity =>
76+
world.query(traits.Name).find((entity) => entity.get(traits.Name)?.endsWith('-frame'))!
77+
3678
describe('removing a plan', () => {
3779
/**
3880
* Snapshots used to be keyed by the plan's position in `plans`, which `removePlan` reindexes. The
39-
* active plan then read whichever array had inherited its old slot: shorter, and the step the
40-
* player clamped to (against the *plan's* step count) ran off the end of it, so reconcile was
41-
* handed `undefined` and threw out of whatever input handler got there.
81+
* active plan then read whichever array had inherited its old slot, so it drew a different plan's
82+
* geometry while its own step count still came from `plans[i].stepCount`.
83+
*
84+
* Nothing threw. `setStep` clamped against the array it had just fetched, so the read was always
85+
* in range. What the user got was worse than a wrong drawing: with `currentStep` pinned to the
86+
* short array's last index and `lastStepIdx` still derived from the plan's own count, `atEnd` was
87+
* never true, so the scrubber's play loop re-reconciled one frame at 10 Hz forever with the
88+
* counter stuck partway and every forward control still enabled.
4289
*/
4390
it('leaves the active plan reading its own snapshots, not its neighbour’s', () => {
44-
const ctx = mount()
91+
const { ctx, world } = mount()
4592
addPlans(ctx, [2, 2, 6])
4693

4794
expect(ctx.activePlanIndex).toBe(2)
@@ -51,37 +98,65 @@ describe('removing a plan', () => {
5198

5299
expect(ctx.activePlanIndex).toBe(1)
53100
expect(ctx.totalSteps).toBe(6)
101+
// The identity assertion, not just the arithmetic one: plan-2 is on screen, not plan-1.
102+
expect(drawnFrames(world)).toEqual(['plan-2-frame'])
54103

55104
ctx.setStep(5)
56105
expect(ctx.currentStep).toBe(5)
106+
expect(drawnFrames(world)).toEqual(['plan-2-frame'])
57107
})
58108

59109
it('lets a plan that shifted down still be reselected', () => {
60-
const ctx = mount()
110+
const { ctx, world } = mount()
61111
addPlans(ctx, [2, 6, 3])
62112

63113
ctx.removePlan(0)
64114
ctx.selectPlan(0)
65115

66116
expect(ctx.plans[0]!.name).toBe('plan-1')
67117
expect(ctx.totalSteps).toBe(6)
118+
expect(drawnFrames(world)).toEqual(['plan-1-frame'])
68119
ctx.setStep(5)
69120
expect(ctx.currentStep).toBe(5)
70121
})
71122

123+
/**
124+
* The same bug in its destructive form, and the one the fix's own call site could still have had:
125+
* `addPlan` computes `index = plans.length`, so after a removal that index belongs to a plan that
126+
* is still loaded. Keyed by position, the new plan's snapshots overwrite the survivor's outright
127+
* rather than merely being read in its place.
128+
*/
129+
it('does not overwrite a surviving plan when a new one is added after a removal', () => {
130+
const { ctx, world } = mount()
131+
addPlans(ctx, [2, 3, 7])
132+
133+
ctx.removePlan(0)
134+
ctx.addPlan('plan-3', 'content-3', planSnapshots('plan-3', 4))
135+
136+
ctx.selectPlan(1)
137+
138+
expect(ctx.plans[1]!.name).toBe('plan-2')
139+
expect(ctx.totalSteps).toBe(7)
140+
expect(drawnFrames(world)).toEqual(['plan-2-frame'])
141+
ctx.setStep(6)
142+
expect(ctx.currentStep).toBe(6)
143+
})
144+
72145
it('clears the scene when the removed plan is the active one', () => {
73-
const ctx = mount()
146+
const { ctx, world } = mount()
74147
addPlans(ctx, [2, 4])
75148

76149
ctx.removePlan(1)
77150

78151
expect(ctx.activePlanIndex).toBeNull()
79152
expect(ctx.totalSteps).toBe(0)
80153
expect(ctx.plans.map((p) => p.name)).toEqual(['plan-0'])
154+
// The removed plan's geometry goes with it rather than being left in the world.
155+
expect(drawnFrames(world)).toEqual([])
81156
})
82157

83158
it('holds the index still when the removed plan sits after the active one', () => {
84-
const ctx = mount()
159+
const { ctx } = mount()
85160
addPlans(ctx, [2, 4])
86161
ctx.selectPlan(0)
87162

@@ -91,13 +166,127 @@ describe('removing a plan', () => {
91166
expect(ctx.totalSteps).toBe(2)
92167
})
93168

94-
it('ignores an index that names no plan', () => {
95-
const ctx = mount()
96-
addPlans(ctx, [2])
169+
/**
170+
* An index naming no plan now returns before anything else happens. That is not only tidiness:
171+
* the id lookup needs the entry, and the shift below used to run unconditionally, so a negative
172+
* or fractional index moved the active plan onto its neighbour without removing anything. Those
173+
* are only reachable from outside, `removePlan` being public API through `./plugins`, but they
174+
* are the same class of bug as the one this fixes.
175+
*/
176+
// The active plan is the last one so that a negative or fractional index would satisfy the
177+
// `activePlanIndex > index` shift. Held at index 1 instead, both compare false and the case
178+
// would pass whether or not the guard exists.
179+
it.each([
180+
['out of range', 7],
181+
['negative', -1],
182+
['fractional', 1.5],
183+
])('ignores a(n) %s index', (_label, index) => {
184+
const { ctx } = mount()
185+
addPlans(ctx, [2, 4, 6])
186+
expect(ctx.activePlanIndex).toBe(2)
97187

98-
ctx.removePlan(7)
188+
ctx.removePlan(index)
99189

100-
expect(ctx.plans).toHaveLength(1)
101-
expect(ctx.activePlanIndex).toBe(0)
190+
expect(ctx.plans.map((p) => p.name)).toEqual(['plan-0', 'plan-1', 'plan-2'])
191+
expect(ctx.activePlanIndex).toBe(2)
192+
expect(ctx.totalSteps).toBe(6)
193+
})
194+
})
195+
196+
describe('plan identity', () => {
197+
/**
198+
* Ids, not names, are what the store and the panel's `{#each}` key on. Names are only deduplicated
199+
* on the upload path, so `addPlan` and the `plans` prop can both produce a collision, and a
200+
* duplicate `{#each}` key throws in production builds as well as in dev.
201+
*/
202+
it('gives two plans with the same name distinct ids', () => {
203+
const { ctx } = mount()
204+
ctx.addPlan('same.json', 'content-a', planSnapshots('plan-a', 2))
205+
ctx.addPlan('same.json', 'content-b', planSnapshots('plan-b', 5))
206+
207+
const [first, second] = ctx.plans
208+
expect(first!.id).not.toBe(second!.id)
209+
210+
// And they keep their own snapshots, which is the whole point of the id.
211+
ctx.selectPlan(0)
212+
expect(ctx.totalSteps).toBe(2)
213+
ctx.selectPlan(1)
214+
expect(ctx.totalSteps).toBe(5)
215+
})
216+
217+
/**
218+
* Every other test hands `addPlan` precomputed snapshots, which is the hosted path. Without a
219+
* `resolvePlanSnapshots` the plan is parsed here instead, and that branch is the one that writes
220+
* the store itself and rewrites `plans[index]` through a spread. The spread has to carry the id
221+
* across, or the plan is left pointing at snapshots it can no longer find.
222+
*/
223+
it('keys a plan it parsed itself the same way, without landing on a live plan', () => {
224+
const { ctx, world } = mount()
225+
// A removal first, so the parsed plan's position and its id genuinely differ. Added straight
226+
// into an untouched list the two coincide, and keying by either one would pass.
227+
addPlans(ctx, [2, 3])
228+
ctx.removePlan(0)
229+
ctx.addPlan('gantry.json', gantryPlan)
230+
231+
expect(ctx.plans.map((p) => p.name)).toEqual(['plan-1', 'gantry.json'])
232+
expect(ctx.plans[1]!.status).toBe('ready')
233+
expect(ctx.totalSteps).toBe(2)
234+
235+
// plan-1 now sits at the position the parsed plan was written from. Its snapshots have to be
236+
// untouched, which is the same corruption as the one above, on the branch that parses.
237+
ctx.selectPlan(0)
238+
239+
expect(ctx.totalSteps).toBe(3)
240+
expect(drawnFrames(world)).toEqual(['plan-1-frame'])
241+
})
242+
})
243+
244+
describe('scrubbing', () => {
245+
it.each([
246+
['below the first step', -3, 0],
247+
['past the last step', 99, 5],
248+
])('clamps a seek %s', (_label, requested, expected) => {
249+
const { ctx } = mount()
250+
addPlans(ctx, [6])
251+
252+
ctx.setStep(requested)
253+
254+
expect(ctx.currentStep).toBe(expected)
255+
})
256+
257+
it('rewinds when the active plan is cleared', () => {
258+
const { ctx, world } = mount()
259+
addPlans(ctx, [6])
260+
ctx.setStep(3)
261+
262+
ctx.clearActivePlan()
263+
264+
expect(ctx.currentStep).toBe(0)
265+
expect(ctx.activePlanIndex).toBeNull()
266+
expect(drawnFrames(world)).toEqual([])
267+
})
268+
269+
/**
270+
* Reconcile runs `updateMetadata` on every step, which resets `Opacity` to its default and drops
271+
* `Invisible` / `ShowAxesHelper`. Without the capture-and-restore around it, scrubbing wipes
272+
* whatever the user set from the Details panel or the tree, one frame at a time. That is a
273+
* regression this module has already had once, and nothing was holding it.
274+
*/
275+
it('keeps display edits made while scrubbing', () => {
276+
const { ctx, world } = mount()
277+
addPlans(ctx, [4])
278+
279+
const entity = drawnEntity(world)
280+
entity.set(traits.Opacity, 0.25)
281+
entity.add(traits.Invisible)
282+
entity.add(traits.ShowAxesHelper)
283+
284+
ctx.setStep(1)
285+
ctx.setStep(2)
286+
287+
expect(entity.isAlive()).toBe(true)
288+
expect(entity.get(traits.Opacity)).toBeCloseTo(0.25)
289+
expect(entity.has(traits.Invisible)).toBe(true)
290+
expect(entity.has(traits.ShowAxesHelper)).toBe(true)
102291
})
103292
})

src/lib/plugins/MotionPlanReplayer/useMotionPlanReplayer.svelte.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import * as planRelations from './relations'
1515
const PLAN_COLOR = { r: 0, g: 0.47, b: 1 }
1616
const PLAN_OPACITY = 0.6
1717

18-
// koota's `set` writes the trait's store slot but will not add an absent trait — the entity's
19-
// mask is untouched, so `has` stays false and nothing querying the trait ever sees the value.
18+
// koota's `set` on a trait the entity does not have throws, because it reaches through a store
19+
// slot that was never allocated: `TypeError: Cannot read properties of undefined (reading 'store')`.
2020
// Plan transforms carry no color metadata, so `Color` is always absent on spawn; `Opacity` only
2121
// happens to be present because `drawTransform` adds it unconditionally. Guard both rather than
2222
// depend on that.

0 commit comments

Comments
 (0)