@fugood/bricks-ctor 2.25.0-beta.49 → 2.25.0-beta.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -171,6 +171,40 @@ describe('compile animations', () => {
171
171
  })
172
172
  })
173
173
 
174
+ describe('compile event handlers', () => {
175
+ const BRICK_ID = 'BRICK_00000000-0000-0000-0000-000000000002'
176
+
177
+ test('preserves item-brick string handler casing and normalizes only system', async () => {
178
+ // A mixed-case ItemBrickID handler — must survive compile verbatim because the
179
+ // runtime resolves handlers case-sensitively (mapEventMapHandlersWithNewId).
180
+ const itemHandlerId = 'itemBrickHandlerId'
181
+ const app = makeApp()
182
+ app.rootSubspace.bricks = [
183
+ {
184
+ __typename: 'Brick',
185
+ id: BRICK_ID,
186
+ templateKey: 'BRICK_VIEW',
187
+ property: {},
188
+ events: {
189
+ onPress: [
190
+ { handler: itemHandlerId, action: { __actionName: 'SCROLL_TO_INDEX' } },
191
+ { handler: 'system', action: { __actionName: 'NAVIGATE' } },
192
+ ],
193
+ },
194
+ },
195
+ ]
196
+
197
+ const config = await compile(app)
198
+ const eventMap = config.subspace_map[SUBSPACE_ID].brick_map[BRICK_ID].event_map
199
+ const events = eventMap.BRICK_VIEW_ON_PRESS
200
+
201
+ // ItemBrickID handler kept verbatim (was wrongly uppercased to ITEMBRICKHANDLERID).
202
+ expect(events[0].handler).toBe(itemHandlerId)
203
+ // The literal 'system' handler still normalizes to SYSTEM.
204
+ expect(events[1].handler).toBe('SYSTEM')
205
+ })
206
+ })
207
+
174
208
  describe('compile data remote update', () => {
175
209
  test.each([
176
210
  [undefined, { bank_type: 'none' }],
@@ -200,6 +234,39 @@ describe('compile data remote update', () => {
200
234
  })
201
235
  })
202
236
 
237
+ describe('compile asset preload normalization', () => {
238
+ const makeAssetData = (id, kindType) => ({
239
+ __typename: 'Data',
240
+ id,
241
+ type: 'string',
242
+ kind: {
243
+ type: kindType,
244
+ preload: { type: 'url', hashType: 'sha256', hash: 'abc123' },
245
+ },
246
+ })
247
+
248
+ test('rive-file-uri normalizes preload like lottie-file-uri (hash re-keyed by hashType)', async () => {
249
+ const RIVE_ID = 'PROPERTY_BANK_DATA_NODE_00000000-0000-0000-0000-0000000000a1'
250
+ const LOTTIE_ID = 'PROPERTY_BANK_DATA_NODE_00000000-0000-0000-0000-0000000000a2'
251
+ const config = await compile(
252
+ makeApp(
253
+ [],
254
+ [makeAssetData(RIVE_ID, 'rive-file-uri'), makeAssetData(LOTTIE_ID, 'lottie-file-uri')],
255
+ ),
256
+ )
257
+
258
+ const rive = config.subspace_map[SUBSPACE_ID].property_bank_map[RIVE_ID]
259
+ const lottie = config.subspace_map[SUBSPACE_ID].property_bank_map[LOTTIE_ID]
260
+
261
+ // Rive must get the same normalized preload as every sibling asset kind: the hash
262
+ // re-keyed under its hashType (the runtime reads preload[hashType]), not left raw under
263
+ // `hash`. Regression: rive was missing from preloadTypes, so it fell to the raw else
264
+ // branch and the runtime preload[hashType] lookup missed.
265
+ expect(rive.preload).toEqual({ type: 'url', hashType: 'sha256', sha256: 'abc123' })
266
+ expect(rive.preload).toEqual(lottie.preload)
267
+ })
268
+ })
269
+
203
270
  describe('compile config-change audit log', () => {
204
271
  const readAudit = async (projectDir) =>
205
272
  (await readFile(path.join(projectDir, '.bricks/edits.jsonl'), 'utf8'))
@@ -263,3 +330,36 @@ describe('compile config-change audit log', () => {
263
330
  }
264
331
  })
265
332
  })
333
+
334
+ describe('compile linked-module subspace', () => {
335
+ const LINKED_SUBSPACE_ID = 'SUBSPACE_00000000-0000-0000-0000-000000000002'
336
+ const makeLinkedApp = () => {
337
+ const app = makeApp()
338
+ app.subspaces = [
339
+ app.rootSubspace,
340
+ {
341
+ __typename: 'Subspace',
342
+ id: LINKED_SUBSPACE_ID,
343
+ title: 'Linked Module',
344
+ layout: { width: 96, height: 54 },
345
+ module: { link: true, id: 'MODULE_00000000-0000-0000-0000-000000000001', version: 1 },
346
+ },
347
+ ]
348
+ return app
349
+ }
350
+
351
+ test('uses a deterministic placeholder canvas id stable across recompiles', async () => {
352
+ // Regression: the placeholder canvas id came from makeId('canvas'), whose count-fallback
353
+ // branch uses a never-reset process-global counter, so recompiling identical source produced
354
+ // a different id and broke compile's byte-stable-output contract (phantom config-change ops).
355
+ const first = await compile(makeLinkedApp())
356
+ const second = await compile(makeLinkedApp())
357
+
358
+ const firstKeys = Object.keys(first.subspace_map[LINKED_SUBSPACE_ID].canvas_map)
359
+ const secondKeys = Object.keys(second.subspace_map[LINKED_SUBSPACE_ID].canvas_map)
360
+ expect(firstKeys).toHaveLength(1)
361
+ expect(secondKeys).toEqual(firstKeys)
362
+ expect(first.subspace_map[LINKED_SUBSPACE_ID].root_canvas_id).toBe(firstKeys[0])
363
+ expect(second.subspace_map[LINKED_SUBSPACE_ID].root_canvas_id).toBe(firstKeys[0])
364
+ })
365
+ })
package/compile/index.ts CHANGED
@@ -6,7 +6,7 @@ import omit from 'lodash/omit'
6
6
  import { parse as parseAST } from 'acorn'
7
7
  import type { ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
8
8
  import escodegen from 'escodegen'
9
- import { makeId } from '../utils/id'
9
+ import { makeSeededId } from '../utils/id'
10
10
  import { generateCalulationMap } from './util'
11
11
  import { templateActionNameMap } from './action-name-map'
12
12
  import { templateEventPropsMap } from '../utils/event-props'
@@ -185,10 +185,17 @@ const compileEvents = (
185
185
 
186
186
  let handlerKey
187
187
  let handlerTemplateKey
188
- if (handler === 'system' || typeof handler === 'string') {
189
- if (handler.startsWith('SUBSPACE_')) handlerKey = handler
190
- else handlerKey = handler.toUpperCase()
191
- if (handlerKey === 'SYSTEM') handlerTemplateKey = 'SYSTEM'
188
+ if (typeof handler === 'string') {
189
+ // Only the literal 'system' handler is normalized to the SYSTEM template key.
190
+ // SubspaceID (SUBSPACE_*) and ItemBrickID handlers are kept verbatim: the runtime
191
+ // resolves them case-sensitively (see mapEventMapHandlersWithNewId), so uppercasing
192
+ // a mixed-case ItemBrickID would break handler-to-item event wiring.
193
+ if (handler === 'system') {
194
+ handlerKey = 'SYSTEM'
195
+ handlerTemplateKey = 'SYSTEM'
196
+ } else {
197
+ handlerKey = handler
198
+ }
192
199
  } else if (typeof handler === 'function') {
193
200
  let instance = handler()
194
201
  if (instance?.id) {
@@ -486,6 +493,7 @@ const preloadTypes = [
486
493
  'media-resource-audio',
487
494
  'media-resource-file',
488
495
  'lottie-file-uri',
496
+ 'rive-file-uri',
489
497
  'ggml-model-asset',
490
498
  'gguf-model-asset',
491
499
  'binary-asset',
@@ -755,7 +763,14 @@ export const compile = async (app: Application) => {
755
763
  // validation (root_canvas_id is required before the conditional
756
764
  // schema fix is published).
757
765
  if (subspace.module?.link) {
758
- const placeholderCanvasId = makeId('canvas')
766
+ // Seed the placeholder id from the (stable) subspace id. `makeId('canvas')` would take
767
+ // the count-fallback branch (a process-global counter that is never reset), so the
768
+ // placeholder id depended on how many prior count-fallback ids had been minted — making
769
+ // it differ between recompiles and breaking compile's byte-stable-output contract
770
+ // (phantom config-change ops). `makeSeededId` keeps no global state, so identical source
771
+ // recompiles to an identical id. (`makeId('canvas', alias)` would instead throw
772
+ // "Duplicate makeId alias" on the second compile in a long-lived process.)
773
+ const placeholderCanvasId = makeSeededId('canvas', `${subspaceId}:module-placeholder`)
759
774
  subspaceMap[subspaceId] = {
760
775
  title: subspace.title,
761
776
  description: subspace.description,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.49",
3
+ "version": "2.25.0-beta.50",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
@@ -11,7 +11,7 @@
11
11
  "@babel/parser": "7.28.5",
12
12
  "@babel/traverse": "7.28.5",
13
13
  "@babel/types": "7.28.5",
14
- "@fugood/bricks-cli": "^2.25.0-beta.49",
14
+ "@fugood/bricks-cli": "^2.25.0-beta.50",
15
15
  "@huggingface/gguf": "^0.3.2",
16
16
  "@iarna/toml": "^3.0.0",
17
17
  "@modelcontextprotocol/sdk": "^1.15.0",
@@ -29,5 +29,5 @@
29
29
  "peerDependencies": {
30
30
  "oxfmt": "^0.36.0"
31
31
  },
32
- "gitHead": "e7da261fd97feda0ee059ff04070f0068cad9d29"
32
+ "gitHead": "3614445cd0166e40634f69c6238299bb63f4f597"
33
33
  }
@@ -229,6 +229,36 @@ describe('ctor MCP entry-editing tools', () => {
229
229
  expect(gitignore).toContain('.bricks/edits.jsonl')
230
230
  })
231
231
 
232
+ test('edit_entry removes the correct elements when unsetting multiple array indices', async () => {
233
+ // Regression: unset paths were applied in input order, but unsetPathValue
234
+ // splices arrays in place — removing items[1] shifts items[2] down, so the
235
+ // second unset hit the wrong element (or no-op'd). Both requested elements
236
+ // must be removed regardless of order.
237
+ projectDir = await writeFixtureProject()
238
+ const target = { file: 'subspaces/subspace-0/bricks.ts', entry: 'bButton', verify: false }
239
+
240
+ const built = await __test__.editEntry(projectDir, {
241
+ ...target,
242
+ set: {
243
+ 'property.list[0]': 'ITEM_A',
244
+ 'property.list[1]': 'ITEM_B',
245
+ 'property.list[2]': 'ITEM_C',
246
+ },
247
+ })
248
+ expect(built.outcome).toBe('ok')
249
+
250
+ const unset = await __test__.editEntry(projectDir, {
251
+ ...target,
252
+ unset: ['property.list[1]', 'property.list[2]'],
253
+ })
254
+ expect(unset.outcome).toBe('ok')
255
+
256
+ const source = await readProjectFile(projectDir, 'subspaces/subspace-0/bricks.ts')
257
+ expect(source).toContain('ITEM_A')
258
+ expect(source).not.toContain('ITEM_B') // items[1] removed
259
+ expect(source).not.toContain('ITEM_C') // items[2] removed (survived before the fix)
260
+ })
261
+
232
262
  test('edit_events adds a typed system event action', async () => {
233
263
  projectDir = await writeFixtureProject()
234
264
 
@@ -924,7 +924,20 @@ const editEntry = async (projectDir: string, input: any) =>
924
924
  for (const [pathValue, value] of Object.entries(input.set || {})) {
925
925
  await setPathValue(targetObject, pathValue, value, ctx)
926
926
  }
927
- for (const pathValue of input.unset || []) {
927
+ // Apply unset paths highest array-index first: unsetPathValue splices arrays
928
+ // in place, so removing a lower index would shift the higher indices down and
929
+ // a later unset would then hit the wrong element (or no-op). Sorting descending
930
+ // by the numeric indices in each path keeps same-array removals correct;
931
+ // object-key and distinct-array removals are order-independent.
932
+ const orderedUnset = [...(input.unset || [])].sort((a: string, b: string) => {
933
+ const ka = parsePath(a).map((token) => ('index' in token ? token.index : -1))
934
+ const kb = parsePath(b).map((token) => ('index' in token ? token.index : -1))
935
+ for (let i = 0; i < Math.min(ka.length, kb.length); i += 1) {
936
+ if (ka[i] !== kb[i]) return kb[i] - ka[i]
937
+ }
938
+ return kb.length - ka.length
939
+ })
940
+ for (const pathValue of orderedUnset) {
928
941
  unsetPathValue(targetObject, pathValue)
929
942
  }
930
943