@things-factory/figure-ui 10.1.145 → 10.1.147

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 (40) hide show
  1. package/client/modeller/refusals.ts +16 -7
  2. package/dist-client/modeller/refusals.js +18 -8
  3. package/dist-client/modeller/refusals.js.map +1 -1
  4. package/dist-client/tsconfig.tsbuildinfo +1 -1
  5. package/package.json +4 -4
  6. package/test/align.test.ts +2 -2
  7. package/test/browser/modeller-page.js +3 -3
  8. package/test/browser/modeller.check.mjs +93 -51
  9. package/test/cavity-drawing.test.ts +1 -1
  10. package/test/clip-consent.test.ts +1 -1
  11. package/test/clips.test.ts +1 -1
  12. package/test/command-preview.test.ts +5 -5
  13. package/test/controls.test.ts +1 -1
  14. package/test/fasten-drawing.test.ts +1 -1
  15. package/test/fields.test.ts +1 -1
  16. package/test/figure-assistant-surface.test.ts +1 -1
  17. package/test/first-minute.test.ts +1 -1
  18. package/test/first-save-suggest.test.ts +1 -1
  19. package/test/gizmo.test.ts +1 -1
  20. package/test/layout.test.ts +2 -2
  21. package/test/links.test.ts +1 -1
  22. package/test/maturity.test.ts +1 -1
  23. package/test/parts-wiring.test.ts +3 -3
  24. package/test/properties.test.ts +1 -1
  25. package/test/proposal-record.test.ts +1 -1
  26. package/test/refusals.test.ts +40 -0
  27. package/test/release-entry.test.ts +1 -1
  28. package/test/revert-choice.test.ts +1 -1
  29. package/test/revert-keeping.test.ts +1 -1
  30. package/test/revision.test.ts +7 -4
  31. package/test/save-landed.test.ts +2 -2
  32. package/test/section-editor.test.ts +3 -3
  33. package/test/section-panel.test.ts +1 -1
  34. package/test/verdict.test.ts +1 -1
  35. package/translations/en.json +4 -1
  36. package/translations/es.json +991 -0
  37. package/translations/ja.json +3 -0
  38. package/translations/ko.json +3 -0
  39. package/translations/ms.json +3 -0
  40. package/translations/zh.json +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.145",
3
+ "version": "10.1.147",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -40,9 +40,9 @@
40
40
  "@operato/shell": "^10.0.0",
41
41
  "@operato/styles": "^10.0.0",
42
42
  "@operato/utils": "^10.0.0",
43
- "@things-factory/ai-assistant": "^10.1.145",
44
- "@things-factory/figure-service": "^10.1.145",
43
+ "@things-factory/ai-assistant": "^10.1.147",
44
+ "@things-factory/figure-service": "^10.1.147",
45
45
  "three": "^0.185.1"
46
46
  },
47
- "gitHead": "1c33612ba31f51b9af59f9bc68751971097350a5"
47
+ "gitHead": "df93dddb6ac19a27c32fad21e29ca0ce02d08eb6"
48
48
  }
