@things-factory/figure-ui 10.1.15 → 10.1.17

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.
Files changed (78) hide show
  1. package/client/modeller/figure-animations.ts +12 -12
  2. package/client/modeller/figure-canvas.ts +32 -48
  3. package/client/modeller/figure-capabilities.ts +10 -10
  4. package/client/modeller/figure-inspector.ts +39 -28
  5. package/client/modeller/figure-parts.ts +1 -1
  6. package/client/modeller/figure-preview.ts +122 -25
  7. package/client/modeller/figure-report.ts +24 -6
  8. package/client/modeller/figure-settings.ts +45 -2
  9. package/client/modeller/figure-side.ts +6 -6
  10. package/client/modeller/figure-source.ts +84 -38
  11. package/client/modeller/figure-thumbnail.ts +5 -5
  12. package/client/modeller/figure-view.ts +53 -0
  13. package/client/modeller/part-edits.ts +36 -36
  14. package/client/modeller/proposal.ts +25 -4
  15. package/client/pages/figure-modeller-page.ts +142 -98
  16. package/dist-client/modeller/figure-animations.d.ts +4 -4
  17. package/dist-client/modeller/figure-animations.js +10 -10
  18. package/dist-client/modeller/figure-animations.js.map +1 -1
  19. package/dist-client/modeller/figure-canvas.d.ts +7 -7
  20. package/dist-client/modeller/figure-canvas.js +27 -40
  21. package/dist-client/modeller/figure-canvas.js.map +1 -1
  22. package/dist-client/modeller/figure-capabilities.d.ts +2 -2
  23. package/dist-client/modeller/figure-capabilities.js +9 -9
  24. package/dist-client/modeller/figure-capabilities.js.map +1 -1
  25. package/dist-client/modeller/figure-inspector.d.ts +16 -5
  26. package/dist-client/modeller/figure-inspector.js +38 -27
  27. package/dist-client/modeller/figure-inspector.js.map +1 -1
  28. package/dist-client/modeller/figure-parts.d.ts +1 -1
  29. package/dist-client/modeller/figure-parts.js +1 -1
  30. package/dist-client/modeller/figure-parts.js.map +1 -1
  31. package/dist-client/modeller/figure-preview.d.ts +39 -0
  32. package/dist-client/modeller/figure-preview.js +112 -23
  33. package/dist-client/modeller/figure-preview.js.map +1 -1
  34. package/dist-client/modeller/figure-report.d.ts +9 -0
  35. package/dist-client/modeller/figure-report.js +23 -5
  36. package/dist-client/modeller/figure-report.js.map +1 -1
  37. package/dist-client/modeller/figure-settings.js +42 -2
  38. package/dist-client/modeller/figure-settings.js.map +1 -1
  39. package/dist-client/modeller/figure-side.d.ts +2 -2
  40. package/dist-client/modeller/figure-side.js +5 -5
  41. package/dist-client/modeller/figure-side.js.map +1 -1
  42. package/dist-client/modeller/figure-source.d.ts +38 -23
  43. package/dist-client/modeller/figure-source.js +40 -21
  44. package/dist-client/modeller/figure-source.js.map +1 -1
  45. package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
  46. package/dist-client/modeller/figure-thumbnail.js +3 -3
  47. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  48. package/dist-client/modeller/figure-view.d.ts +45 -0
  49. package/dist-client/modeller/figure-view.js +27 -0
  50. package/dist-client/modeller/figure-view.js.map +1 -1
  51. package/dist-client/modeller/part-edits.d.ts +16 -16
  52. package/dist-client/modeller/part-edits.js +35 -35
  53. package/dist-client/modeller/part-edits.js.map +1 -1
  54. package/dist-client/modeller/proposal.d.ts +1 -1
  55. package/dist-client/modeller/proposal.js +15 -3
  56. package/dist-client/modeller/proposal.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.d.ts +44 -9
  58. package/dist-client/pages/figure-modeller-page.js +134 -92
  59. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  60. package/dist-client/route.d.ts +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-server/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +4 -4
  64. package/test/ai-proposal-contract.test.ts +393 -12
  65. package/test/expect.ts +55 -0
  66. package/test/figure-source.test.ts +562 -0
  67. package/test/i18n-prefix-guard.test.ts +1 -1
  68. package/{client/modeller → test}/part-edits.test.ts +50 -45
  69. package/translations/en.json +31 -28
  70. package/translations/ja.json +16 -21
  71. package/translations/ko.json +41 -38
  72. package/translations/ms.json +16 -21
  73. package/translations/zh.json +16 -21
  74. package/client/modeller/figure-ask.ts +0 -265
  75. package/client/modeller/figure-source.test.ts +0 -352
  76. package/dist-client/modeller/figure-ask.d.ts +0 -40
  77. package/dist-client/modeller/figure-ask.js +0 -282
  78. package/dist-client/modeller/figure-ask.js.map +0 -1
