1- import '@testing-library/jest-dom/vitest'
21import { render } from '@testing-library/svelte'
2+ import { type Entity , type World } from 'koota'
3+ import { UuidTool } from 'uuid-tool'
34import { 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
710import type { MotionPlanReplayerContext } from '../useMotionPlanReplayer.svelte'
811
9- import { parsePlan } from '../parse-plan'
10- import { parsedPlanToSnapshots } from '../plan-to-snapshots'
1112import gantryPlan from './__fixtures__/gantry-plan.json?raw'
1213import 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
3057const 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+
3678describe ( '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} )
0 commit comments