@things-factory/figure-ui 10.1.151 → 10.1.153

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 (86) hide show
  1. package/client/modeller/ai-proposal.ts +14 -13
  2. package/client/modeller/command-preview-controller.ts +210 -0
  3. package/client/modeller/editor-styles.ts +307 -0
  4. package/client/modeller/editor.ts +65 -634
  5. package/client/modeller/fasten-geometry.ts +32 -0
  6. package/client/modeller/figure-first-save.ts +9 -1
  7. package/client/modeller/figure-links.ts +32 -14
  8. package/client/modeller/mesh-import.ts +0 -10
  9. package/client/modeller/modeller-keys.ts +60 -0
  10. package/client/modeller/pane-fold.ts +61 -0
  11. package/client/modeller/panel-memory.ts +53 -0
  12. package/client/modeller/refusal-teller.ts +93 -0
  13. package/client/modeller/refusals.ts +32 -2
  14. package/dist-client/modeller/ai-proposal.d.ts +6 -5
  15. package/dist-client/modeller/ai-proposal.js +9 -9
  16. package/dist-client/modeller/ai-proposal.js.map +1 -1
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/command-preview-controller.d.ts +75 -0
  19. package/dist-client/modeller/command-preview-controller.js +178 -0
  20. package/dist-client/modeller/command-preview-controller.js.map +1 -0
  21. package/dist-client/modeller/editor-styles.d.ts +2 -0
  22. package/dist-client/modeller/editor-styles.js +307 -0
  23. package/dist-client/modeller/editor-styles.js.map +1 -0
  24. package/dist-client/modeller/editor.d.ts +5 -56
  25. package/dist-client/modeller/editor.js +76 -656
  26. package/dist-client/modeller/editor.js.map +1 -1
  27. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  28. package/dist-client/modeller/fasten-geometry.js +29 -0
  29. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  30. package/dist-client/modeller/figure-first-save.d.ts +1 -0
  31. package/dist-client/modeller/figure-first-save.js +8 -1
  32. package/dist-client/modeller/figure-first-save.js.map +1 -1
  33. package/dist-client/modeller/figure-links.d.ts +9 -2
  34. package/dist-client/modeller/figure-links.js +35 -14
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/mesh-import.d.ts +0 -2
  37. package/dist-client/modeller/mesh-import.js +0 -11
  38. package/dist-client/modeller/mesh-import.js.map +1 -1
  39. package/dist-client/modeller/modeller-keys.d.ts +22 -0
  40. package/dist-client/modeller/modeller-keys.js +43 -0
  41. package/dist-client/modeller/modeller-keys.js.map +1 -0
  42. package/dist-client/modeller/pane-fold.d.ts +15 -0
  43. package/dist-client/modeller/pane-fold.js +60 -0
  44. package/dist-client/modeller/pane-fold.js.map +1 -0
  45. package/dist-client/modeller/panel-memory.d.ts +15 -0
  46. package/dist-client/modeller/panel-memory.js +54 -0
  47. package/dist-client/modeller/panel-memory.js.map +1 -0
  48. package/dist-client/modeller/product-session.d.ts +4 -28
  49. package/dist-client/modeller/refusal-teller.d.ts +36 -0
  50. package/dist-client/modeller/refusal-teller.js +81 -0
  51. package/dist-client/modeller/refusal-teller.js.map +1 -0
  52. package/dist-client/modeller/refusals.d.ts +12 -1
  53. package/dist-client/modeller/refusals.js +26 -1
  54. package/dist-client/modeller/refusals.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +5 -5
  57. package/test/browser/build.mjs +7 -2
  58. package/test/browser/editing.check.mjs +14 -0
  59. package/test/browser/fix-alignment.check.mjs +112 -0
  60. package/test/browser/links.check.mjs +69 -13
  61. package/test/browser/mesh-select.check.mjs +91 -0
  62. package/test/browser/modeller-page.js +36 -0
  63. package/test/browser/modeller-revision.check.mjs +18 -6
  64. package/test/browser/modeller.check.mjs +25 -0
  65. package/test/browser/part-ids.check.mjs +24 -6
  66. package/test/browser/parts-page.js +9 -1
  67. package/test/browser/parts.check.mjs +61 -2
  68. package/test/browser/places-mesh.check.mjs +25 -9
  69. package/test/browser/product-page.check.mjs +42 -2
  70. package/test/browser/row-preview.check.mjs +81 -0
  71. package/test/browser/screen.check.mjs +35 -1
  72. package/test/clip-ai-proposal.test.ts +4 -1
  73. package/test/clip-consent.test.ts +9 -2
  74. package/test/fields.test.ts +5 -9
  75. package/test/fix-alignment.test.ts +32 -0
  76. package/test/layout.test.ts +3 -1
  77. package/test/mesh-import.test.ts +1 -8
  78. package/test/parts-wiring.test.ts +1 -5
  79. package/test/refusals.test.ts +46 -10
  80. package/test/theme.test.ts +5 -15
  81. package/translations/en.json +8 -0
  82. package/translations/es.json +8 -0
  83. package/translations/ja.json +8 -0
  84. package/translations/ko.json +8 -0
  85. package/translations/ms.json +8 -0
  86. package/translations/zh.json +8 -0
@@ -202,6 +202,65 @@ describe('a removal is told: what it let go of as a notice, a refusal as an erro
202
202
  })
203
203
  })
204
204
 