@@ -8,6 +8,7 @@
8
8
  import assert from 'node:assert/strict'
9
9
  import { readFileSync } from 'node:fs'
10
10
  import { fileURLToPath } from 'node:url'
11
+ import { createRequire } from 'node:module'
11
12
  import { test } from 'node:test'
12
13
 
13
14
  import { applyProposal, diffProposal, takeProposal } from '../dist-client/modeller/proposal.js'
@@ -126,17 +127,24 @@ test('피드백은 후보 세션의 선택과 측정값만 전송한다', () =>
126
127
  })
127
128
  })
128
129
 
129
- test('모델러 AI 입구는 후보만 알리고, 팔레트·그림·현재 정본을 서버에 함께 보낸다', () => {
130
- /* Lit 컴포넌트는 DOM 없이 실로드하지 않는다. 경계에서는 실제 소스 계약을 읽는다. */
131
- const path = fileURLToPath(new URL('../client/modeller/figure-ask.ts', import.meta.url))
130
+ /*
131
+ * The modeller has one AI entrance now: the toolbar button opens the asidebar dock, and the dock
132
+ * runs the whole exchange. `figure-ask` — a second, inline form that called `proposeFigure`
133
+ * directly — was removed on 2026-09-16. It had already stopped being rendered, so it was dead
134
+ * code a reader would still have to understand before touching this path.
135
+ *
136
+ * What the entrance owes the author is unchanged and checked here: it must not save anything by
137
+ * itself, and it must hand the server the current source and palette so the candidate is a
138
+ * revision of what is open rather than something invented from the prompt alone.
139
+ */
140
+ test('the modeller has exactly one AI entrance, and it opens the dock', () => {
141
+ const path = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
132
142
  const source = readFileSync(path, 'utf8')
133
143
 
134
- assert.match(source, /const baseSource = this\.source \? JSON\.stringify\(this\.source\) : undefined/)
135
- assert.match(source, /proposeFigure\(\{[\s\S]*prompt,[\s\S]*base: baseSource,[\s\S]*type: this\.source \? undefined : this\.type,[\s\S]*palette: Object\.keys\(activePalette\(\)/)
136
- assert.match(source, /image: this\.picture/)
137
- assert.match(source, /feedback: this\.feedback/)
138
- assert.match(source, /new CustomEvent\('proposed', \{ detail: \{ proposal, baseSource \}/)
139
- assert.match(source, /this\.failure = \(err as Error\)\.message/)
144
+ assert.match(source, /requestFigureAI\(\{ figureId: this\.figure\?\.id, source: this\.folded \}\)/)
145
+ assert.match(source, /openOverlay\('figure-ai-dock'/)
146
+ assert.doesNotMatch(source, /<figure-ask/, 'the inline ask form is gone; the dock is the entrance')
147
+ assert.doesNotMatch(source, /proposeFigure\(/, 'the page must not call the propose mutation itself')
140
148
  })
141
149
 
142
150
  test('그림이 붙은 AI 요청은 JSON이 아니라 GraphQL multipart Upload 링크를 탄다', () => {
@@ -189,7 +197,7 @@ test('새 Figure 경로는 MDI에 남아 있는 직전 Figure를 재사용하지
189
197
  assert.match(pageUpdated, /if \(this\.figure\.id\)[\s\S]*this\.startNew\(\)/)
190
198
  // 새 초안은 저장 식별자·선택·기존 부품·진단을 모두 초기화한다.
191
199
  assert.match(startNew, /this\.figure = \{ id: '', type: draftType, name: '' \}/)
192
- assert.match(startNew, /const \{ board, parts \} = startingModel\(draftType\)/)
200
+ assert.match(startNew, /const \{ draft, parts \} = startingModel\(draftType\)/)
193
201
  assert.match(startNew, /this\.selected = -1/)
194
202
  assert.match(startNew, /this\.dirty = true/)
195
203
  assert.match(startNew, /this\.violations = \[\]/)
@@ -200,7 +208,7 @@ test('빈 새 Figure는 캔버스의 직전 씬을 폐기하고 안내 상태로
200
208
  const source = readFileSync(path, 'utf8')
201
209
  const standUp = source.slice(source.indexOf(' private standUp()'), source.indexOf(' /**\n * 지금 판 위에'))
202
210
 
203
- assert.match(standUp, /if \(!board\) \{[\s\S]*this\.teardown\(\)/)
211
+ assert.match(standUp, /if \(!draft\) \{[\s\S]*this\.teardown\(\)/)
204
212
  assert.match(standUp, /if \(this\.parts\.length === 0\) \{[\s\S]*this\.teardown\(\)[\s\S]*canvas-appears-once-a-part-exists/)
205
213
  })
206
214
 
@@ -212,7 +220,7 @@ test('캔버스는 figureId를 판별에 포함하고 없어진 부품을 씬에
212
220
 
213
221
  // figureId 변경 시 새 씬을 세우도록 signature에 figureId가 포함된다
214
222
  assert.match(canvasSource, /@property\(\{ type: String \}\) figureId\?: string/)
215
- assert.match(canvasSource, /signature = `\$\{this\.figureId \?\? ''\}:\$\{board\.width\}x\$\{board\.height\}x\$\{board\.depth\}/)
223
+ assert.match(canvasSource, /signature = `\$\{this\.figureId \?\? ''\}:\$\{draft\.width\}x\$\{draft\.height\}x\$\{draft\.depth\}/)
216
224
 
217
225
  // 없어진 부품은 root.removeComponent와 component.dispose로 제거된다
218
226
  assert.match(canvasSource, /root\.removeComponent\?\.\(component\)/)
@@ -222,3 +230,376 @@ test('캔버스는 figureId를 판별에 포함하고 없어진 부품을 씬에
222
230
  assert.match(modellerSource, /\.figureId=\$\{this\.figure\?\.id \|\| this\.figure\?\.type\}/)
223
231
  })
224
232
 
233
+
234
+ /*
235
+ * ── The whole round trip, not just one hop ───────────────────────────────────
236
+ *
237
+ * Everything above checks a single step. What broke on 2026-09-15 was the sequence: a candidate
238
+ * that only added animations produced an EMPTY change list, so the take button — disabled while
239
+ * nothing is picked — could never be pressed. The author could neither accept the candidate nor
240
+ * get rid of it. Asking again did not help, because the second candidate had the same shape.
241
+ *
242
+ * `FIGURE_FIELDS` in proposal.ts decides which figure-level fields are visible at all, and it had
243
+ * fallen behind the format: `animations`, `capabilities` and `placement` were missing, while
244
+ * `anchor` — removed from the format on 2026-09-10 — was still listed. The tests below walk the
245
+ * sequence an author actually performs, so a gap in that list shows up as a stuck session rather
246
+ * than as a silently dropped field.
247
+ */
248
+
249
+ /** A candidate whose only change is movement — the shape that could not be finished. */
250
+ const animationOnly = () => ({
251
+ ...current(),
252
+ animations: [
253
+ {
254
+ name: 'door',
255
+ drive: 'loop',
256
+ channels: [
257
+ {
258
+ target: 'motor',
259
+ path: 'rotation',
260
+ keys: [
261
+ { at: 0, value: { x: 0, y: 0, z: 0 } },
262
+ { at: 2, value: { x: 0, y: 90, z: 0 } }
263
+ ]
264
+ }
265
+ ]
266
+ }
267
+ ]
268
+ })
269
+
270
+ test('a candidate that only adds movement is something the author can act on', () => {
271
+ const changes = diffProposal(current() as any, animationOnly() as any)
272
+
273
+ assert.deepEqual(
274
+ changes.map(c => `${c.kind}:${c.key}`),
275
+ ['animations:animations'],
276
+ 'movement-only candidates used to diff to nothing, which left the take button disabled forever'
277
+ )
278
+
279
+ const taken = applyProposal(current() as any, animationOnly() as any, new Set(['animations'])) as any
280
+ assert.equal(taken.animations?.length, 1, 'the clip has to survive being taken')
281
+ assert.equal(taken.animations[0].channels[0].target, 'motor')
282
+ })
283
+
284
+ test('figure-level fields the format has are all offered, and ones it dropped are not', () => {
285
+ const before = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
286
+ const after = {
287
+ ...current(),
288
+ placement: 'ceiling',
289
+ capabilities: ['Holdable', 'FlowNode'],
290
+ styleKit: 'factory',
291
+ detailLevel: 'L',
292
+ base: { x: 120, y: 100, z: 100 },
293
+ animations: animationOnly().animations
294
+ }
295
+
296
+ const kinds = diffProposal(before as any, after as any).map(c => c.kind)
297
+ for (const kind of ['base', 'placement', 'detail', 'styleKit', 'animations', 'capabilities']) {
298
+ assert.ok(kinds.includes(kind as any), `${kind} changed and the author was never shown it`)
299
+ }
300
+ assert.ok(!kinds.includes('anchor' as any), 'figure-level anchor left the format on 2026-09-10')
301
+ })
302
+
303
+ test('propose, revise, then accept — each round starts from what the author has now', () => {
304
+ /* Round one: the author keeps the new guard but not the wider base. */
305
+ const first = openAiProposal(current() as any, { source: JSON.stringify(proposal()) } as any)
306
+ assert.ok(first.picked.size > 0, 'a fresh session starts with every change picked')
307
+
308
+ const withoutBase = toggleProposalChange(first, 'base')
309
+ assert.ok(!withoutBase.picked.has('base'))
310
+
311
+ const afterFirst = applyProposal(current() as any, proposal() as any, withoutBase.picked) as any
312
+ assert.deepEqual(afterFirst.base, current().base, 'the change left unpicked must not be applied')
313
+ assert.ok(afterFirst.parts.some((p: any) => p.name === 'guard'), 'the picked addition must be applied')
314
+
315
+ /* Round two: asked again, this time about movement. The base the author refused stays refused. */
316
+ const revised = { ...afterFirst, animations: animationOnly().animations }
317
+ const second = openAiProposal(afterFirst, { source: JSON.stringify(revised) } as any)
318
+ assert.deepEqual(
319
+ [...second.picked],
320
+ ['animations'],
321
+ 'the second round compares against the accepted draft, not the original — already-taken changes are not offered again'
322
+ )
323
+
324
+ const afterSecond = applyProposal(afterFirst, revised as any, second.picked) as any
325
+ assert.equal(afterSecond.animations?.length, 1)
326
+ assert.ok(afterSecond.parts.some((p: any) => p.name === 'guard'), 'round one survives round two')
327
+ assert.deepEqual(afterSecond.base, current().base, 'and so does the refusal')
328
+ })
329
+
330
+ test('cancelling leaves the draft exactly as it was', () => {
331
+ const draft = current()
332
+ const session = openAiProposal(draft as any, { source: JSON.stringify(proposal()) } as any)
333
+
334
+ /* Cancelling is dropping the session. The draft is a separate value and must be untouched. */
335
+ assert.deepEqual(draft, current(), 'opening a candidate for review must not mutate the draft')
336
+ assert.ok(session.source !== (draft as any), 'and the session must hold its own copy')
337
+
338
+ /* Unpicking everything is the other way to refuse: the take button disables and nothing moves. */
339
+ let emptied = session
340
+ for (const key of [...session.picked]) emptied = toggleProposalChange(emptied, key)
341
+ assert.equal(emptied.picked.size, 0)
342
+ assert.deepEqual(applyProposal(draft as any, proposal() as any, emptied.picked) as any, {
343
+ ...draft,
344
+ parts: draft.parts
345
+ }, 'with nothing picked the result is the draft itself')
346
+ })
347
+
348
+ /*
349
+ * ── The cases the four tests above did not reach ─────────────────────────────
350
+ *
351
+ * Those covered the happy sequence. What follows are the ones an author actually hits and that a
352
+ * happy-path test never touches: refusing a deletion, a candidate that takes movement away, the
353
+ * draft moving under an open candidate, and every change kind surviving being applied rather than
354
+ * merely being listed. The animation defect was in exactly that gap — the kind was listed nowhere,
355
+ * so nothing that only listed kinds could have caught it.
356
+ */
357
+
358
+ const withAnimation = () => ({ ...current(), animations: animationOnly().animations })
359
+
360
+ test('refusing a deletion keeps the part, and taking it removes it', () => {
361
+ const dropsMotor = { ...current(), parts: current().parts.filter(p => p.name !== 'motor') }
362
+
363
+ const changes = diffProposal(current() as any, dropsMotor as any)
364
+ assert.deepEqual(changes.map(c => `${c.kind}:${c.key}`), ['removed:motor'])
365
+
366
+ const refused = applyProposal(current() as any, dropsMotor as any, new Set()) as any
367
+ assert.ok(refused.parts.some((p: any) => p.name === 'motor'), 'an unpicked removal must not delete anything')
368
+
369
+ const taken = applyProposal(current() as any, dropsMotor as any, new Set(['motor'])) as any
370
+ assert.ok(!taken.parts.some((p: any) => p.name === 'motor'))
371
+ })
372
+
373
+ test('a candidate that takes movement away is shown, not applied behind the author', () => {
374
+ /* The reverse direction of the defect: the draft has clips and the candidate comes back without. */
375
+ const stripped = { ...withAnimation(), animations: undefined }
376
+
377
+ const changes = diffProposal(withAnimation() as any, stripped as any)
378
+ assert.deepEqual(changes.map(c => c.kind), ['animations'], 'losing the clips has to appear as a change')
379
+
380
+ const refused = applyProposal(withAnimation() as any, stripped as any, new Set()) as any
381
+ assert.equal(refused.animations?.length, 1, 'unpicked, the clips stay')
382
+
383
+ const taken = applyProposal(withAnimation() as any, stripped as any, new Set(['animations'])) as any
384
+ assert.equal(taken.animations, undefined, 'picked, they go — but only because the author said so')
385
+ })
386
+
387
+ test('every change kind survives being applied, not just being listed', () => {
388
+ const before = { ...current(), placement: 'floor', capabilities: ['Holdable'], styleKit: 'plain' }
389
+ const after = {
390
+ ...current(),
391
+ placement: 'ceiling',
392
+ capabilities: ['Holdable', 'FlowNode'],
393
+ styleKit: 'factory',
394
+ detailLevel: 'L',
395
+ base: { x: 120, y: 100, z: 100 },
396
+ animations: animationOnly().animations
397
+ }
398
+
399
+ const all = new Set(diffProposal(before as any, after as any).map(c => c.key))
400
+ const taken = applyProposal(before as any, after as any, all) as any
401
+
402
+ assert.equal(taken.placement, 'ceiling')
403
+ assert.deepEqual(taken.capabilities, ['Holdable', 'FlowNode'])
404
+ assert.equal(taken.styleKit, 'factory')
405
+ assert.equal(taken.detailLevel, 'L')
406
+ assert.deepEqual(taken.base, { x: 120, y: 100, z: 100 })
407
+ assert.equal(taken.animations?.length, 1)
408
+ })
409
+
410
+ test('each figure-level change can be taken on its own without dragging the others along', () => {
411
+ const before = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
412
+ const after = {
413
+ ...current(),
414
+ placement: 'ceiling',
415
+ capabilities: ['Holdable', 'FlowNode'],
416
+ animations: animationOnly().animations
417
+ }
418
+
419
+ for (const only of ['placement', 'capabilities', 'animations']) {
420
+ const taken = applyProposal(before as any, after as any, new Set([only])) as any
421
+ assert.equal(taken.placement, only === 'placement' ? 'ceiling' : 'floor', `${only}: placement leaked`)
422
+ assert.equal(
423
+ taken.capabilities.length,
424
+ only === 'capabilities' ? 2 : 1,
425
+ `${only}: capabilities leaked`
426
+ )
427
+ assert.equal(taken.animations === undefined, only !== 'animations', `${only}: animations leaked`)
428
+ }
429
+ })
430
+
431
+ test('taking part of a candidate and then asking again offers only what is still different', () => {
432
+ const after = {
433
+ ...current(),
434
+ placement: 'ceiling',
435
+ animations: animationOnly().animations,
436
+ parts: [...current().parts, proposal().parts[1]]
437
+ }
438
+
439
+ /* The author takes the movement only. */
440
+ const firstRound = applyProposal(current() as any, after as any, new Set(['animations'])) as any
441
+ assert.equal(firstRound.animations?.length, 1)
442
+ assert.equal(firstRound.placement, undefined)
443
+
444
+ /* Asking again with the same candidate must still offer what was left behind — and not movement. */
445
+ const second = openAiProposal(firstRound, { source: JSON.stringify(after) } as any)
446
+ assert.deepEqual([...second.picked].sort(), ['guard', 'placement'])
447
+ })
448
+
449
+ test('a malformed candidate is refused at the session boundary, not half-opened', () => {
450
+ for (const bad of ['{"type":"MIXER"}', '{"parts":"not an array"}', 'not json at all']) {
451
+ assert.throws(
452
+ () => openAiProposal(current() as any, { source: bad } as any),
453
+ 'a candidate without a usable parts array must not open a review session'
454
+ )
455
+ }
456
+ })
457
+
458
+ test('an identical candidate leaves nothing to decide', () => {
459
+ const same = openAiProposal(current() as any, { source: JSON.stringify(current()) } as any)
460
+ assert.equal(same.picked.size, 0, 'no differences means no picks')
461
+ assert.deepEqual(
462
+ applyProposal(current() as any, current() as any, same.picked) as any,
463
+ current(),
464
+ 'and applying it changes nothing'
465
+ )
466
+ })
467
+
468
+ test('taking a candidate twice does not compound — the second time there is nothing left', () => {
469
+ const once = applyProposal(current() as any, proposal() as any) as any
470
+ const twice = applyProposal(once, proposal() as any) as any
471
+ assert.deepEqual(twice, once, 'apply has to be idempotent, or a double click duplicates parts')
472
+ assert.equal(
473
+ once.parts.filter((p: any) => p.name === 'guard').length,
474
+ 1,
475
+ 'the added part must not appear twice'
476
+ )
477
+ })
478
+
479
+ test('part names are matched exactly — a renamed part is an addition plus a removal', () => {
480
+ const renamed = {
481
+ ...current(),
482
+ parts: current().parts.map(p => (p.name === 'motor' ? { ...p, name: 'Motor' } : p))
483
+ }
484
+
485
+ const kinds = diffProposal(current() as any, renamed as any).map(c => `${c.kind}:${c.key}`).sort()
486
+ assert.deepEqual(kinds, ['added:Motor', 'removed:motor'], 'case differences are different parts — bindings are exact')
487
+ })
488
+
489
+ /*
490
+ * ── The three properties this path has to keep, stated as properties ─────────
491
+ *
492
+ * Everything above is a case. Cases are written by hand and a hand-written list stops where its
493
+ * author stopped thinking — which is how a whole feature (movement) went unreachable while every
494
+ * test stayed green. These are closed over the format instead: they read what a FigureSource may
495
+ * contain and assert something that must hold for all of it, so the format growing is what makes
496
+ * them fail.
497
+ *
498
+ * They are also the contract another authoring surface would have to meet. Nothing below knows
499
+ * about figures specifically — only that there is a canonical document, a candidate for it, and a
500
+ * person deciding between the two.
501
+ */
502
+
503
+ /** 형식이 선언한 저작 칸. 손으로 적지 않는다 — 형식이 자라면 이 시험도 같이 자란다. */
504
+ function figureFields(): string[] {
505
+ /* 패키지가 호이스팅되면 이 패키지 아래에 없다. 해석으로 찾는다 — 경로를 적어 두지 않는다. */
506
+ const entry = createRequire(import.meta.url).resolve('@hatiolab/figure-model/package.json')
507
+ const source = readFileSync(entry.replace(/package\.json$/, 'dist/types.d.ts'), 'utf8')
508
+ const body = source.match(/interface FigureSource \{([\s\S]*?)\n\}/)
509
+ assert.ok(body, 'FigureSource 를 형식에서 못 찾았다')
510
+ return [...body![1].matchAll(/^ {4}([a-zA-Z]+)\??:/gm)].map(m => m[1]!)
511
+ }
512
+
513
+ test('구조 · 저작 가능한 모든 칸은 검토를 지난다 — 조용히 지나가는 칸이 없다', () => {
514
+ /*
515
+ `type` 은 저장 식별자라 후보가 바꿔도 받지 않고, `parts` 는 부품 단위로 따로 센다.
516
+ `version` 은 저장할 때 서버가 찍는다. 나머지는 전부 사람이 보고 고를 수 있어야 한다.
517
+ */
518
+ const decidedElsewhere = new Set(['type', 'parts', 'version'])
519
+ const mustBeReviewable = figureFields().filter(f => !decidedElsewhere.has(f))
520
+
521
+ const before: any = { ...current() }
522
+ const after: any = { ...current() }
523
+ /* 각 칸을 실제로 다르게 만들어 본다 — 이름만 맞추는 시험은 값이 안 옮겨져도 통과한다. */
524
+ const sample: Record<string, [unknown, unknown]> = {
525
+ base: [{ x: 100, y: 100, z: 100 }, { x: 120, y: 100, z: 100 }],
526
+ placement: ['floor', 'ceiling'],
527
+ detailLevel: ['M', 'L'],
528
+ styleKit: ['plain', 'factory'],
529
+ animations: [undefined, animationOnly().animations],
530
+ capabilities: [['Holdable'], ['Holdable', 'FlowNode']]
531
+ }
532
+
533
+ const unsampled = mustBeReviewable.filter(f => !(f in sample))
534
+ assert.deepEqual(unsampled, [], '형식에 칸이 늘었다 — 여기에 다른 값 한 쌍을 더해 검토를 확인하라')
535
+
536
+ for (const field of mustBeReviewable) {
537
+ const [b, a] = sample[field]!
538
+ before[field] = b
539
+ after[field] = a
540
+ }
541
+
542
+ const changes = diffProposal(before, after)
543
+ const shown = new Set(changes.map(c => c.key))
544
+ const hidden = mustBeReviewable.filter(f => !shown.has(f))
545
+ assert.deepEqual(hidden, [], '바뀌었는데 사람에게 안 보이는 칸이다 — 받아도 반영되지 않고 아무 말도 안 난다')
546
+
547
+ const taken = applyProposal(before, after, new Set(mustBeReviewable)) as any
548
+ for (const field of mustBeReviewable) {
549
+ assert.deepEqual(taken[field], sample[field]![1], `${field}: 목록에는 떴는데 받아도 안 옮겨졌다`)
550
+ }
551
+ })
552
+
553
+ test('구조 · 고른 것만 옮겨진다 — 어떤 부분집합에도', () => {
554
+ const before: any = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
555
+ const after: any = {
556
+ ...current(),
557
+ placement: 'ceiling',
558
+ capabilities: ['Holdable', 'FlowNode'],
559
+ animations: animationOnly().animations,
560
+ parts: [...current().parts, proposal().parts[1]]
561
+ }
562
+
563
+ const keys = diffProposal(before, after).map(c => c.key)
564
+ /* 부분집합 전수. 칸이 늘면 경우도 자동으로 는다. */
565
+ for (let mask = 0; mask < 1 << keys.length; mask++) {
566
+ const picked = new Set(keys.filter((_, i) => mask & (1 << i)))
567
+ const taken = applyProposal(before, after, picked) as any
568
+ const left = diffProposal(taken, after).map(c => c.key)
569
+
570
+ for (const key of keys) {
571
+ const wasPicked = picked.has(key)
572
+ assert.equal(
573
+ left.includes(key),
574
+ !wasPicked,
575
+ `고른 것은 남으면 안 되고 안 고른 것은 남아야 한다 (${[...picked].join(',') || '없음'} 에서 ${key})`
576
+ )
577
+ }
578
+ }
579
+ })
580
+
581
+ test('구조 · 검토는 정본을 건드리지 않는다 — 받기 전까지', () => {
582
+ const draft = Object.freeze({
583
+ ...current(),
584
+ parts: Object.freeze(current().parts.map(p => Object.freeze(p)))
585
+ }) as any
586
+ const candidate = { ...current(), animations: animationOnly().animations, placement: 'ceiling' } as any
587
+
588
+ /* 얼린 것에 쓰면 던진다. 던지지 않는다는 것이 곧 안 건드렸다는 것이다. */
589
+ const session = openAiProposal(draft, { source: JSON.stringify(candidate) } as any)
590
+ diffProposal(draft, candidate)
591
+ applyProposal(draft, candidate, session.picked)
592
+ toggleProposalChange(session, 'placement')
593
+
594
+ assert.deepEqual(draft.animations, undefined, '검토를 열었다고 정본이 달라지면 안 된다')
595
+ assert.equal(draft.placement, undefined)
596
+ })
597
+
598
+ test('구조 · 같은 후보를 두 번 받아도 한 번 받은 것과 같다', () => {
599
+ const after: any = { ...current(), animations: animationOnly().animations, placement: 'ceiling' }
600
+ const once = applyProposal(current() as any, after, undefined) as any
601
+ const twice = applyProposal(once, after, undefined) as any
602
+
603
+ assert.deepEqual(twice, once, '두 번 눌러도 달라지면 안 된다')
604
+ assert.deepEqual(diffProposal(once, after), [], '한 번 받으면 남는 변경이 없다')
605
+ })
package/test/expect.ts ADDED
@@ -0,0 +1,55 @@
1
+ /*
2
+ * A thin `expect` over `node:assert`, so Jest-shaped tests can run here.
3
+ *
4
+ * ── Why this exists ──────────────────────────────────────────────────────────
5
+ * This package runs `node --test test/*.test.ts`. Two suites were written in Jest's shape and
6
+ * left under `client/modeller/`, where **neither runner picked them up** — `npm test` does not
7
+ * look there, and the repository's Jest is not configured for this package. They had not executed
8
+ * since they were written.
9
+ *
10
+ * That mattered more than the count. `figure-source.ts` carries a comment saying the round-trip
11
+ * guard "now reads the field list the format declares rather than trusting the fixture", and that
12
+ * guard lives in one of those files. The source leaned on a test that never ran.
13
+ *
14
+ * Rewriting 662 lines of assertions by hand would have changed what they mean in places nobody
15
+ * would notice. Eight matchers cover both files, so the cheaper and safer move is to give them
16
+ * the eight.
17
+ *
18
+ * Only add a matcher here when a test needs it. This is a bridge for suites that already exist,
19
+ * not a second assertion library for new ones — new tests in this package use `node:assert`
20
+ * directly, like every other file under `test/`.
21
+ */
22
+ import assert from 'node:assert/strict'
23
+
24
+ export interface Expectation {
25
+ toBe(expected: unknown): void
26
+ toEqual(expected: unknown): void
27
+ toMatchObject(expected: Record<string, unknown>): void
28
+ toHaveLength(expected: number): void
29
+ toBeUndefined(): void
30
+ toBeGreaterThan(expected: number): void
31
+ toBeLessThan(expected: number): void
32
+ toBeLessThanOrEqual(expected: number): void
33
+ }
34
+
35
+ export function expect(actual: any): Expectation {
36
+ return {
37
+ /* Jest's toBe is Object.is; assert.strictEqual is the same comparison. */
38
+ toBe: expected => assert.strictEqual(actual, expected),
39
+ toEqual: expected => assert.deepStrictEqual(actual, expected),
40
+ toMatchObject: expected => {
41
+ for (const [key, value] of Object.entries(expected)) {
42
+ assert.deepStrictEqual(
43
+ actual?.[key],
44
+ value,
45
+ `${key}: ${JSON.stringify(actual?.[key])} !== ${JSON.stringify(value)}`
46
+ )
47
+ }
48
+ },
49
+ toHaveLength: expected => assert.strictEqual(actual?.length, expected, `length ${actual?.length} !== ${expected}`),
50
+ toBeUndefined: () => assert.strictEqual(actual, undefined),
51
+ toBeGreaterThan: expected => assert.ok(actual > expected, `${actual} is not greater than ${expected}`),
52
+ toBeLessThan: expected => assert.ok(actual < expected, `${actual} is not less than ${expected}`),
53
+ toBeLessThanOrEqual: expected => assert.ok(actual <= expected, `${actual} is not <= ${expected}`)
54
+ }
55
+ }