@@ -91,7 +91,7 @@ test('a tilted part is told it touches by a point or an edge, a ball by its lowe
91
91
  const editor = read('../client/modeller/editor.ts')
92
92
  assert.ok(editor.includes("const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''"), 'the tooltip follows the contact')
93
93
  assert.ok(editor.includes("this.notice = i18next.t(`figure.text.aligned-${contact}-${asset.placement ?? 'floor'}`)"), 'and so does the word after')
94
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
94
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
95
95
  const t = JSON.parse(read(`../translations/${lang}.json`))
96
96
  for (const placement of ['floor', 'ceiling', 'wall'])
97
97
  for (const contact of ['tilted', 'extreme'])
@@ -106,7 +106,7 @@ test('a tilted part is told it touches by a point or an edge, a ball by its lowe
106
106
  test('the size gizmo\'s reason names what the placement keeps, in every language', () => {
107
107
  const editor = read('../client/modeller/editor.ts')
108
108
  assert.equal(editor.match(/'figure\.text\.gizmo-size-turned-fastening'[^\n]*extreme: this\.keptExtremeWord\(\)/g)?.length, 3, 'every place it is said')
109
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
109
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
110
110
  const t = JSON.parse(read(`../translations/${lang}.json`))
111
111
  assert.ok(t['figure.text.gizmo-size-turned-fastening'].includes('{extreme}'), lang)
112
112
  for (const placement of ['floor', 'ceiling', 'wall', 'center']) assert.ok(t[`figure.label.kept-extreme-${placement}`], `${lang} ${placement}`)
@@ -172,9 +172,9 @@ window.probe = {
172
172
  ids() {
173
173
  return editor().session.read().geometry.map(g => g.id)
174
174
  },
175
- /** The value of the selects that offer this option, as they stand. */
176
- selectsOffering(value) {
177
- return deep('select').filter(s => [...s.options].some(o => o.value === value)).map(s => s.value)
175
+ /** The way the fastening drawing shows the part fastened: meet, flush or centre. */
176
+ fastenMode() {
177
+ return deep('figure-fasten-drawing')[0]?.mode ?? null
178
178
  },
179
179
  /** The page point of an input by its aria-label, or of the part name field. */
180
180
  nameField() {
@@ -133,9 +133,9 @@ describe('the gizmo on a part the modeller made (own numbers)', () => {
133
133
  const after = await state()
134
134
  assert.equal(after.revision, start.revision + 1, 'one step of the history')
135
135
  assert.equal(after.selected, 'box')
136
- assert.ok(now.dimensions.width > was.dimensions.width * 1.2, `grew: ${was.dimensions.width} → ${now.dimensions.width}`)
137
- assert.equal(now.dimensions.height, was.dimensions.height)
138
- assert.equal(now.dimensions.depth, was.dimensions.depth)
136
+ assert.ok(now.dimensions['size.x'] > was.dimensions['size.x'] * 1.2, `grew: ${was.dimensions['size.x']} → ${now.dimensions['size.x']}`)
137
+ assert.equal(now.dimensions.length, was.dimensions.length)
138
+ assert.equal(now.dimensions['size.z'], was.dimensions['size.z'])
139
139
  })
140
140
 
141
141
  test('rotate: one step, the part turns about the handle axis and keeps the turn', async () => {
@@ -182,7 +182,7 @@ describe('the gizmo on a part placed by worked-out rules', () => {
182
182
  assert.equal(after.error, '', 'not refused')
183
183
  assert.equal(after.revision, start.revision + 1, 'one step of the history')
184
184
  assert.equal(after.selected, 'pedestal')
185
- assert.ok(now.dimensions.radius > was.dimensions.radius * 1.1, `wider: ${was.dimensions.radius} → ${now.dimensions.radius}`)
185
+ assert.ok(now.dimensions['radius.x'] > was.dimensions['radius.x'] * 1.1, `wider: ${was.dimensions['radius.x']} → ${now.dimensions['radius.x']}`)
186
186
  assert.equal(now.dimensions.length, was.dimensions.length)
187
187
  })
188
188
 
@@ -219,7 +219,7 @@ describe('the gizmo on a part placed by worked-out rules', () => {
219
219
  describe('a pose slider dragged with the mouse (user 2026-09-24: the arm jumped on release)', () => {
220
220
  before(async () => {
221
221
  await mount('six-axis')
222
- await click(await read(() => probe.tab('figure.label.motion')))
222
+ await click(await read(() => probe.tab('figure.label.tab-short-motion')))
223
223
  })
224
224
 
225
225
  /** Presses a slider's thumb, moves it `units`, reads the canvas while still held, then releases. */
@@ -328,33 +328,50 @@ describe('the fastening form belongs to the part it is for', () => {
328
328
  })
329
329
 
330
330
  /* The select whose label (the i18n key: the check loads no translations) is `label`, in the parts' links form. */
331
- const alignSelect = label =>
331
+ const select = label =>
332
332
  page().evaluateHandle(label => {
333
333
  const deep = (root = document) => [...root.querySelectorAll('select')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
334
334
  return deep().find(s => s.previousElementSibling?.textContent?.trim() === label) ?? null
335
335
  }, label)
336
+ /* Where the part sits across the target face, as the face drawing holds it (the drawing sets it since 2026-10-02). */
337
+ const placing = () =>
338
+ read(() => {
339
+ const deep = (root = document) => [...root.querySelectorAll('figure-fasten-drawing')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
340
+ const drawing = deep()[0]
341
+ return drawing ? { target: drawing.target, x: drawing.align.x ?? 'centre', mm: drawing.alignMm.x ?? 0 } : null
342
+ })
336
343
 
337
344
  test('a choice made for one part is not carried to the next part picked; the next opens on its own fastening (셔터 커튼 2026-09-25, walk 1 2026-09-26)', async () => {
338
- /* The placing across the target face is asked once there is a target: the form starts with none. */
339
345
  await click(await read(() => probe.row('forearm')))
340
- const target = await alignSelect('figure.label.target-part')
341
- assert.ok(await target.evaluate(s => !!s), 'the form asks what the forearm is fastened to')
342
- await target.select('elbow')
343
- const select = await alignSelect('figure.label.align-x')
344
- assert.ok(await select.evaluate(s => !!s), 'with a target, the form offers left-right placing for the forearm')
345
- await select.select('max')
346
- assert.equal(await select.evaluate(s => s.value), 'max', 'chosen for the forearm')
346
+ assert.equal(await (await select('figure.label.target-part')).evaluate(s => s?.value), 'elbow', 'the forearm opens on what it is fastened to')
347
+ /* Placed off centre on the elbow's face, as a hand does: the face drawing focused, the arrows. */
348
+ const face = await read(() => {
349
+ const deep = (root = document) => [...root.querySelectorAll('figure-fasten-drawing')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
350
+ const rect = deep()[0]?.shadowRoot.querySelector('div[where] svg > rect')
351
+ if (!rect) return null
352
+ rect.scrollIntoView({ block: 'center' })
353
+ const r = rect.getBoundingClientRect()
354
+ return { x: r.left + 4, y: r.top + 4 }
355
+ })
356
+ await click(face)
357
+ await page().keyboard.down('Shift')
358
+ for (let i = 0; i < 3; i++) await page().keyboard.press('ArrowRight')
359
+ await page().keyboard.up('Shift')
360
+ await sleep(600)
361
+ const chosen = await placing()
362
+ assert.equal(chosen?.x, 'mm', `placed off centre for the forearm: ${JSON.stringify(chosen)}`)
363
+ assert.ok(chosen.mm > 0)
347
364
 
348
365
  await click(await read(() => probe.row('elbow')))
349
366
  /* The elbow opens on its own fastening (walk 1, 2026-09-26), not on the forearm's choices. */
350
367
  const own = await read(() => {
351
368
  const joint = JSON.parse(document.querySelector('figure-editor').session.source()).joints.find(j => j.body1.part === 'elbow')
352
- return { target: joint.body0.part, x: joint.origin?.align?.x ?? 'centre' }
369
+ return { target: joint.body0.part, x: Object.keys(joint.origin?.align?.x ?? {})[0] ?? 'centre' }
353
370
  })
354
- const nextTarget = await alignSelect('figure.label.target-part')
355
- assert.equal(await nextTarget.evaluate(s => s.value), own.target, 'the elbow opens on what it is fastened to')
356
- const next = await alignSelect('figure.label.align-x')
357
- assert.ok(await next.evaluate((s, x) => !s || s.value === (typeof x === 'string' ? x : 'mm') || s.value === 'centre', own.x), 'and on its own placing, not the forearm\'s edge')
371
+ assert.equal(await (await select('figure.label.target-part')).evaluate(s => s?.value), own.target, 'the elbow opens on what it is fastened to')
372
+ const next = await placing()
373
+ assert.equal(next?.target, own.target)
374
+ assert.equal(next.x, own.x, 'and on its own placing, not the forearm\'s')
358
375
  })
359
376
  })
360
377
 
@@ -410,11 +427,22 @@ describe('a figure made in the modeller is given its size from its parts (user 2
410
427
  assert.equal(after.revision, start.revision + 1, 'one step')
411
428
  assert.equal((await declared())['size.x'][1], 3000, 'the largest width is what was typed')
412
429
  await type('size.x.min', '500')
413
- assert.match((await state()).error, /500|400/, 'the figure is 400 wide: a smallest of 500 is refused, and said')
430
+ assert.notEqual((await state()).error, '', 'the figure is 400 wide: a smallest of 500 is refused')
414
431
  assert.equal((await declared())['size.x'][0] < 400, true, 'and the range is as it was')
415
432
  assert.equal(await (await field('size.x.min')).evaluate(i => Number(i.value) < 400), true, 'the field shows the range it still has')
416
433
  })
417
434
 
435
+ /*
436
+ The smallest refused is said with the figure's size (2026-10-11): figure-model refuses it with INSTANCE_SIZE_RANGE
437
+ 「size.x: the authored size 400 mm must lie within 500…4000 mm」, which the screen once showed as the bare
438
+ figure.text.edit-refused. The page loads no translations: the numbers are the unit suite's to check, the sentence's key here.
439
+ */
440
+ test('the smallest refused is said in its own words, with the figure\'s size, not as a bare refusal', async () => {
441
+ const said = (await state()).error
442
+ assert.notEqual(said, '', 'refused')
443
+ assert.notEqual(said, 'figure.text.edit-refused', `said in its own words: ${said}`)
444
+ })
445
+
418
446
  test('a part follows the figure\'s width less a difference: chosen and typed, the width is the size less it', async () => {
419
447
  await click(await read(() => probe.partOnPage('box')))
420
448
  assert.equal((await state()).selected, 'box')
@@ -428,15 +456,19 @@ describe('a figure made in the modeller is given its size from its parts (user 2
428
456
  what,
429
457
  label
430
458
  )
459
+ /* The way to make a side follow is folded under the advanced choice (2026-09-28): opened, as a hand does. */
460
+ await click(await read(() => {
461
+ const deep = (root = document) => [...root.querySelectorAll('details[resize-choice] > summary')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
462
+ const summary = deep()[0]
463
+ if (!summary) return null
464
+ summary.scrollIntoView({ block: 'center' })
465
+ const r = summary.getBoundingClientRect()
466
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
467
+ }))
431
468
  const width = await handle('select', 'X')
432
469
  assert.ok(await width.evaluate(s => !!s), 'the width can follow the figure size')
433
470
  await width.select('instance:x')
434
471
  await sleep(600)
435
- /* Refusals and notices go to the app's notification: heard here as the event it is. */
436
- await page().evaluate(() => {
437
- window.__told = []
438
- document.addEventListener('notify', e => window.__told.push(e.detail))
439
- })
440
472
  const plus = await handle('input')
441
473
  assert.ok(await plus.evaluate(i => !!i), 'a following width offers its difference')
442
474
  await plus.evaluate(i => i.scrollIntoView({ block: 'center' }))
@@ -446,17 +478,21 @@ describe('a figure made in the modeller is given its size from its parts (user 2
446
478
  await sleep(600)
447
479
  /* The declared sizes start at 1 mm: less 50 the box would be gone below 50, so the link is refused -- with the way on. */
448
480
  assert.match((await state()).error, /figure.text.link-needs-smaller-range/, 'refused, said in words')
449
- const told = await read(() => {
450
- const last = window.__told.at(-1)
451
- return last && { level: last.level, message: last.message, label: last.option?.action?.label }
481
+ /* A refusal with a way on is asked in the app's prompt (OxPrompt): confirmed, the narrowed link is made. */
482
+ const asked = await read(() => {
483
+ const deep = (root = document) => [...root.querySelectorAll('ox-prompt')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
484
+ const prompt = deep().at(-1)
485
+ const confirm = prompt?.shadowRoot?.querySelector('#confirm')
486
+ if (!confirm) return null
487
+ const r = confirm.getBoundingClientRect()
488
+ return { text: prompt.text, label: confirm.textContent.trim(), x: r.left + r.width / 2, y: r.top + r.height / 2 }
452
489
  })
453
- assert.equal(told?.level, 'error', 'said in the app\'s notification')
454
- assert.match(told?.message ?? '', /figure.text.link-needs-smaller-range/)
455
- assert.match(told?.label ?? '', /figure.button.narrow-range-from/, 'the same link is offered with the sizes narrowed')
456
- await read(() => window.__told.at(-1).option.action.callback())
490
+ assert.match(asked?.text ?? '', /figure.text.link-needs-smaller-range/, 'asked in the app\'s prompt')
491
+ assert.match(asked.label, /figure.button.narrow-range-from/, 'the same link is offered with the sizes narrowed')
492
+ await click(asked)
457
493
  await sleep(600)
458
494
  assert.equal((await state()).error, '', 'taken')
459
- assert.equal((await body('box')).dimensions.width, 350, 'the figure\'s 400 less 50')
495
+ assert.equal((await body('box')).dimensions['size.x'], 350, 'the figure\'s 400 less 50')
460
496
  })
461
497
 
462
498
  test('grown by one ratio, every part is made to follow it in one step, and the offer goes', async () => {
@@ -503,7 +539,7 @@ describe('a motion is changed where it is (2026-09-25)', () => {
503
539
  })
504
540
 
505
541
  test('the shoulder\'s range is changed from the motion tab: one step, the same joint, its new limit', async () => {
506
- await click(await read(() => probe.tab('figure.label.motion')))
542
+ await click(await read(() => probe.tab('figure.label.tab-short-motion')))
507
543
  const jointOf = part => read(part => (JSON.parse(document.querySelector('figure-editor').session.source()).joints ?? []).find(j => j.body1.part === part) ?? null, part)
508
544
  const was = await jointOf('upper-arm')
509
545
  assert.ok(was, 'the upper arm has its joint')
@@ -582,23 +618,25 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
582
618
  await page().keyboard.press('Enter')
583
619
  await sleep(600)
584
620
  const now = await body('box')
585
- assert.equal(Math.round(now.t[1] - now.dimensions.height / 2), 0, `bottom on the floor: centre ${now.t[1]}, height ${now.dimensions.height}`)
586
- assert.equal(now.dimensions.height, 1000)
621
+ /* The box is drawn up y from its start: its bottom is its centre plus the start. */
622
+ assert.equal(Math.round(now.t[1] + now.dimensions.start), 0, `bottom on the floor: centre ${now.t[1]}, starts at ${now.dimensions.start}`)
623
+ assert.equal(now.dimensions.length, 1000)
587
624
  })
588
625
 
589
626
  test('fastened to the floor, a leaf set off its frame stays where it stood', async () => {
590
627
  await mount('door')
591
628
  await click(await read(() => probe.row('leaf')))
592
629
  const was = await body('leaf')
593
- const box = await page().evaluate(() => {
594
- const deep = (root = document) => [...root.querySelectorAll('label[toggle]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
595
- const label = deep().find(l => l.textContent.includes('figure.label.stands-on-plane'))
596
- label.scrollIntoView({ block: 'center' })
597
- const r = label.querySelector('input').getBoundingClientRect()
598
- return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
630
+ /* The floor is chosen as what the leaf is fastened to, in the fastening form; a new fastening waits for 붙이기. */
631
+ await click(await centre(await find('button', 'figure.button.fasten-start')))
632
+ const target = await page().evaluateHandle(() => {
633
+ const deep = (root = document) => [...root.querySelectorAll('select')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
634
+ return deep().find(s => s.previousElementSibling?.textContent?.trim() === 'figure.label.target-part') ?? null
599
635
  })
600
- await click(box)
601
- const fasten = await find('button', 'figure.button.fasten')
636
+ assert.ok(await target.evaluate(s => !!s), 'the form asks what the leaf is fastened to')
637
+ await target.select('__plane')
638
+ await sleep(500)
639
+ const fasten = await find('button[act]', 'figure.button.fasten')
602
640
  const start = await state()
603
641
  await click(await centre(fasten))
604
642
  const after = await state()
@@ -611,7 +649,7 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
611
649
  test('a part is shown while a control is at least a value: chosen in the motion tab, one step each, drawn or not', async () => {
612
650
  await mount('arm')
613
651
  await click(await read(() => probe.row('upper-arm')))
614
- await click(await read(() => probe.tab('figure.label.motion')))
652
+ await click(await read(() => probe.tab('figure.label.tab-short-motion')))
615
653
  const shownSelect = () =>
616
654
  page().evaluateHandle(() => {
617
655
  const deep = (root = document) => [...root.querySelectorAll('select')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
@@ -680,7 +718,7 @@ describe('a part follows the figure size in one step (follow-size, 2026-09-26)',
680
718
  editor.session.apply([{ kind: 'set-design', input: 'size.x', unit: 'mm', value: 800 }])
681
719
  return editor.session.read().geometry.find(g => g.id === 'box').shape.dimensions
682
720
  })
683
- assert.equal(Math.round(grown.width), 800, 'twice the size, twice the box')
721
+ assert.equal(Math.round(grown['size.x']), 800, 'twice the size, twice the box')
684
722
  })
685
723
  })
686
724
 
@@ -745,7 +783,7 @@ describe('the recipe catalog: a template chosen, filled in, put onto the chosen
745
783
  const ids = await parts()
746
784
  for (const part of ['frame', 'left', 'right', 'lamp']) assert.ok(ids.includes(`sliding-door-1-${part}`), `${part} is in`)
747
785
  const frame = await body('sliding-door-1-frame')
748
- assert.equal(Math.round(frame.dimensions.right - frame.dimensions.left), 2000, "half the room's 4000 mm")
786
+ assert.equal(Math.round(frame.dimensions['size.x']), 2000, "half the room's 4000 mm")
749
787
  assert.equal(await at('figure-recipe-catalog'), null, 'the catalog closes')
750
788
  })
751
789
 
@@ -760,8 +798,12 @@ describe('the recipe catalog: a template chosen, filled in, put onto the chosen
760
798
  assert.equal((await state()).revision, start.revision, 'no step')
761
799
  assert.deepEqual(await parts(), before, 'nothing added')
762
800
  const said = await at('section[detail] p[role="alert"]')
763
- /* The step it stopped at and the kernel's code -- not the kernel's English (2026-09-27). */
764
- assert.ok(said && /recipe-step-refused|단계|step/i.test(said.text) && /\([A-Z_]+\)/.test(said.text) && !/steps\[/.test(said.text), `the step and the code, in words: ${said?.text}`)
801
+ /*
802
+ The step it stopped at and the kernel's code -- not the kernel's English (2026-09-27). The page loads no
803
+ translations, so the sentence shows as its key; the step and the code it is given are the unit suite's
804
+ (refusals.test.ts, recipeRefusalOf).
805
+ */
806
+ assert.equal(said?.text, 'figure.text.recipe-step-refused', `the step's sentence, not the kernel's English: ${said?.text}`)
765
807
  await click(await at('button[close]'))
766
808
  })
767
809
  })
@@ -823,8 +865,8 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
823
865
  await click(await read(() => probe.row('roller')))
824
866
  assert.equal((await state()).selected, 'roller')
825
867
  assert.ok(await read(() => probe.hasInput('roller.repeat.pitch')), 'the roller offers its row')
826
- /* Walk 1 (2026-09-26): the fastening form opens on the roller's own fastening -- by its centre. */
827
- assert.ok((await read(() => probe.selectsOffering('centre'))).includes('centre'), 'my face reads centre')
868
+ /* Walk 1 (2026-09-26): the fastening form opens on the roller's own fastening -- by its centre, the drawing's way (2026-10-01). */
869
+ assert.equal(await read(() => probe.fastenMode()), 'centre', 'the way it is fastened reads centre')
828
870
  /* And the row's part keeps a name of its own to change. */
829
871
  const name = await read(() => probe.nameField())
830
872
  assert.equal(name.disabled, false, 'the roller can be renamed')
@@ -66,7 +66,7 @@ test('단면 칸에 그림이 들어간다: 속 공간이 있을 때, 면마다
66
66
  assert.ok(!panel.includes("cavityField(") && !panel.includes('renderEnd('), 'no number fields repeating the drawing')
67
67
  has(drawing, /@pointerdown=\$\{\(ev: PointerEvent\) => this\.type\(ev, \{ kind: 'wall', side \}, 'top', w\.wall\[side\]!\)\}/)
68
68
  has(drawing, /!open && this\.type\(ev, \{ kind: 'end', end \}, 'side'/)
69
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
69
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
70
70
  const t = JSON.parse(read(`../translations/${lang}.json`)) as Record<string, string>
71
71
  for (const key of ['figure.label.section-cavity-top', 'figure.label.section-cavity-side']) assert.ok(t[key], `${lang} ${key}`)
72
72
  }
@@ -80,7 +80,7 @@ test('the modeller asks on every path an edit can stop a clip, in every language
80
80
  assert.ok(editor.includes(`if (this.askClipLoss(error, ${path})) return`), path)
81
81
  assert.ok(editor.includes('if (!now || now.context !== asked.context || now.revision !== asked.revision) {'), 'what was asked about is checked after the answer')
82
82
  assert.ok(editor.includes('asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)'))
83
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
83
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
84
84
  const t = JSON.parse(read(`../translations/${lang}.json`))
85
85
  for (const key of ['figure.text.clips-would-go', 'figure.text.clip-gone', 'figure.text.clip-changed', 'figure.button.let-clips-go', 'figure.text.clips-consent-stale']) assert.ok(t[key], `${lang} ${key}`)
86
86
  assert.ok(t['figure.text.clips-would-go'].includes('{clips}') && t['figure.text.clip-gone'].includes('{clip}'), lang)
@@ -64,7 +64,7 @@ test('움직임 탭에 서고, 막대는 입력 한 묶음 · 나머지는 부
64
64
  const clips = read('../client/modeller/figure-clips.ts')
65
65
  assert.ok(clips.includes("@change=${(e: Event) => this.scrub(clip, number(e))}"), '막대는 놓을 때 한 번')
66
66
  const keys = [...clips.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
67
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
67
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
68
68
  const table = JSON.parse(read(`../translations/${lang}.json`))
69
69
  for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
70
70
  }
@@ -94,7 +94,7 @@ test('the details fold, and what they hold that is not the default stays said --
94
94
  /* The way and the place on the face are the drawing's: not said again in the summary, nor chosen again in the details. */
95
95
  assert.ok(!links.includes('fasten-summary-edge') && !links.includes('fasten-summary-off-centre') && !links.includes("'figure.label.facing-flush'"))
96
96
  assert.ok(links.includes('({ axis }) => !view || !view.axes.includes(axis) || !!f.alignInput?.[axis] || hasInstanceSize(asset, axis)'), 'a row only where the drawing cannot set it')
97
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
97
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
98
98
  const t = JSON.parse(read(`../translations/${lang}.json`))
99
99
  for (const key of ['preview-refused', 'preview-stays', 'preview-moves', 'preview-turns-named', 'preview-turns-slanted', 'preview-carries', 'preview-copies', 'preview-clips', 'preview-face-unknown', 'target-would-loop', 'fasten-summary-gap', 'fasten-summary-twist'])
100
100
  assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
@@ -124,7 +124,7 @@ test('detaching says what it would do: stays, what stays fastened to it, the cli
124
124
  assert.ok(editor.includes(': /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []'), 'nothing said when it stays')
125
125
  const links = read('../client/modeller/figure-links.ts')
126
126
  assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length, 3, 'beside every button that detaches (a part at an origin has its own)')
127
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
127
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
128
128
  const t = JSON.parse(read(`../translations/${lang}.json`))
129
129
  for (const key of ['detach-moves', 'detach-keeps-children', 'detach-clips', 'detach-refused']) assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
130
130
  }
@@ -164,7 +164,7 @@ test('a row is seen before it is made, read as figure-model draws it -- and said
164
164
  const editor = read('../client/modeller/editor.ts')
165
165
  assert.ok(editor.includes("const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.shown()!.inputs) : undefined"))
166
166
  assert.ok(editor.includes('<ul row-reading>${rowWordsOf(this.rowNow(shown)'), 'a row made says the same, as it stands')
167
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
167
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
168
168
  const t = JSON.parse(read(`../translations/${lang}.json`))
169
169
  for (const key of ['row-count-pitch', 'row-count-uneven', 'row-ends-centred', 'row-ends', 'row-ends-unknown', 'row-companions', 'row-count-spreads', 'row-blocked-unfastened', 'row-blocked-on-plane', 'row-blocked-holds-parts'])
170
170
  assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
@@ -241,7 +241,7 @@ test('past the edge of its face is said beside the fastening, with the way to ch
241
241
  assert.ok(links.includes("<button act @click=${() => this.showWhere()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>"))
242
242
  assert.ok(links.includes("shadowRoot?.querySelector<HTMLElement>('div[where]')"), 'to the face in the drawing, where the place is dragged or keyed')
243
243
  assert.equal(say('figure.text.overhang-out', { on: 'top', face: '윗면', sides: say('figure.text.overhang-side-high', { axis: 'X', mm: '100' }) }), 'top의 윗면 가장자리 밖으로 나와 있습니다: X 높은 쪽 100 mm. 모델의 점유 공간과는 따로 봅니다.')
244
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
244
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
245
245
  const t = JSON.parse(read(`../translations/${lang}.json`))
246
246
  for (const key of ['figure.text.overhang-out', 'figure.text.overhang-possible', 'figure.text.overhang-unknown', 'figure.text.overhang-held', 'figure.text.overhang-side-low', 'figure.text.overhang-side-high', 'figure.button.fix-alignment']) assert.ok(t[key], `${lang} ${key}`)
247
247
  }
@@ -290,7 +290,7 @@ test('a part wider than its face is said in the relation, with its numbers and w
290
290
  const links = read('../client/modeller/figure-links.ts')
291
291
  assert.ok(links.includes("${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}"), 'in the details, not the warning')
292
292
  assert.equal(say('figure.text.overhang-larger', { on: 'leg', face: '윗면', sides: 'X 낮은 쪽 270 mm, X 높은 쪽 870 mm' }), 'leg의 윗면보다 넓어 가장자리 밖으로 나와 있습니다: X 낮은 쪽 270 mm, X 높은 쪽 870 mm.')
293
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
293
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
294
294
  const t = JSON.parse(read(`../translations/${lang}.json`))
295
295
  assert.ok(t['figure.text.overhang-larger'] && t['figure.text.overhang-lopsided'], lang)
296
296
  }
@@ -62,7 +62,7 @@ test('편집기에 닿는다 — 값 바꾸기는 입력으로, 이름 · 범위
62
62
  const controls = read('../client/modeller/figure-controls.ts')
63
63
  assert.ok(controls.includes("@change=${(e: Event) => this.try(c, number(e))}"), '슬라이더는 놓을 때 한 번 — 끄는 동안 기록이 쌓이지 않는다')
64
64
  const keys = [...controls.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
65
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
65
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
66
66
  const table = JSON.parse(read(`../translations/${lang}.json`))
67
67
  for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
68
68
  }
@@ -40,6 +40,6 @@ test('화면 문구는 결과와 예외만 — 한 줄 요약, 가장자리 밖,
40
40
  assert.equal(say('figure.text.fasten-sentence-meet', { part: 'lid', face: '아랫면', to: 'bin의 윗면' }), 'lid의 아랫면을 bin의 윗면에 맞닿게')
41
41
  assert.equal(say('figure.text.fasten-sentence-centre', { part: 'roller', face: '', to: 'frame의 앞면' }), 'roller의 가운데를 frame의 앞면 위에')
42
42
  for (const key of ['figure.text.fasten-off-face', 'figure.text.fasten-past-edges', 'figure.label.fasten-mode-meet', 'figure.label.fasten-mode-flush', 'figure.label.fasten-mode-centre'])
43
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) assert.ok((JSON.parse(read(`../translations/${lang}.json`)) as Record<string, string>)[key], `${lang} ${key}`)
43
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) assert.ok((JSON.parse(read(`../translations/${lang}.json`)) as Record<string, string>)[key], `${lang} ${key}`)
44
44
  assert.ok(!/끌어 옮깁니다|면 밖으로도/.test(JSON.stringify(ko)), 'no instructions on the screen')
45
45
  })
@@ -96,7 +96,7 @@ test('pitch 는 한 곳에서 하나의 값이고, 늘 set-repeat 로 바꿔 최
96
96
  const pitch = editor.slice(editor.indexOf(' private setPitch('), editor.indexOf(' /** Choose a part,'))
97
97
  assert.ok(pitch.includes("this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })"), '늘 명령으로 — 최대 길이가 따라온다')
98
98
  assert.ok(!pitch.includes('changeField'))
99
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
99
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
100
100
  const table = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
101
101
  /* The term stays pitch; what it measures is said beside it (ADR-0097 §5.5: centre to centre). */
102
102
  assert.ok(table['figure.label.repeat-gap'].startsWith('pitch') && table['figure.label.repeat-gap'].length > 'pitch'.length, lang)
@@ -48,7 +48,7 @@ test('a figure proposal is answered by its handler, in words, never left without
48
48
  assert.match(handler, /export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'/)
49
49
  assert.match(handler, /runFigureProposal\(/)
50
50
  assert.match(handler, /context\.notify\(/)
51
- for (const lang of ['en', 'ko', 'ja', 'zh', 'ms']) {
51
+ for (const lang of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
52
52
  const words = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
53
53
  for (const key of ['applied', 'stale', 'refused', 'needs-modeller', 'not-opened', 'new-figure-unsaved'])
54
54
  assert.ok(words[`figure.text.proposal-${key}`], `${lang} has no words for proposal-${key}`)
@@ -50,7 +50,7 @@ test('the side tabs say their names, in every language', () => {
50
50
  assert.ok(editor.includes("const name = i18next.t('figure.label.tab-short-' + tab)"))
51
51
  assert.ok(editor.includes('aria-label=${name}'), 'its accessible name is the name it shows')
52
52
  assert.ok(editor.includes('<span tab-label aria-hidden="true">${name}</span>'))
53
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
53
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
54
54
  const table = JSON.parse(read(`../translations/${lang}.json`))
55
55
  for (const tab of ['properties', 'capability', 'motion', 'verdict', 'view', 'history']) assert.ok(table[`figure.label.tab-short-${tab}`], `${lang} ${tab}`)
56
56
  }
@@ -35,7 +35,7 @@ test('the first save asks the name first; the identifier offered stays through r
35
35
  assert.ok(popup.includes('if (!this.type || offeredAndTaken) this.type = this.suggested = suggestTypeOf(this.name, this.taken)'), 'offered again only when empty, or offered and taken')
36
36
  const page = read('../client/pages/figure-modeller-page.ts')
37
37
  assert.ok(page.includes('if (answer) this.row = { ...this.row, type: answer.type }'), 'a retry opens on the identifier answered before')
38
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
38
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
39
39
  const t = JSON.parse(read(`../translations/${lang}.json`))
40
40
  assert.ok(t['figure.label.figure-identifier'] && t['figure.text.identifier-suggested'], lang)
41
41
  }
@@ -94,7 +94,7 @@ test('캔버스 — 기즈모 · W E R · 스냅은 씬의 편집 모드가 한
94
94
  })
95
95
 
96
96
  test('기즈모의 말은 다섯 말 모두에 있다', () => {
97
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
97
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
98
98
  const table = JSON.parse(read(`../translations/${lang}.json`))
99
99
  for (const mode of ['translate', 'rotate', 'scale']) assert.ok(table[`figure.label.gizmo-${mode}`], `${lang}: ${mode}`)
100
100
  }
@@ -66,7 +66,7 @@ test('띠 — 칸 접기 · 되돌리기 · 전체 보기 · 저장 상태 ·
66
66
  assert.ok(bar.includes(piece), piece)
67
67
  assert.doesNotMatch(renderBody, /[가-힣]/, '편집기의 화면 말에 한국어가 박혀 있지 않다')
68
68
  const keys = [...editor.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
69
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
69
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
70
70
  const table = JSON.parse(read(`../translations/${lang}.json`))
71
71
  for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
72
72
  }
@@ -103,7 +103,7 @@ test('불러오는 동안 빈 무대에 「도형을 추가해…」가 깜빡
103
103
  /* A new figure's start card says how to begin; an empty stage otherwise is one loading, or with every part hidden. */
104
104
  assert.ok(canvas.includes('return html`<div stage></div>`'))
105
105
  assert.ok(!canvas.includes('add-a-shape-to-start') && !editor.includes('startShown'))
106
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms'])
106
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es'])
107
107
  assert.equal((JSON.parse(read(`../translations/${lang}.json`)) as Record<string, string>)['figure.text.add-a-shape-to-start'], undefined, lang)
108
108
  assert.ok(editor.includes("if (!foldSaid()) notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })"))
109
109
  assert.ok(/function foldSaid\(\): boolean \{\s*try \{\s*if \(localStorage\.getItem\('figure-fold-said'\)\) return true\s*localStorage\.setItem\('figure-fold-said', '1'\)\s*\} catch/.test(editor), 'storage refused: said, as before')
@@ -98,7 +98,7 @@ test('떼면 지금 크기로 고정한다 — 따르던 값을 잃지 않는다
98
98
  test('세 칸의 말은 다섯 말 모두에 있다', () => {
99
99
  const links = read('../client/modeller/figure-links.ts')
100
100
  const keys = [...links.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
101
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
101
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
102
102
  const table = JSON.parse(read(`../translations/${lang}.json`))
103
103
  for (const key of [...keys, 'figure.text.follow-would-loop', 'figure.text.fasten-would-loop', 'figure.label.motion-slide', 'figure.label.motion-turn', 'figure.label.place-grip', 'figure.label.place-seat'])
104
104
  assert.ok(table[key], `${lang}: ${key}`)
@@ -229,7 +229,7 @@ test('화면이 키를 이어 붙여 찾는 말도 다섯 말 모두에 있다',
229
229
  'figure.text.bound-by-triangles',
230
230
  ...[...maturity.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1]!)
231
231
  ]
232
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
232
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
233
233
  const table = JSON.parse(read(`../translations/${lang}.json`))
234
234
  for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
235
235
  }
@@ -40,7 +40,7 @@ test('지울 수 없는 부품은 사람의 말로 거절하고, 지우며 풀
40
40
  /* Since 2026-09-25 the reason is told apart (partRefusalOf, tested in refusals); the editor hands the refusal to it. */
41
41
  assert.match(editor, /private removePart[\s\S]*?partRefusalOf\(error, id,/)
42
42
  assert.match(editor, /private removePart[\s\S]*?this\.notice = removalNoticeOf\(view\.removal,/, '지운 뒤 풀어 준 것을 알린다')
43
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
43
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
44
44
  const table = JSON.parse(read(`../translations/${lang}.json`))
45
45
  for (const key of ['part-is-referenced', 'part-in-declared-space', 'part-row-along', 'part-row-carries'])
46
46
  assert.ok(table[`figure.text.${key}`]?.includes('{part}'), `${lang} ${key}`)
@@ -63,7 +63,7 @@ test('팔레트는 시작 모양만 낸다 — 둥근 상자와 통은 상자의
63
63
  assert.deepEqual(kinds, ['box', 'cylinder', 'sphere', 'frustum', 'polygon', 'pyramid', 'plate', 'wall', 'portal'])
64
64
  /* A part made so is still called by what it is: its icon in the parts list, its name over the properties (links.ts). */
65
65
  assert.match(panel, /'rounded-box': 'crop_square'/)
66
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms'])
66
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es'])
67
67
  assert.ok(JSON.parse(read(`../translations/${lang}.json`))['figure.label.primitive-rounded-box'], lang)
68
68
  })
69
69
 
@@ -71,7 +71,7 @@ test('부품 이름은 figure-model 의 rename-part 로 바꾸고, 거절은 까
71
71
  assert.match(editor, /this\.session!\.edit\(\{ kind: 'rename-part', target: from, name \}\)/)
72
72
  assert.match(editor, /this\.selected = name/, '바꾼 이름의 부품이 그대로 골라져 있다')
73
73
  assert.match(editor, /'figure\.text\.part-name-taken' : 'figure\.text\.part-name-invalid'/)
74
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
74
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
75
75
  const table = JSON.parse(read(`../translations/${lang}.json`))
76
76
  for (const key of ['figure.text.part-name-taken', 'figure.text.part-name-invalid']) assert.ok(table[key]?.includes('{name}'), `${lang}: ${key}`)
77
77
  }
@@ -89,7 +89,7 @@ test('속성 탭의 말은 다섯 말 모두에 있다', () => {
89
89
  const keys = [...tab.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
90
90
  for (const face of ['left', 'right', 'bottom', 'top', 'back', 'front']) keys.push(`figure.label.face-${face}`)
91
91
  for (const one of ['floor', 'ceiling', 'center']) keys.push(`figure.label.placement-${one}`)
92
- for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
92
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
93
93
  const table = JSON.parse(read(`../translations/${lang}.json`))
94
94
  for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
95
95
  }
@@ -122,5 +122,5 @@ test('the dock checks the result from the server: saved is accepted and named, n
122
122
  assert.equal(proposalOutcomeFrom([], sayKo), undefined, 'not saved is not known, never refused')
123
123
  const page = read('../client/pages/figure-modeller-page.ts')
124
124
  assert.ok(page.includes('proposalOutcomeFrom(await fetchProposalOutcome(proposalKey)'), 'the surface asks the server')
125
- for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) assert.ok(JSON.parse(read(`../translations/${lang}.json`))['figure.text.proposal-saved-into'], lang)
125
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) assert.ok(JSON.parse(read(`../translations/${lang}.json`))['figure.text.proposal-saved-into'], lang)
126
126
  })