205
+ describe('a part renamed in its name field: one step, still selected; a name refused is said why', () => {
206
+ before(() => mount('box', 'three'))
207
+ const name = () => read(() => editing.fieldValue('figure.label.name'))
208
+ async function rename(to) {
209
+ await tab('properties')
210
+ const at = await read(() => probe.labelled('figure.label.name'))
211
+ assert.ok(at, 'the name field')
212
+ await page().mouse.click(at.x, at.y, { clickCount: 3 })
213
+ await page().keyboard.type(to)
214
+ await page().keyboard.press('Enter')
215
+ await sleep(400)
216
+ }
217
+
218
+ test('renamed: the list has the new name, it stays selected, in one step', async () => {
219
+ await pick('a')
220
+ const before = await revision()
221
+ await rename('left')
222
+ assert.deepEqual(await ids(), ['left', 'b', 'c'])
223
+ assert.equal((await state()).selected, 'left')
224
+ assert.equal(await revision(), before + 1)
225
+ })
226
+
227
+ test('a name another part has is refused: nothing written, the field shows the name it had, and why is said', async () => {
228
+ await pick('left')
229
+ await read(() => editing.told())
230
+ const before = await revision()
231
+ await rename('b')
232
+ assert.equal(await revision(), before, 'nothing written')
233
+ assert.deepEqual(await ids(), ['left', 'b', 'c'])
234
+ assert.equal(await name(), 'left')
235
+ const told = await read(() => editing.told())
236
+ assert.deepEqual(told.map(t => t.level), ['error'], JSON.stringify(told))
237
+ /* Said from the kernel's details (ADR-0127): the page loads no translations, so the words are the key. */
238
+ assert.equal(told[0].message, 'figure.text.part-name-taken')
239
+ })
240
+ })
241
+
242
+ describe('a part in the declared space removed: at once, the space fitted to the parts left, and said so (2026-09-27)', () => {
243
+ test('one step, nothing asked, the space still declared, and one notice that it was fitted again', async () => {
244
+ await mount('box', 'gate')
245
+ await pick('stray')
246
+ const before = await revision()
247
+ const space = () => read(() => JSON.parse(document.querySelector('figure-editor').session.source()).occupancy ?? null)
248
+ const was = await space()
249
+ assert.ok(was, 'declared to begin with')
250
+ await click(await read(() => parts.op('remove-part')), 'remove')
251
+ assert.deepEqual(await ids(), ['housing'])
252
+ assert.equal(await revision(), before + 1, 'one step')
253
+ assert.equal(await read(() => editing.prompt()), null, 'nothing asked')
254
+ const now = await space()
255
+ assert.ok(now, 'the space is still declared')
256
+ assert.notDeepEqual(now, was, 'fitted to the part left')
257
+ const told = await read(() => editing.told())
258
+ assert.deepEqual(told.map(t => t.level), ['info'], JSON.stringify(told))
259
+ /* The page loads no translations: the words are the key. */
260
+ assert.ok(told[0].message.includes('figure.text.removal-space-refitted'), told[0].message)
261
+ })
262
+ })
263
+
205
264
  describe('the list narrowed: the selected row keeps its name and its buttons in sight', () => {
206
265
  before(() => mount('box', 'three'))
207
266
  after(() => read(() => parts.narrow(0)))
@@ -221,11 +280,11 @@ describe('the list narrowed: the selected row keeps its name and its buttons in
221
280
  })
222
281
 
223
282
  describe('the palette: the starting shapes, locked at the detail level\'s limit', () => {
224
- test('nine starting shapes, in order; a rounded box or a drum is made from the box', async () => {
283
+ test('nine starting shapes, in order, then a mesh brought from a file; a rounded box or a drum is made from the box', async () => {
225
284
  await mount('box', 'three')
226
285
  assert.deepEqual(
227
286
  (await read(() => parts.palette())).map(b => b.label),
228
- ['cube', 'cylinder', 'sphere', 'frustum', 'polygon', 'pyramid', 'plate', 'wall', 'portal'].map(k => `figure.label.primitive-${k}`)
287
+ ['cube', 'cylinder', 'sphere', 'frustum', 'polygon', 'pyramid', 'plate', 'wall', 'portal'].map(k => `figure.label.primitive-${k}`).concat('figure.button.import-mesh')
229
288
  )
230
289
  })
231
290
 
@@ -42,24 +42,38 @@ async function until(fn, args, what, ms = 8000) {
42
42
  await sleep(50)
43
43
  }
44
44
  }
45
- const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
45
+ /*
46
+ * Every element the check reads has drawn what it was last told: the editor, and the places panel and the shared slot
47
+ * layout editor inside it, which draw after the editor does -- awaited until none of them has a draw pending, so a read
48
+ * never meets the one before.
49
+ */
50
+ const settled = () =>
51
+ read(async () => {
52
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
53
+ const drawing = () => [document.querySelector('figure-editor'), ...deep('figure-links, figure-slot-layout-panel, ox-slot-layout-editor')].filter(Boolean)
54
+ for (let round = 0; round < 20; round++) {
55
+ await Promise.all(drawing().map(e => e.updateComplete))
56
+ if (!drawing().some(e => e.isUpdatePending)) return
57
+ }
58
+ })
46
59
 
47
60
  async function mount(asset) {
48
61
  await read(text => harness.mount('given', text), serializeFigureAsset(asset))
49
62
  await until(() => look.drawn().length > 0, [], 'the board is drawn')
50
63
  }
51
- /** The element `findDeep` finds, as a handle the mouse and keyboard act on. */
52
- const handle = (selector, options = {}) => page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
64
+ /** The element `findDeep` finds, once it is on the page -- waited for, not looked up once -- as a handle to act on. */
65
+ async function handle(selector, options = {}, what = selector) {
66
+ await until((s, o) => !!findDeep(s, o), [selector, options], `${what} on the page`)
67
+ return page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
68
+ }
53
69
  async function click(selector, options = {}, what = selector) {
54
- const element = await handle(selector, options)
55
- assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
70
+ const element = await handle(selector, options, what)
56
71
  await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
57
72
  await element.click()
58
73
  await settled()
59
74
  }
60
75
  async function type(selector, options, text) {
61
76
  const field = await handle(selector, options)
62
- assert.ok(await field.evaluate(e => !!e), `${selector} is on the page`)
63
77
  await field.evaluate(e => e.scrollIntoView({ block: 'center' }))
64
78
  await field.click({ clickCount: 3 })
65
79
  await page().keyboard.type(text)
@@ -68,7 +82,6 @@ async function type(selector, options, text) {
68
82
  }
69
83
  async function choose(selector, options, value) {
70
84
  const select = await handle(selector, options)
71
- assert.ok(await select.evaluate(e => !!e), `${selector} is on the page`)
72
85
  await select.select(value)
73
86
  await settled()
74
87
  }
@@ -120,7 +133,8 @@ describe('a GLB made into a mesh part', () => {
120
133
  })
121
134
 
122
135
  test('the file chooser takes GLB files', async () => {
123
- assert.equal(await read(() => findDeep('input[mesh-file]').accept), '.glb,model/gltf-binary')
136
+ const input = await handle('input[mesh-file]', {}, 'the palette\'s file input')
137
+ assert.equal(await input.evaluate(i => i.accept), '.glb,model/gltf-binary')
124
138
  })
125
139
 
126
140
  test('a file with one node: uploaded, added at once as one step, named by the file, selected', async () => {
@@ -142,7 +156,7 @@ describe('a GLB made into a mesh part', () => {
142
156
  await read(a => meshServer.answer({ upload: a }), crane)
143
157
  const start = await state()
144
158
  await importFile()
145
- await until(() => !!findDeep('figure-mesh-choice'), [], 'the question')
159
+ await until(() => (findDeep('figure-mesh-choice')?.shadowRoot?.querySelectorAll('li button').length ?? 0) === 3, [], 'the question and its three choices')
146
160
  const choices = await read(() => [...findDeep('figure-mesh-choice').shadowRoot.querySelectorAll('li button')].map(b => b.textContent.replace(/\s+/g, ' ').trim()))
147
161
  assert.equal(choices.length, 3)
148
162
  assert.match(choices[0], /^figure\.text\.mesh-whole-file/)
@@ -186,6 +200,7 @@ describe('places made by a rule: the shared slot layout editor on a part\'s face
186
200
  await pick('fork')
187
201
  const tab = await read(l => probe.tab(l), 'figure.label.tab-short-capability')
188
202
  await page().mouse.click(tab.x, tab.y)
203
+ await until(() => look.tab() === 'figure.label.tab-short-capability', [], 'the capability tab on')
189
204
  await settled()
190
205
  })
191
206
 
@@ -193,6 +208,7 @@ describe('places made by a rule: the shared slot layout editor on a part\'s face
193
208
  assert.equal(await has('figure-slot-layout-panel'), false)
194
209
  await click('md-filter-chip[data-value="rule"]', {}, 'the by-a-rule choice')
195
210
  await until(() => !!findDeep('ox-slot-layout-editor'), [], 'the editor')
211
+ await settled()
196
212
  assert.equal(await has('button[data-action="use-default"]'), false, 'no default: the figure\'s layout is the only one')
197
213
  })
198
214
 
@@ -10,6 +10,8 @@
10
10
  import { test, describe, before, after } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
 
13
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
14
+
13
15
  import { buildPage } from './build.mjs'
14
16
  import { openPage } from './serve.mjs'
15
17
 
@@ -203,7 +205,9 @@ describe('the first save of a new figure', () => {
203
205
  await until(() => !product.deep('figure-first-save').length, 'the question closed')
204
206
  await press('button[save]', 'save')
205
207
  await until(() => product.deep('figure-first-save')[0]?.shadowRoot.querySelector('#type'), 'asked again')
206
- assert.equal((await firstSave()).type, 'my-lift')
208
+ /* Asked again, the name is focused again: the browser honours `autofocus` once per document. */
209
+ await until(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; return a?.id === 'name' }, 'the name focused again')
210
+ assert.deepEqual(await firstSave(), { first: 'name', type: 'my-lift', focused: 'name' })
207
211
  await read(() => {
208
212
  const name = product.deep('figure-first-save >>> #name')[0]
209
213
  name.focus()
@@ -566,6 +570,42 @@ describe('the page around the figure: the shell, the dock, the properties', () =
566
570
  })
567
571
  })
568
572
 
573
+ describe('a placement the figure\'s parts refuse, chosen on the properties tab (wall placement, 09-25)', () => {
574
+ /* A post standing on the floor's plane by its bottom: a wall takes the back face, so the wall is refused. */
575
+ const post = () => {
576
+ const a = addFigureBox(createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 500, z: 0 } })
577
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'attach', part: 'post', face: 'bottom', to: { plane: 'mounting-plane' } }))
578
+ }
579
+ const placement = () =>
580
+ read(() => {
581
+ const select = product.deep('figure-modeller-page >>> figure-properties >>> select').find(s => s.previousElementSibling?.textContent.trim() === 'figure.label.figure-placement')
582
+ return select ? select.value : null
583
+ })
584
+ const revision = () => read(() => product.page().session.read().revision)
585
+
586
+ before(async () => {
587
+ await fresh()
588
+ await read(text => product.seed({ id: 'p', type: 'post', source: text }), post())
589
+ await open('p')
590
+ })
591
+
592
+ test('refused: nothing written, the reason said in words beside the tabs, and the list back on the placement it has', async () => {
593
+ assert.equal(await placement(), 'floor')
594
+ const start = await revision()
595
+ await read(() => {
596
+ const select = product.deep('figure-modeller-page >>> figure-properties >>> select').find(s => s.previousElementSibling?.textContent.trim() === 'figure.label.figure-placement')
597
+ select.value = 'wall'
598
+ select.dispatchEvent(new Event('change', { bubbles: true }))
599
+ })
600
+ const said = await until(() => product.deep('figure-modeller-page >>> p[fit-error]')[0]?.textContent.trim(), 'the refusal said')
601
+ /* Said from the kernel's details (ADR-0127): the page loads no translations, so the words are the key with its values. */
602
+ assert.match(said, /^figure\.text\./, said)
603
+ assert.ok(said.includes('post') && said.includes('back'), `names the part and the face the wall takes: ${said}`)
604
+ assert.equal(await revision(), start, 'nothing written')
605
+ assert.equal(await placement(), 'floor', 'the list says the placement the figure has')
606
+ })
607
+ })
608
+
569
609
  describe('AI proposals reaching the page', () => {
570
610
  before(async () => {
571
611
  await fresh()
@@ -593,7 +633,7 @@ describe('AI proposals reaching the page', () => {
593
633
  test('a proposal that would stop a clip is refused whole, the clip named', async () => {
594
634
  const before = await read(() => product.view())
595
635
  const refused = await offer({ steps: [{ kind: 'remove-part', target: 'leaf' }], create: false, context: before.context, revision: before.revision })
596
- assert.deepEqual({ applied: refused.applied, code: refused.code, clips: refused.clips }, { applied: false, code: 'CLIPS_DROPPED', clips: ['swing'] })
636
+ assert.deepEqual({ applied: refused.applied, code: refused.code, clips: refused.clips?.map(c => c.clip) }, { applied: false, code: 'CLIPS_DROPPED', clips: ['swing'] })
597
637
  const after = await read(() => product.view())
598
638
  assert.deepEqual({ source: after.source, revision: after.revision }, { source: before.source, revision: before.revision })
599
639
  })
@@ -0,0 +1,81 @@
1
+ /*
2
+ * A row's preview follows the row as it is set: a pitch refused, then 개수를 정함 chosen -- the pitch's refusal goes at
3
+ * once, not after the count is worked out (on :3300, 2026-10-11, it stayed under the count, as if the count were
4
+ * refused), and the count's own result is said.
5
+ *
6
+ * Run: node --test test/browser/row-preview.check.mjs
7
+ */
8
+ import { test, describe, before, after } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
12
+
13
+ import { buildPage } from './build.mjs'
14
+ import { openPage } from './serve.mjs'
15
+
16
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
17
+
18
+ let opened
19
+ before(async () => {
20
+ opened = await openPage(await buildPage('places-mesh-page.js'))
21
+ })
22
+ after(async () => {
23
+ await opened?.close()
24
+ })
25
+
26
+ const page = () => opened.page
27
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
28
+ async function until(fn, args, what, ms = 8000) {
29
+ const deadline = Date.now() + ms
30
+ for (;;) {
31
+ if (await read(fn, ...args)) return
32
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
33
+ await sleep(50)
34
+ }
35
+ }
36
+ const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
37
+ const handle = (selector, options = {}) => page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
38
+ async function click(selector, options = {}, what = selector) {
39
+ const element = await handle(selector, options)
40
+ assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
41
+ await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
42
+ await element.click()
43
+ await settled()
44
+ }
45
+ /** What the row's preview says now, line by line. */
46
+ const said = () => read(() => [...(findDeep('section[row]')?.querySelectorAll('[preview-words], [preview-words] li') ?? [])].map(e => e.textContent.trim()))
47
+
48
+ /* A 400 mm board, a 3 mm bin fastened on it: a row of it along X. */
49
+ function board() {
50
+ let a = addFigureBox(createFigureAsset('Board'), { id: 'board', size: { x: 400, y: 50, z: 1000 }, position: { x: 0, y: 25, z: 0 } })
51
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'board', face: 'bottom', to: { plane: 'mounting-plane' } })
52
+ a = addFigureBox(a, { id: 'bin', size: { x: 3, y: 100, z: 100 }, position: { x: 0, y: 100, z: 0 } })
53
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'bin', face: 'bottom', to: { part: 'board', face: 'top' } })
54
+ }
55
+
56
+ describe('a row\'s preview follows its basis', () => {
57
+ before(async () => {
58
+ await read(text => harness.mount('given', text), serializeFigureAsset(board()))
59
+ await until(() => look.drawn().length > 0, [], 'the board is drawn')
60
+ const at = await read(() => probe.row('bin'))
61
+ await page().mouse.click(at.x, at.y)
62
+ await until(() => probe.state().selected === 'bin', [], 'bin selected')
63
+ await click('details[row-fold] > summary', {}, 'the row\'s fold')
64
+ })
65
+
66
+ test('a 1 mm pitch: refused, said under the row', async () => {
67
+ const pitch = await handle('section[row] input[type=number]')
68
+ await pitch.evaluate(i => i.scrollIntoView({ block: 'center' }))
69
+ await pitch.click({ clickCount: 3 })
70
+ await page().keyboard.type('1')
71
+ await page().keyboard.press('Tab')
72
+ await until(() => [...(findDeep('section[row]')?.querySelectorAll('[preview-words]') ?? [])].some(e => e.textContent.includes('figure.text.preview-refused')), [], 'the pitch refused')
73
+ })
74
+
75
+ test('개수를 정함 chosen: the pitch\'s refusal is gone at once, and the count is said', async () => {
76
+ await click('section[row] input[type=radio]', { index: 1 }, 'by count')
77
+ /* Read before the count is worked out (it settles 150 ms after it is chosen). */
78
+ assert.ok(!(await said()).some(line => line.includes('preview-refused')), `not the pitch's: ${JSON.stringify(await said())}`)
79
+ await until(() => [...(findDeep('section[row]')?.querySelectorAll('[preview-words] li') ?? [])].some(e => e.textContent.includes('figure.text.row-count-pitch')), [], 'the count said')
80
+ })
81
+ })
@@ -287,6 +287,39 @@ describe('the frame: parts · canvas · one side of six tabs, and the bar over t
287
287
  })
288
288
  })
289
289
 
290
+ describe('the editor is drawn in the shell\'s tokens (2026-09-27)', () => {
291
+ /* Set apart from any theme, so a colour read back can only have come from the token named. */
292
+ const TOKENS = { '--md-sys-color-secondary-container': 'rgb(1, 2, 3)', '--md-sys-color-on-secondary-container': 'rgb(4, 5, 6)', '--md-sys-color-primary': 'rgb(7, 8, 9)' }
293
+ before(async () => {
294
+ await mount('box')
295
+ await read(tokens => Object.entries(tokens).forEach(([name, value]) => screenCheck.editor().style.setProperty(name, value)), TOKENS)
296
+ })
297
+ after(() => read(tokens => Object.keys(tokens).forEach(name => screenCheck.editor().style.removeProperty(name)), TOKENS))
298
+
299
+ test('a pressed button is drawn in the container the shell matched for both themes', async () => {
300
+ await pressRow('box')
301
+ await press('the move tool', () => screenCheck.editor().shadowRoot.querySelector('button[tool][aria-label="figure.button.gizmo-translate"]'))
302
+ await until('the move tool pressed', () => screenCheck.editor().shadowRoot.querySelector('button[tool][aria-pressed="true"]'))
303
+ /* Off the button: under the pointer it is drawn as hovered. */
304
+ await page().mouse.move(0, 0)
305
+ const drawn = await read(() => {
306
+ const root = screenCheck.editor().shadowRoot
307
+ const tool = getComputedStyle(root.querySelector('button[tool][aria-pressed="true"]'))
308
+ const mode = getComputedStyle(root.querySelector('button[mode][aria-pressed="true"]'))
309
+ return { tool: { background: tool.backgroundColor, color: tool.color }, mode: { color: mode.color, border: mode.borderTopColor } }
310
+ })
311
+ assert.deepEqual(drawn, { tool: { background: 'rgb(1, 2, 3)', color: 'rgb(4, 5, 6)' }, mode: { color: 'rgb(4, 5, 6)', border: 'rgb(7, 8, 9)' } })
312
+ })
313
+
314
+ test('the side draws no edge of its own and stacks over nothing: the frame draws the line between panes', async () => {
315
+ const aside = await read(() => {
316
+ const style = getComputedStyle(screenCheck.editor().shadowRoot.querySelector('aside'))
317
+ return { zIndex: style.zIndex, borderLeft: style.borderLeftWidth }
318
+ })
319
+ assert.deepEqual(aside, { zIndex: 'auto', borderLeft: '0px' })
320
+ })
321
+ })
322
+
290
323
  describe('a part chosen: each tab in its place, the figure-wide tabs give way, the list\'s root goes back', () => {
291
324
  before(() => mount('arm'))
292
325
 
@@ -648,7 +681,8 @@ describe('the preview: registered as a board registers it, posed and played, the
648
681
  await mode('preview')
649
682
  await until('the preview stands', () => !!screenCheck.deep('figure-preview')[0]?.shadowRoot?.querySelector('div[clips]'))
650
683
  const type = await read(() => screenCheck.previewTypeOf(screenCheck.editor().session.read().context))
651
- assert.equal(await read(type => screenCheck.registered(type), type), true, `registered as ${type}`)
684
+ /* The preview made earlier stands hidden and is shown again first: registered once it has the figure as it is now. */
685
+ await until(`registered as ${type}`, type => screenCheck.registered(type), type)
652
686
  assert.equal(await read(() => screenCheck.registered('figure-preview-new') && screenCheck.editor().session.read().context !== ''), false, 'not under another figure\'s type')
653
687
  await boardsUp()
654
688
  await read(() => {
@@ -10,6 +10,7 @@ import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart,
10
10
 
11
11
  import { createFigureProductSession } from '../client/modeller/product-session.js'
12
12
  import { runFigureProposal, takeFigureOffer, type FigureApplyDetail } from '../client/modeller/ai-proposal.js'
13
+ import { clipLossOf } from '../client/modeller/refusals.js'
13
14
 
14
15
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
15
16
  const ko = JSON.parse(read('../translations/ko.json'))
@@ -45,7 +46,7 @@ test('a proposal that would stop a clip is refused whole: nothing written, nothi
45
46
  const out = await runFigureProposal({ steps, context: context!, revision: revision! }, pageOf(s), say)
46
47
  assert.equal(out.verdict, 'refused')
47
48
  assert.equal(out.errorCode, 'CLIPS_DROPPED')
48
- assert.match(out.note!, /swing/)
49
+ assert.equal(out.note, say('figure.text.proposal-would-drop-clips', { clips: 'swing' }), 'said as the clips it would stop, not the kernel\'s English')
49
50
  assert.equal(s.source(), before, 'not even the rename before it: refused whole')
50
51
  assert.equal(s.read().revision, revision)
51
52
  assert.equal(s.read().dirty, false, 'nothing to save')
@@ -93,6 +94,8 @@ test('an AI proposal made while a person\'s consent is held is refused; clips, f
93
94
  s.withConsent({ dropClips: ['swing/open'], context: context!, revision: revision! }, () => takeFigureOffer(s, detail, { onSavedFigure: false, say }))
94
95
  assert.equal(detail.applied, false)
95
96
  assert.equal(detail.code, 'CLIPS_DROPPED')
97
+ /* Recorded as figure-model refused it, and read where every refusal is read. */
98
+ assert.deepEqual(clipLossOf(detail)?.map(c => `${c.clip} ${c.why}`), ['swing gone'])
96
99
  assert.equal(s.source(), before)
97
100
  assert.equal(s.read().revision, revision)
98
101
  assert.equal(s.read().dirty, false)
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'
10
10
  import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, parseFigureAsset, serializeFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
11
11
 
12
12
  import { createFigureProductSession } from '../client/modeller/product-session.js'
13
+ import { clipLossOf } from '../client/modeller/refusals.js'
13
14
 
14
15
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
15
16
 
@@ -37,9 +38,15 @@ test('refused and named first; agreed, exactly those go in one step; undo brings
37
38
  const s = createFigureProductSession(store)
38
39
  await s.open('doors')
39
40
  const revision = s.read().revision
40
- assert.throws(() => s.command(fixed(s, 'left')), (e: { code?: string; clips?: Array<{ id: string; clip: string; why: string }> }) =>
41
- e.code === 'CLIPS_DROPPED' && e.clips?.map(c => `${c.clip} ${c.why}`).join() === 'left-swing gone')
41
+ assert.throws(() => s.command(fixed(s, 'left')), (e: { code?: string; details?: { reason?: string; clips?: Array<{ id: string; clip: string; why: string }> } }) =>
42
+ e.code === 'CLIPS_DROPPED' && e.details?.reason === 'clips-dropped' && e.details.clips?.map(c => `${c.clip} ${c.why}`).join() === 'left-swing gone')
42
43
  assert.equal(s.read().revision, revision, 'nothing written')
44
+ /* The screen reads the refusal in one place: the clips to ask about, and nothing for any other refusal. */
45
+ const refusalOf = (run: () => unknown) => { try { run() } catch (error) { return error } }
46
+ assert.deepEqual(clipLossOf(refusalOf(() => s.command(fixed(s, 'left'))))?.map(c => `${c.clip} ${c.why}`), ['left-swing gone'])
47
+ const other = refusalOf(() => s.edit({ kind: 'remove-part', target: 'nowhere' }))
48
+ assert.ok((other as { code?: string })?.code, 'another refusal')
49
+ assert.equal(clipLossOf(other), null)
43
50
  assert.deepEqual(clipsOf(s), ['left-swing', 'right-swing'])
44
51
  const ids = ['left-swing/left-open']
45
52
  const at = () => ({ context: s.read().context!, revision: s.read().revision! })
@@ -76,12 +76,8 @@ test('pitch 는 한 곳에서 하나의 값이고, 늘 set-repeat 로 바꿔 최
76
76
  }
77
77
  })
78
78
 
79
- test('기즈모로 끈, 규칙이 자리를 정하는 부품은 규칙을 지킨 채 옮겨지고, 관절·붙이기가 정하는 부품은 까닭을 말한다 — 튕겨 돌아가지 않는다', () => {
80
- const editor = readFileSync(fileURLToPath(new URL('../client/modeller/editor.ts', import.meta.url)), 'utf8')
81
- const moved = editor.slice(editor.indexOf(' private transformed('), editor.indexOf(' /** 맞추기'))
82
- assert.ok(moved.includes("kind: 'nudge-part',"), '사용자 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」')
83
- assert.ok(moved.includes('...(Object.keys(grow).length ? { grow } : {})'), '크기 끌기도 배율로 — 「되는 듯하다가 다시 원래 크기로 돌아간다」')
84
- /* Which of the two places it is said from the refusal's details (refusals.test.ts makes both with the kernel, ADR-0127). */
85
- assert.ok(moved.includes('placedRefusalOf(error, partLabelOf(id)'), moved)
86
- })
87
-
79
+ /*
80
+ * A gizmo drag on a part a rule places is written as the rule's nudge, growing by a ratio on a scale drag
81
+ * (modeller.check.mjs, the gizmo on a part placed by worked-out rules); one a joint places is refused in words from the
82
+ * kernel's details (editing.check.mjs).
83
+ */
@@ -0,0 +1,32 @@
1
+ /*
2
+ * 정렬·간격 고치기 (fasten-geometry `insideAlignmentOf`): the alignment that brings a part inside the face it is fastened
3
+ * on -- the least move on each side it is out, the centre when it is wider -- or why there is nothing to move.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ import { insideAlignmentOf, type FaceView } from '../client/modeller/fasten-geometry.js'
9
+
10
+ /* A 400 × 1000 top, a 100 × 100 footprint on it; edges offered on X, a distance only on Z. */
11
+ const view = (part = { x: 100, y: 100, z: 100 }): FaceView => ({ axes: ['x', 'z'], face: { x: 400, z: 1000 }, part, edges: { x: true, y: true, z: false } })
12
+
13
+ test('out past the low edge: moved to that edge from inside, the other axis left as it was', () => {
14
+ const fixed = insideAlignmentOf(view(), { x: 'mm', z: 'centre' }, { x: -1000 })
15
+ assert.deepEqual(fixed, { align: { x: 'min', z: 'centre' }, alignMm: { x: -1000 }, moved: ['x'] })
16
+ })
17
+
18
+ test('out past the high edge where edges are not offered: that edge as a distance', () => {
19
+ const fixed = insideAlignmentOf(view(), { x: 'centre', z: 'mm' }, { z: 900 })
20
+ assert.deepEqual(fixed, { align: { x: 'centre', z: 'mm' }, alignMm: { z: 450 }, moved: ['z'] })
21
+ })
22
+
23
+ test('wider than the face: centred, the most of it on the face', () => {
24
+ const fixed = insideAlignmentOf(view({ x: 600, y: 100, z: 100 }), { x: 'max' }, {})
25
+ assert.deepEqual(fixed, { align: { x: 'centre' }, alignMm: {}, moved: ['x'] })
26
+ })
27
+
28
+ test('nothing of it out: inside -- what is out is fastened on it; no face size: unmeasured', () => {
29
+ assert.deepEqual(insideAlignmentOf(view(), { x: 'max', z: 'centre' }, {}), { cannot: 'inside' })
30
+ assert.deepEqual(insideAlignmentOf(undefined, {}, {}), { cannot: 'unmeasured' })
31
+ assert.deepEqual(insideAlignmentOf({ ...view(), face: { x: 400 } }, {}, {}), { cannot: 'unmeasured' })
32
+ })
@@ -16,7 +16,9 @@ const renderBody = editor.slice(editor.indexOf(' render() {'), editor.indexOf('
16
16
  test('띠 — 칸 접기 · 되돌리기 · 전체 보기 · 저장 상태 · 저장, 말은 번역에서', () => {
17
17
  /* The bar itself -- its folds, undo · redo · fit, the status and save -- is the browser check's (test/browser/screen.check.mjs). */
18
18
  assert.doesNotMatch(renderBody, /[가-힣]/, '편집기의 화면 말에 한국어가 박혀 있지 않다')
19
- const keys = [...editor.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
19
+ /* The editor's words, and those of the parts it hands its work to. */
20
+ const said = [editor, ...['pane-fold', 'refusal-teller', 'command-preview-controller'].map(name => read(`../client/modeller/${name}.ts`))].join('\n')
21
+ const keys = [...said.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
20
22
  for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
21
23
  const table = JSON.parse(read(`../translations/${lang}.json`))
22
24
  for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
@@ -8,7 +8,7 @@ import assert from 'node:assert/strict'
8
8
  import { readFileSync } from 'node:fs'
9
9
  import { fileURLToPath } from 'node:url'
10
10
 
11
- import { meshChoicesOf, meshPartIdOf, meshPartOf, type MeshUpload } from '../client/modeller/mesh-import.js'
11
+ import { meshChoicesOf, meshPartOf, type MeshUpload } from '../client/modeller/mesh-import.js'
12
12
 
13
13
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
14
14
  const HASH = 'c'.repeat(64)
@@ -59,13 +59,6 @@ test('a node: its own bounds and counts, standing and turned where the file had
59
59
  assert.throws(() => meshPartOf(upload, { node: 3 }, 'x'), 'a node that cannot be a part is not made one')
60
60
  })
61
61
 
62
- test("a part's id from a node's name: letter-led and clean, another number when taken", () => {
63
- assert.equal(meshPartIdOf('Hoist Unit #2', new Set()), 'Hoist-Unit-2')
64
- assert.equal(meshPartIdOf('2nd beam', new Set()), 'nd-beam')
65
- assert.equal(meshPartIdOf('###', new Set()), 'mesh')
66
- assert.equal(meshPartIdOf('hoist', new Set(['hoist', 'hoist-2'])), 'hoist-3')
67
- })
68
-
69
62
  /* The screen itself -- the palette's button, the upload, the question, the step added -- is driven in browser/places-mesh.check.mjs. */
70
63
  test('the mesh words are in every language', () => {
71
64
  for (const lang of ['en', 'es', 'ja', 'ko', 'ms', 'zh']) {
@@ -11,7 +11,6 @@ import { readFileSync } from 'node:fs'
11
11
  import { fileURLToPath } from 'node:url'
12
12
 
13
13
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
14
- const editor = read('../client/modeller/editor.ts')
15
14
 
16
15
  /*
17
16
  * Each event the panel sends answered by the editor -- picked, moved, duplicated, removed, added, hidden -- as one step
@@ -37,10 +36,7 @@ test('팔레트는 시작 모양만 낸다 — 둥근 상자와 통은 상자의
37
36
  })
38
37
 
39
38
  test('부품 이름은 figure-model 의 rename-part 로 바꾸고, 거절은 까닭별로 사람의 말로 한다', () => {
40
- assert.match(editor, /this\.session!\.edit\(\{ kind: 'rename-part', target: from, name \}\)/)
41
- assert.match(editor, /this\.selected = name/, '바꾼 이름의 부품이 그대로 골라져 있다')
42
- /* The reason is the kernel's refusal (ADR-0127): the screen does not check the name again (refusals.test.ts makes all three). */
43
- assert.match(editor, /this\.error = partNameRefusalOf\(error, /)
39
+ /* Renamed in one step and still selected, and a refused name said from the kernel's details: parts.check.mjs. */
44
40
  for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
45
41
  const table = JSON.parse(read(`../translations/${lang}.json`))
46
42
  for (const key of ['figure.text.part-name-taken', 'figure.text.part-name-invalid']) assert.ok(table[key]?.includes('{name}'), `${lang}: ${key}`)
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
13
13
  import * as fm from '@hatiolab/figure-model'
14
14
  import type { FigureAsset } from '@hatiolab/figure-model'
15
15
  import {
16
+ clipLossOf,
16
17
  editRefusalOf,
17
18
  inDeclaredRoom,
18
19
  inputRangeRefusalOf,
@@ -22,6 +23,7 @@ import {
22
23
  placedRefusalOf,
23
24
  placementRefusalOf,
24
25
  recipeRefusalOf,
26
+ refusalRecordOf,
25
27
  removalNoticeOf,
26
28
  repeatPitchRefusalOf,
27
29
  sizeRangeRefusalOf
@@ -133,11 +135,7 @@ test('공간 선언에 든 부품 — 한 번 눌러 지우고, 공간은 남은
133
135
  const after = fm.editFigurePart(a, { kind: 'remove-part', target: 'stray', refitRoom: true })
134
136
  assert.equal(after.document.model.nodes.some(n => n.id === 'stray'), false)
135
137
  assert.ok(after.occupancy?.bounds, '공간은 남은 부품으로 다시 선언된다')
136
- const editor = readFileSync(fileURLToPath(new URL('../client/modeller/editor.ts', import.meta.url)), 'utf8')
137
- const remove = editor.slice(editor.indexOf(' private removePart('), editor.indexOf(' private toggleHiddenPart('))
138
- assert.match(remove, /if \(!refitRoom && inDeclaredRoom\(error\)\) \{\s*this\.removePart\(id, true\)/, '거절 없이 곧바로 다시 맞춰 지운다')
139
- assert.match(remove, /figure\.text\.removal-space-refitted/, '그렇다고 한 줄')
140
- assert.doesNotMatch(remove, /this\.retry = \{/, '묻거나 권하지 않는다')
138
+ /* Removed in one click, nothing asked, the space fitted to the parts left and said so: parts.check.mjs. */
141
139
  assert.ok(ko['figure.text.removal-space-refitted'])
142
140
  })
143
141
 
@@ -153,11 +151,7 @@ test('배치를 바꿀 수 없는 까닭 — 평면에 붙은 부품과 새 평
153
151
  assert.ok(said.includes('post (bottom)') && said.includes('벽 기반') && said.includes('back'), said)
154
152
  assert.equal(editRefusalOf(refused, say), said, '편집 거절의 말도 같다')
155
153
  assert.equal(placementRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), say), null)
156
- /* The placement list sends its command through the page, not the editor: the page says it in words too, and the list goes back. */
157
- const page = readFileSync(fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url)), 'utf8')
158
- assert.ok(page.includes('editRefusalOf(error, (key, params) => i18next.t(key, params))'), '페이지 경로도 사람의 말로')
159
- const props = readFileSync(fileURLToPath(new URL('../client/modeller/figure-properties.ts', import.meta.url)), 'utf8')
160
- assert.ok(props.includes('select.value = current'), '거절되면 목록이 원래 배치로 돌아온다')
154
+ /* The placement list sends its command through the page: said there in words, and the list back on the placement it has (product-page.check.mjs). */
161
155
  })
162
156
 
163
157
  test('범위 밖 값은 받을 수 있는 범위를 숫자로 말한다 — 커널의 영어가 아니다 (Figure 레인 :3300, 2026-09-26)', () => {
@@ -210,12 +204,54 @@ test('줄이 한도를 넘으면 몇 개가 되는지와 한도를 말한다 —
210
204
  assert.ok(bodies.includes('19,402') && bodies.includes('10,000'), bodies)
211
205
  })
212
206
 
207
+ test('일상에서 코드로만 보이던 거절 넷 — 줄이 안 들어감 · 같은 축의 줄 안의 줄 · 기준면이 걸린 부품의 줄 · 쓰인 부품 id (:3300, 2026-10-11)', () => {
208
+ const shelf = () => {
209
+ let a = fm.addFigureBox(fm.createFigureAsset('Shelf'), { id: 'board', size: { x: 400, y: 50, z: 1000 }, position: { x: 0, y: 0, z: 0 } })
210
+ a = fm.addFigureBox(a, { id: 'bin', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 0, z: 0 } })
211
+ a = act(a, { kind: 'attach', part: 'board', face: 'bottom', to: { plane: 'mounting-plane' } })
212
+ return act(a, { kind: 'attach', part: 'bin', face: 'bottom', to: { part: 'board', face: 'top' } })
213
+ }
214
+ /* Five 100 mm bins asked for on 400 mm. */
215
+ const fit = editRefusalOf(refusalOf(() => act(shelf(), { kind: 'make-repeat', part: 'bin', axis: 'x', pitch: 1, count: 5 })), keyed)
216
+ assert.match(fit, /row-does-not-fit .*"count":"5".*"length":"400".*"extent":"100"/)
217
+ /* A cap on the roller made a row along X, as the roller's row is. */
218
+ let capped = fm.addFigureBox(conveyor(), { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 0, z: 0 } })
219
+ capped = act(capped, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
220
+ assert.match(editRefusalOf(refusalOf(() => act(capped, { kind: 'make-repeat', part: 'cap', axis: 'x', pitch: 10 })), keyed), /row-same-axis .*"part":"cap","outer":"roller","axis":"X"/)
221
+ /* The bin's top holds the slot layout: made a row, the layout would stand on no one bin. */
222
+ const slotted = act(shelf(), {
223
+ kind: 'set-slot-layout',
224
+ label: 'Bin',
225
+ role: 'seat',
226
+ anchors: [{ id: 'bin', part: 'bin', face: 'top' }],
227
+ layout: { tiers: [{ tierNo: 1, anchorId: 'bin', mount: 'ON', position: { x: 0, y: 0, z: 0 }, rotation: 0, layout: { pattern: 'SINGLE', slot: { allowed: { width: 100, depth: 100, height: 100 }, accepts: ['TOTE'] } } }] }
228
+ })
229
+ const held = editRefusalOf(refusalOf(() => act(slotted, { kind: 'make-repeat', part: 'bin', axis: 'x', pitch: 150 })), say)
230
+ assert.ok(held.includes('bin') && held.includes('자리 배치') && !held.includes('SURFACE_PROVENANCE'), held)
231
+ /* A new part named as the row's part is (its copies drawn under ids of their own): the same words as a rename. */
232
+ assert.match(editRefusalOf(refusalOf(() => fm.addFigureBox(conveyor(), { id: 'roller', size: { x: 1, y: 1, z: 1 }, position: { x: 0, y: 0, z: 0 } })), keyed), /part-name-taken .*"name":"roller"/)
233
+ })
234
+
213
235
  test('기즈모로 옮길 수 없는 부품 — 관절이 자리를 정하는지, 붙은 면이 정하는지', () => {
214
236
  assert.match(placedRefusalOf(refusalOf(() => act(conveyor(), { kind: 'nudge-part', part: 'roller', by: { x: 10 } })), '롤러', keyed)!, /gizmo-joint-places .*"part":"롤러"/)
215
237
  assert.match(placedRefusalOf(refusalOf(() => act(conveyor(), { kind: 'scale-position', part: 'roller', axis: 'x' })), '롤러', keyed)!, /gizmo-fastening-places .*"part":"롤러"/)
216
238
  assert.equal(placedRefusalOf(refusalOf(() => act(conveyor(), { kind: 'set-repeat', part: 'frame', pitch: 10 })), '프레임', keyed), null, '다른 EDIT_TARGET 은 이 말이 아니다')
217
239
  })
218
240
 
241
+ test('옮겨 적은 거절 — 기록은 커널의 거절과 같은 모양이라 같은 말로 읽힌다 (ADR-0127)', () => {
242
+ const a = act(fm.addFigureBox(fm.createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 0, z: 0 } }), {
243
+ kind: 'attach',
244
+ part: 'post',
245
+ face: 'bottom',
246
+ to: { plane: 'mounting-plane' }
247
+ })
248
+ const refused = refusalOf(() => act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'wall' }))
249
+ const record = refusalRecordOf(refused)
250
+ assert.ok(record.details, 'its details kept')
251
+ assert.equal(editRefusalOf(record, say), editRefusalOf(refused, say))
252
+ assert.equal(clipLossOf(record), null, 'no clips to ask about')
253
+ })
254
+
219
255
  test('부품 이름 — 이름 규칙은 커널 한 벌이고, 화면은 거절의 값으로 말한다 (ADR-0127)', () => {
220
256
  const rename = (asset: FigureAsset, target: string, name: string) => refusalOf(() => fm.editFigurePart(asset, { kind: 'rename-part', target, name } as never))
221
257
  assert.match(partNameRefusalOf(rename(conveyor(), 'frame', '1x'), keyed)!, /part-name-invalid .*"name":"1x"/)