@things-factory/figure-ui 10.1.147 → 10.1.150

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 (210) hide show
  1. package/client/bootstrap.ts +10 -2
  2. package/client/graphql/index.ts +20 -0
  3. package/client/modeller/canvas.ts +6 -2
  4. package/client/modeller/carrier-declaration.ts +63 -0
  5. package/client/modeller/editor.ts +62 -27
  6. package/client/modeller/figure-carrier.ts +117 -0
  7. package/client/modeller/figure-cavity-drawing.ts +3 -9
  8. package/client/modeller/figure-first-save.ts +4 -7
  9. package/client/modeller/figure-history-panel.ts +2 -3
  10. package/client/modeller/figure-links.ts +46 -60
  11. package/client/modeller/figure-material-panel.ts +4 -14
  12. package/client/modeller/figure-mesh-choice.ts +68 -0
  13. package/client/modeller/figure-parts.ts +23 -1
  14. package/client/modeller/figure-preview.ts +2 -2
  15. package/client/modeller/figure-properties.ts +5 -3
  16. package/client/modeller/figure-section-editor.ts +12 -33
  17. package/client/modeller/figure-section-panel.ts +6 -7
  18. package/client/modeller/figure-slot-layout-panel.ts +208 -0
  19. package/client/modeller/figure-verdict.ts +21 -31
  20. package/client/modeller/first-save.ts +23 -0
  21. package/client/modeller/links.ts +52 -1
  22. package/client/modeller/material-edit.ts +38 -0
  23. package/client/modeller/maturity.ts +28 -0
  24. package/client/modeller/mesh-import.ts +77 -0
  25. package/client/modeller/modeller-route.ts +9 -0
  26. package/client/modeller/mounting-part.ts +15 -0
  27. package/client/modeller/newest-answer.ts +21 -0
  28. package/client/modeller/preview-model.ts +5 -0
  29. package/client/modeller/refusals.ts +168 -82
  30. package/client/modeller/section-controls.ts +25 -1
  31. package/client/modeller/section-drawing.ts +43 -1
  32. package/client/modeller/slot-layout-host.ts +140 -0
  33. package/client/pages/figure-modeller-page.ts +6 -5
  34. package/client/viewparts/detail-route.ts +7 -0
  35. package/client/viewparts/figure-detail-dock.ts +1 -3
  36. package/dist-client/bootstrap.d.ts +1 -1
  37. package/dist-client/bootstrap.js +9 -2
  38. package/dist-client/bootstrap.js.map +1 -1
  39. package/dist-client/graphql/index.d.ts +5 -0
  40. package/dist-client/graphql/index.js +19 -0
  41. package/dist-client/graphql/index.js.map +1 -1
  42. package/dist-client/modeller/canvas.d.ts +2 -0
  43. package/dist-client/modeller/canvas.js +10 -2
  44. package/dist-client/modeller/canvas.js.map +1 -1
  45. package/dist-client/modeller/carrier-declaration.d.ts +31 -0
  46. package/dist-client/modeller/carrier-declaration.js +53 -0
  47. package/dist-client/modeller/carrier-declaration.js.map +1 -0
  48. package/dist-client/modeller/editor.d.ts +13 -0
  49. package/dist-client/modeller/editor.js +62 -27
  50. package/dist-client/modeller/editor.js.map +1 -1
  51. package/dist-client/modeller/figure-carrier.d.ts +26 -0
  52. package/dist-client/modeller/figure-carrier.js +123 -0
  53. package/dist-client/modeller/figure-carrier.js.map +1 -0
  54. package/dist-client/modeller/figure-cavity-drawing.js +3 -11
  55. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  56. package/dist-client/modeller/figure-first-save.d.ts +4 -0
  57. package/dist-client/modeller/figure-first-save.js +5 -8
  58. package/dist-client/modeller/figure-first-save.js.map +1 -1
  59. package/dist-client/modeller/figure-history-panel.js +2 -4
  60. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  61. package/dist-client/modeller/figure-links.d.ts +4 -1
  62. package/dist-client/modeller/figure-links.js +47 -57
  63. package/dist-client/modeller/figure-links.js.map +1 -1
  64. package/dist-client/modeller/figure-material-panel.d.ts +0 -1
  65. package/dist-client/modeller/figure-material-panel.js +4 -14
  66. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-mesh-choice.d.ts +16 -0
  68. package/dist-client/modeller/figure-mesh-choice.js +75 -0
  69. package/dist-client/modeller/figure-mesh-choice.js.map +1 -0
  70. package/dist-client/modeller/figure-parts.d.ts +1 -1
  71. package/dist-client/modeller/figure-parts.js +22 -0
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.js +2 -2
  74. package/dist-client/modeller/figure-preview.js.map +1 -1
  75. package/dist-client/modeller/figure-properties.d.ts +2 -1
  76. package/dist-client/modeller/figure-properties.js +5 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-section-editor.d.ts +2 -2
  79. package/dist-client/modeller/figure-section-editor.js +12 -33
  80. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  81. package/dist-client/modeller/figure-section-panel.js +6 -7
  82. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  83. package/dist-client/modeller/figure-slot-layout-panel.d.ts +36 -0
  84. package/dist-client/modeller/figure-slot-layout-panel.js +198 -0
  85. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -0
  86. package/dist-client/modeller/figure-verdict.d.ts +1 -1
  87. package/dist-client/modeller/figure-verdict.js +19 -37
  88. package/dist-client/modeller/figure-verdict.js.map +1 -1
  89. package/dist-client/modeller/first-save.d.ts +10 -0
  90. package/dist-client/modeller/first-save.js +16 -0
  91. package/dist-client/modeller/first-save.js.map +1 -1
  92. package/dist-client/modeller/links.d.ts +26 -1
  93. package/dist-client/modeller/links.js +43 -0
  94. package/dist-client/modeller/links.js.map +1 -1
  95. package/dist-client/modeller/material-edit.d.ts +11 -0
  96. package/dist-client/modeller/material-edit.js +28 -0
  97. package/dist-client/modeller/material-edit.js.map +1 -0
  98. package/dist-client/modeller/maturity.d.ts +15 -0
  99. package/dist-client/modeller/maturity.js +23 -0
  100. package/dist-client/modeller/maturity.js.map +1 -1
  101. package/dist-client/modeller/mesh-import.d.ts +47 -0
  102. package/dist-client/modeller/mesh-import.js +47 -0
  103. package/dist-client/modeller/mesh-import.js.map +1 -0
  104. package/dist-client/modeller/modeller-route.d.ts +5 -0
  105. package/dist-client/modeller/modeller-route.js +9 -0
  106. package/dist-client/modeller/modeller-route.js.map +1 -1
  107. package/dist-client/modeller/mounting-part.d.ts +14 -0
  108. package/dist-client/modeller/mounting-part.js +10 -0
  109. package/dist-client/modeller/mounting-part.js.map +1 -0
  110. package/dist-client/modeller/newest-answer.d.ts +12 -0
  111. package/dist-client/modeller/newest-answer.js +21 -0
  112. package/dist-client/modeller/newest-answer.js.map +1 -0
  113. package/dist-client/modeller/preview-model.d.ts +2 -0
  114. package/dist-client/modeller/preview-model.js +4 -0
  115. package/dist-client/modeller/preview-model.js.map +1 -1
  116. package/dist-client/modeller/refusals.d.ts +26 -5
  117. package/dist-client/modeller/refusals.js +184 -86
  118. package/dist-client/modeller/refusals.js.map +1 -1
  119. package/dist-client/modeller/section-controls.d.ts +17 -1
  120. package/dist-client/modeller/section-controls.js +18 -0
  121. package/dist-client/modeller/section-controls.js.map +1 -1
  122. package/dist-client/modeller/section-drawing.d.ts +24 -1
  123. package/dist-client/modeller/section-drawing.js +42 -0
  124. package/dist-client/modeller/section-drawing.js.map +1 -1
  125. package/dist-client/modeller/slot-layout-host.d.ts +81 -0
  126. package/dist-client/modeller/slot-layout-host.js +105 -0
  127. package/dist-client/modeller/slot-layout-host.js.map +1 -0
  128. package/dist-client/pages/figure-modeller-page.js +5 -4
  129. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  130. package/dist-client/tsconfig.tsbuildinfo +1 -1
  131. package/dist-client/viewparts/detail-route.d.ts +5 -0
  132. package/dist-client/viewparts/detail-route.js +8 -0
  133. package/dist-client/viewparts/detail-route.js.map +1 -0
  134. package/dist-client/viewparts/figure-detail-dock.d.ts +0 -1
  135. package/dist-client/viewparts/figure-detail-dock.js +1 -3
  136. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  137. package/package.json +9 -8
  138. package/test/align.test.ts +0 -9
  139. package/test/browser/assistant-stand-in.js +32 -0
  140. package/test/browser/build.check.mjs +77 -0
  141. package/test/browser/build.mjs +31 -6
  142. package/test/browser/editing-page.js +224 -0
  143. package/test/browser/editing.check.mjs +550 -0
  144. package/test/browser/links-page.js +113 -0
  145. package/test/browser/links.check.mjs +568 -0
  146. package/test/browser/modeller.check.mjs +21 -0
  147. package/test/browser/parts-page.js +120 -0
  148. package/test/browser/parts.check.mjs +328 -0
  149. package/test/browser/places-mesh-page.js +36 -0
  150. package/test/browser/places-mesh.check.mjs +239 -0
  151. package/test/browser/product-page.check.mjs +682 -0
  152. package/test/browser/product-page.js +276 -0
  153. package/test/browser/screen-page.js +112 -0
  154. package/test/browser/screen-product-page.js +62 -0
  155. package/test/browser/screen.check.mjs +1003 -0
  156. package/test/browser/section-page.js +256 -0
  157. package/test/browser/section.check.mjs +719 -0
  158. package/test/browser/serve.mjs +20 -12
  159. package/test/carrier-declaration.test.ts +69 -0
  160. package/test/cavity-drawing.test.ts +2 -18
  161. package/test/clip-ai-proposal.test.ts +1 -5
  162. package/test/clip-consent.test.ts +0 -5
  163. package/test/clips.test.ts +0 -7
  164. package/test/command-preview.test.ts +0 -68
  165. package/test/controls.test.ts +0 -9
  166. package/test/detail-dock.test.ts +6 -25
  167. package/test/face-mapping.test.ts +1 -20
  168. package/test/fasten-drawing.test.ts +0 -23
  169. package/test/fields.test.ts +6 -33
  170. package/test/figure-assistant-surface.test.ts +32 -38
  171. package/test/first-minute.test.ts +3 -55
  172. package/test/first-save-suggest.test.ts +11 -8
  173. package/test/first-save.test.ts +4 -26
  174. package/test/gizmo.test.ts +0 -25
  175. package/test/joint-fan.test.ts +0 -10
  176. package/test/keys.test.ts +0 -7
  177. package/test/layout.test.ts +4 -73
  178. package/test/links.test.ts +48 -67
  179. package/test/live.test.ts +0 -34
  180. package/test/material.test.ts +17 -26
  181. package/test/maturity.test.ts +13 -16
  182. package/test/mesh-import.test.ts +76 -0
  183. package/test/modeller-route.test.ts +3 -19
  184. package/test/package-download.test.ts +6 -7
  185. package/test/parts-wiring.test.ts +9 -51
  186. package/test/preview.test.ts +5 -46
  187. package/test/properties.test.ts +11 -26
  188. package/test/proposal-record.test.ts +2 -19
  189. package/test/recipes.test.ts +0 -15
  190. package/test/refusals.test.ts +257 -151
  191. package/test/release-entry.test.ts +1 -9
  192. package/test/release-panel.test.ts +1 -22
  193. package/test/resize-with-placement.test.ts +0 -32
  194. package/test/revert-choice.test.ts +1 -22
  195. package/test/revert-keeping.test.ts +1 -12
  196. package/test/save-landed.test.ts +16 -14
  197. package/test/scene-canvas.test.ts +2 -13
  198. package/test/section-editor.test.ts +30 -83
  199. package/test/section-panel.test.ts +22 -17
  200. package/test/slot-layout-host.test.ts +117 -0
  201. package/test/theme.test.ts +5 -9
  202. package/test/verdict.test.ts +44 -41
  203. package/test/view.test.ts +5 -12
  204. package/translations/en.json +61 -1
  205. package/translations/es.json +61 -1
  206. package/translations/ja.json +61 -1
  207. package/translations/ko.json +61 -1
  208. package/translations/ms.json +61 -1
  209. package/translations/zh.json +61 -1
  210. package/test/thumbnail.test.ts +0 -29
@@ -0,0 +1,550 @@
1
+ /*
2
+ * Editing a part in the modeller, in a real browser: the bar's gizmo tools, putting a part on its plane, the material,
3
+ * values drawn while they are in hand and written once on release, the pose sliders and the clips, the question asked
4
+ * before an edit stops a clip, the keys, the joint fan and the recipe catalog's guide. What is read is what a person
5
+ * sees -- the tools offered, the part as the canvas draws it, the app's notices and prompts -- and what the session
6
+ * holds: its revision counts the steps written.
7
+ *
8
+ * Run: node --test test/browser/editing.check.mjs
9
+ */
10
+ import { test, describe, before, after } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { readFileSync } from 'node:fs'
13
+ import { dirname, resolve } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ import { buildPage } from './build.mjs'
17
+ import { openPage } from './serve.mjs'
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url))
20
+ const SIX_AXIS = resolve(HERE, '../fixtures/robot-arm-6dof.json')
21
+
22
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
23
+
24
+ let opened
25
+ before(async () => {
26
+ opened = await openPage(await buildPage('editing-page.js'))
27
+ })
28
+ after(async () => {
29
+ await opened?.close()
30
+ })
31
+
32
+ const page = () => opened.page
33
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
34
+ const state = () => read(() => probe.state())
35
+ const revision = async () => (await state()).revision
36
+ const look = id => read(id => editing.look(id), id)
37
+ const UNDO = ['Control', 'KeyZ']
38
+
39
+ /** Waits until `check` holds, reading again every 50 ms; fails with `what` after `ms`. */
40
+ async function until(check, what, ms = 3000) {
41
+ for (const end = Date.now() + ms; Date.now() < end; await sleep(50)) if (await check()) return
42
+ assert.fail(`waited for ${what}`)
43
+ }
44
+
45
+ /** Mounts a figure -- one of the harness's (`box`, `arm`, `room`), the six-axis arm, or one of the page's -- and waits for it to be drawn. */
46
+ async function mount(kind, figure, templates = []) {
47
+ const text = kind === 'six-axis' ? readFileSync(SIX_AXIS, 'utf-8') : null
48
+ await read((kind, text, figure, templates) => harness.mount(kind, figure ? figures[figure]() : text, templates), kind === 'six-axis' ? 'box' : kind, text, figure, templates)
49
+ await until(
50
+ () =>
51
+ read(() => {
52
+ /* Not drawn yet is not an error: until the canvas stands, reading it throws. */
53
+ try {
54
+ return probe.ids().some(id => !!probe.partOnPage(id))
55
+ } catch {
56
+ return false
57
+ }
58
+ }),
59
+ 'the figure drawn',
60
+ 10000
61
+ )
62
+ /* The camera is fitted after the first frames. */
63
+ await sleep(1000)
64
+ }
65
+
66
+ async function click(point) {
67
+ assert.ok(point, 'nothing on the page to click')
68
+ await page().mouse.click(point.x, point.y)
69
+ await sleep(300)
70
+ }
71
+ const pick = async id => {
72
+ await click(await read(id => probe.row(id), id))
73
+ assert.equal((await state()).selected, id, `${id} is picked from the list`)
74
+ }
75
+ /** Keys pressed with nothing focused: the page's, not a field's. */
76
+ async function press(...keys) {
77
+ await read(() => {
78
+ const deepest = root => (root.activeElement?.shadowRoot ? deepest(root.activeElement.shadowRoot) : root.activeElement)
79
+ deepest(document)?.blur?.()
80
+ })
81
+ for (const key of keys.slice(0, -1)) await page().keyboard.down(key)
82
+ await page().keyboard.press(keys.at(-1))
83
+ for (const key of keys.slice(0, -1).reverse()) await page().keyboard.up(key)
84
+ await sleep(400)
85
+ }
86
+ /** Types into a field by its label; `commit` is the key that ends it, or none to keep the value in hand. */
87
+ async function type(label, text, commit) {
88
+ const at = await read(label => probe.labelled(label), label)
89
+ assert.ok(at, `the field ${label}`)
90
+ await page().mouse.click(at.x, at.y, { clickCount: 3 })
91
+ await page().keyboard.type(text)
92
+ if (commit) await page().keyboard.press(commit)
93
+ await sleep(400)
94
+ }
95
+ /** Presses at `from`, moves `dx` across in ten steps, calls `held` before letting go, then lets go. */
96
+ async function drag(from, dx, held) {
97
+ await page().mouse.move(from.x, from.y)
98
+ await page().mouse.down()
99
+ for (let i = 1; i <= 10; i++) {
100
+ await page().mouse.move(from.x + (dx * i) / 10, from.y)
101
+ await sleep(30)
102
+ }
103
+ await sleep(300)
104
+ const seen = await held()
105
+ await page().mouse.up()
106
+ await sleep(500)
107
+ return seen
108
+ }
109
+
110
+ const KEYS = { translate: 'KeyW', rotate: 'KeyE', scale: 'KeyR' }
111
+
112
+ /**
113
+ * Drags a gizmo handle `pixels` outward along it, as modeller.check.mjs does: hovered until the gizmo holds it, ten moves,
114
+ * release. With `midway`, the hand holds still half way and `midway()` is read there before the drag goes on.
115
+ */
116
+ async function dragHandle(part, mode, axis, pixels, midway) {
117
+ const s = await state()
118
+ if (s.mode !== mode || !s.attached) {
119
+ await page().keyboard.press(KEYS[mode])
120
+ await sleep(300)
121
+ }
122
+ assert.ok((await state()).attached, `the ${mode} gizmo is on the selected part`)
123
+ const centre = await read(id => probe.partOnPage(id), part)
124
+ let hold
125
+ for (const point of await read(axis => probe.handlePoints(axis), axis)) {
126
+ await page().mouse.move(point.x, point.y)
127
+ await sleep(20)
128
+ if ((await state()).axis === axis) {
129
+ hold = point
130
+ break
131
+ }
132
+ }
133
+ assert.ok(hold, `the mouse finds the ${mode} ${axis} handle`)
134
+ const length = Math.hypot(hold.x - centre.x, hold.y - centre.y) || 1
135
+ const dx = (hold.x - centre.x) / length,
136
+ dy = (hold.y - centre.y) / length
137
+ let seen
138
+ await page().mouse.down()
139
+ for (let i = 1; i <= 10; i++) {
140
+ await page().mouse.move(hold.x + (dx * pixels * i) / 10, hold.y + (dy * pixels * i) / 10)
141
+ await sleep(30)
142
+ if (i === 5 && midway) {
143
+ /* Longer than the canvas waits after a change before it writes one that came with no pointer down. */
144
+ await sleep(500)
145
+ seen = await midway()
146
+ }
147
+ }
148
+ await page().mouse.up()
149
+ await sleep(600)
150
+ return seen
151
+ }
152
+
153
+ /** A box's bottom, from the session: it is drawn up y from its start. */
154
+ const bottomOf = async id => {
155
+ const b = await read(id => probe.body(id), id)
156
+ return Math.round(b.t[1] + b.dimensions.start)
157
+ }
158
+
159
+ describe('the bar\'s move, turn and resize tools: the keys\' way, only where the part\'s own numbers take it (2026-09-26)', () => {
160
+ before(() => mount('box'))
161
+
162
+ test('a part of its own numbers is offered all three, each telling its key', async () => {
163
+ await pick('box')
164
+ const tools = await read(() => editing.tools())
165
+ assert.deepEqual(tools.map(t => [t.label, t.disabled]), [
166
+ ['figure.button.gizmo-translate', false],
167
+ ['figure.button.gizmo-rotate', false],
168
+ ['figure.button.gizmo-scale', false]
169
+ ])
170
+ assert.deepEqual(tools.map(t => t.title.match(/\((\w)\)/)?.[1]), ['W', 'E', 'R'])
171
+ })
172
+
173
+ test('R pressed with the part picked from the list puts the resize gizmo on it, and the bar shows resize pressed', async () => {
174
+ await press('KeyR')
175
+ const s = await state()
176
+ assert.equal(s.mode, 'scale')
177
+ assert.ok(s.attached, 'the gizmo is on the box')
178
+ assert.deepEqual((await read(() => editing.tools())).filter(t => t.pressed).map(t => t.label), ['figure.button.gizmo-scale'])
179
+ })
180
+
181
+ test('the height dragged with the resize handle keeps the bottom on the floor, in one step', async () => {
182
+ const start = await revision()
183
+ const was = await read(() => probe.body('box'))
184
+ await dragHandle('box', 'scale', 'Y', 80)
185
+ const now = await read(() => probe.body('box'))
186
+ assert.equal(await revision(), start + 1, 'one step')
187
+ assert.ok(now.dimensions.length > was.dimensions.length * 1.1, `taller: ${was.dimensions.length} -> ${now.dimensions.length}`)
188
+ assert.equal(await bottomOf('box'), 0, 'the bottom stays on the floor, as the placement keeps it')
189
+ })
190
+
191
+ test('a drag held still half way is written once, on release -- nothing while the hand rests', async () => {
192
+ const start = await revision()
193
+ const was = await read(() => probe.body('box'))
194
+ const midway = await dragHandle('box', 'translate', 'X', 80, () => revision())
195
+ assert.equal(midway, start, 'nothing written while the pointer is down')
196
+ assert.equal(await revision(), start + 1, 'one step on release')
197
+ assert.ok(Math.abs((await read(() => probe.body('box'))).t[0] - was.t[0]) > 20, 'moved the whole drag')
198
+ })
199
+
200
+ test('in the preview the tools are not offered; back in the edit view they are', async () => {
201
+ await click(await read(() => probe.modeButton('preview')))
202
+ await sleep(1000)
203
+ assert.deepEqual((await read(() => editing.tools())).map(t => t.disabled), [true, true, true])
204
+ await click(await read(() => probe.modeButton('edit')))
205
+ await sleep(1000)
206
+ assert.deepEqual((await read(() => editing.tools())).map(t => t.disabled), [false, false, false])
207
+ })
208
+ })
209
+
210
+ describe('a part a joint places is not moved by the bar, nor put on the plane', () => {
211
+ before(() => mount('arm'))
212
+
213
+ test('the upper arm: move is not offered, and the align tools are not there', async () => {
214
+ await pick('upper-arm')
215
+ const move = (await read(() => editing.tools())).find(t => t.label === 'figure.button.gizmo-translate')
216
+ assert.equal(move.disabled, true, 'not moved by its own numbers')
217
+ assert.equal(await read(() => editing.at('button[plane-align]')), null, 'no align tools')
218
+ })
219
+ })
220
+
221
+ describe('put on the plane by the figure\'s placement: once, not fastened (designer 2026-09-27)', () => {
222
+ test('a box 500 mm above the floor is put on it by its bottom, in one step', async () => {
223
+ await mount('box', 'raised')
224
+ await pick('box')
225
+ assert.ok((await bottomOf('box')) > 0, 'it starts off the floor')
226
+ const start = await revision()
227
+ await click(await read(() => editing.at('button[plane-align]')))
228
+ assert.equal(await revision(), start + 1, 'one step')
229
+ assert.equal(await bottomOf('box'), 0)
230
+ })
231
+
232
+ test('a tilted part is told it touches by a point or an edge, before and after', async () => {
233
+ await mount('box', 'tilted')
234
+ await pick('box')
235
+ const tool = await read(() => editing.at('button[plane-align]'))
236
+ assert.equal(tool.title, 'figure.text.align-to-floor-tilted')
237
+ await read(() => editing.told())
238
+ const start = await revision()
239
+ await click(tool)
240
+ assert.equal(await revision(), start + 1, 'one step')
241
+ assert.deepEqual((await read(() => editing.told())).filter(n => n.level === 'info').map(n => n.message), ['figure.text.aligned-tilted-floor'])
242
+ })
243
+ })
244
+
245
+ describe('the material: colours by purpose, one step each, a glow drawn while dragged, segments for round bodies only', () => {
246
+ before(() => mount('box', 'shapes'))
247
+
248
+ test('a box has no segments to choose', async () => {
249
+ await pick('bin')
250
+ assert.equal(await read(() => editing.at('button[seg]', 'figure-material-panel')), null)
251
+ })
252
+
253
+ test('the colours are named by what they are for, never by their raw names', async () => {
254
+ const titles = (await read(() => editing.all('button[swatch]', 'figure-material-panel'))).map(s => s.title)
255
+ assert.ok(titles.length >= 5, `the palette's colours: ${titles}`)
256
+ assert.deepEqual(titles.filter(t => !t.startsWith('figure.label.palette-purpose-')), [])
257
+ })
258
+
259
+ test('a colour picked is one step, and the part takes it', async () => {
260
+ const start = await revision()
261
+ assert.notEqual((await read(() => editing.material('bin'))).token, 'palette.accent')
262
+ await click(await read(() => editing.at('button[swatch][title="figure.label.palette-purpose-accent"]', 'figure-material-panel')))
263
+ assert.equal(await revision(), start + 1, 'one step')
264
+ assert.equal((await read(() => editing.material('bin'))).token, 'palette.accent')
265
+ })
266
+
267
+ test('the glow is drawn while its slider is held, nothing written; the release writes it once', async () => {
268
+ const start = await revision()
269
+ const thumb = await read(() => editing.range('figure-material-panel'))
270
+ assert.ok(thumb, 'the glow slider')
271
+ assert.equal(await look('bin').then(l => l.glow), 0)
272
+ const held = await drag(thumb, thumb.pxPerUnit * 0.5, async () => ({ glow: (await look('bin')).glow, revision: await revision() }))
273
+ assert.ok(held.glow > 0.2, `drawn glowing while held: ${held.glow}`)
274
+ assert.equal(held.revision, start, 'nothing written while held')
275
+ assert.equal(await revision(), start + 1, 'the release is one step')
276
+ assert.ok((await read(() => editing.material('bin'))).emissive?.intensity > 0.2)
277
+ })
278
+
279
+ test('a drum offers the four common segment counts as outlines, the rest folded away', async () => {
280
+ await pick('drum')
281
+ const offered = await read(() => {
282
+ const panel = [...document.querySelector('figure-editor').renderRoot.querySelectorAll('figure-material-panel')][0].shadowRoot
283
+ const free = panel.querySelector('details[free]')
284
+ return {
285
+ common: [...panel.querySelectorAll('div[segments] > button[seg]')].map(b => !!b.querySelector('svg polygon')),
286
+ folded: free ? { open: free.open, count: free.querySelectorAll('button').length } : null
287
+ }
288
+ })
289
+ assert.deepEqual(offered.common, [true, true, true, true])
290
+ assert.ok(offered.folded && !offered.folded.open && offered.folded.count > 0, JSON.stringify(offered.folded))
291
+ })
292
+
293
+ test('a single field typed is drawn while it is typed; written when it is left', async () => {
294
+ const start = await revision()
295
+ await type('drum.dimension.radius', '300')
296
+ assert.equal((await look('drum')).size[0], 600, 'drawn 600 across while typed')
297
+ assert.equal(await revision(), start, 'nothing written yet')
298
+ await page().keyboard.press('Enter')
299
+ await sleep(400)
300
+ assert.equal(await revision(), start + 1)
301
+ assert.equal(await read(() => probe.value('drum.dimension.radius')), 300)
302
+ })
303
+ })
304
+
305
+ describe('values in hand on the properties tab: drawn at once, written on change (user 2026-09-24)', () => {
306
+ before(async () => {
307
+ await mount('box')
308
+ await pick('box')
309
+ })
310
+
311
+ test('a size typed is drawn while it is typed, and written once on Enter', async () => {
312
+ const start = await revision()
313
+ await type('box.size.x', '700')
314
+ assert.equal((await look('box')).size[0], 700, 'drawn 700 wide while typed')
315
+ assert.equal(await revision(), start, 'nothing written yet')
316
+ await page().keyboard.press('Enter')
317
+ await sleep(400)
318
+ assert.equal(await revision(), start + 1)
319
+ })
320
+
321
+ test('once written, the value in hand is let go: undone, the canvas draws the figure as it stands', async () => {
322
+ await press(...UNDO)
323
+ assert.equal(await read(() => probe.value('box.size.x')), 400, 'undone')
324
+ assert.equal((await look('box')).size[0], 400, 'drawn as it stands, not as last typed')
325
+ })
326
+
327
+ test('a label dragged sideways scrubs the value: drawn while held, one step on release', async () => {
328
+ const start = await revision()
329
+ const label = await read(() => editing.fieldLabel('box.size.x'))
330
+ assert.ok(label, 'the X of the size row')
331
+ const held = await drag(label, 60, async () => ({ drawn: (await look('box')).size[0], shown: await read(() => editing.fieldValue('box.size.x')), revision: await revision() }))
332
+ assert.equal(held.drawn, 460, 'one millimetre a pixel at 400 mm')
333
+ assert.equal(held.shown, '460', 'the field follows')
334
+ assert.equal(held.revision, start, 'nothing written while held')
335
+ assert.equal(await revision(), start + 1, 'the release is one step')
336
+ assert.equal(await read(() => probe.value('box.size.x')), 460)
337
+ })
338
+ })
339
+
340
+ describe('the pose sliders: drawn while dragged, one step on release; the pose kept as the default (user 2026-09-24)', () => {
341
+ before(async () => {
342
+ await mount('six-axis')
343
+ await click(await read(() => probe.tab('figure.label.tab-short-motion')))
344
+ })
345
+
346
+ test('one drag of a slider is one step, written on release, none while held', async () => {
347
+ const start = await revision()
348
+ const thumb = await read(() => probe.slider('Base pan (J1)'))
349
+ assert.ok(thumb, 'the J1 slider')
350
+ const held = await drag(thumb, thumb.pxPerUnit * 60, async () => ({ revision: await revision(), drawn: await read(() => probe.drawn('finger-left')) }))
351
+ assert.equal(held.revision, start, 'nothing written while held')
352
+ assert.equal(await revision(), start + 1, 'one step on release')
353
+ assert.deepEqual(await read(() => probe.drawn('finger-left')), held.drawn, 'the release lands where the drag showed')
354
+ })
355
+
356
+ test('the pose looked at is kept as the one the figure stands in, in one step', async () => {
357
+ const before = await read(() => editing.pose())
358
+ assert.notDeepEqual(before.now, { ...before.now, ...before.defaults }, 'the pose looked at differs from the one it stands in')
359
+ const keep = await read(() => editing.button('figure.button.keep-pose'))
360
+ assert.ok(keep && !keep.disabled, 'offered')
361
+ const start = await revision()
362
+ await click(keep)
363
+ assert.equal(await revision(), start + 1, 'one step')
364
+ const after = await read(() => editing.pose())
365
+ assert.deepEqual(after.defaults, after.now)
366
+ })
367
+
368
+ test('the controls are not offered again among the figure\'s design values', async () => {
369
+ const controls = Object.keys((await read(() => editing.pose())).now)
370
+ assert.ok(controls.length > 0)
371
+ await click(await read(() => probe.tab('figure.label.tab-short-properties')))
372
+ const shown = await read(() => editing.propertyInputs())
373
+ assert.deepEqual(shown.filter(label => controls.includes(label)), [])
374
+ })
375
+ })
376
+
377
+ describe('a clip scrubbed and keyed (2026-09-24)', () => {
378
+ before(async () => {
379
+ await mount('box', 'doors')
380
+ await click(await read(() => probe.tab('figure.label.tab-short-motion')))
381
+ })
382
+
383
+ test('the clip bar draws the pose at that moment while held, and writes it once on release', async () => {
384
+ const start = await revision()
385
+ const was = await look('left')
386
+ const bar = await read(() => editing.range('figure-clips'))
387
+ assert.ok(bar, 'the left door\'s clip bar')
388
+ const held = await drag(bar, bar.pxPerUnit * 1, async () => ({ drawn: await look('left'), revision: await revision() }))
389
+ assert.notDeepEqual(held.drawn.turn, was.turn, 'the door is drawn turned while the bar is held')
390
+ assert.equal(held.revision, start, 'nothing written while held')
391
+ assert.equal(await revision(), start + 1, 'one step on release')
392
+ const at = (await read(() => editing.range('figure-clips'))).value
393
+ assert.ok(at > 0.5, `scrubbed to ${at} s`)
394
+ assert.ok(Math.abs((await read(() => probe.value('left-open'))) - 40 * at) < 1e-6, 'the pose of that moment: 80 degrees over 2 seconds')
395
+ assert.deepEqual((await look('left')).turn, held.drawn.turn, 'the release lands where the bar showed')
396
+ })
397
+
398
+ test('a key added at that moment is one step, and the clip has it', async () => {
399
+ const start = await revision()
400
+ await click(await read(() => editing.button('figure.button.add-key-here')))
401
+ assert.equal(await revision(), start + 1, 'one step')
402
+ assert.deepEqual(Object.fromEntries((await read(() => editing.clips())).map(c => [c.clip, c.keys])), { 'left-swing': 3, 'right-swing': 2 })
403
+ })
404
+
405
+ test('a track\'s name stands on its own line, the repeat choice and a small remove button under it', async () => {
406
+ const head = await read(() => editing.trackHead())
407
+ assert.ok(head, 'a track')
408
+ assert.equal(head.name.lines, 1, 'the name is one line, not a letter a line')
409
+ assert.ok(head.name.bottom <= head.choice.top, 'above the choice')
410
+ assert.ok(Math.abs(head.choice.top - head.remove.top) < 2, 'the choice and remove side by side')
411
+ assert.ok(head.remove.width <= 32, `a small button: ${head.remove.width}px`)
412
+ })
413
+ })
414
+
415
+ describe('an edit that would stop a clip is asked first (designer 2026-09-27)', () => {
416
+ before(() => mount('box', 'doors'))
417
+
418
+ const asked = () => read(() => editing.prompt())
419
+ const unchanged = async start => {
420
+ assert.equal(await revision(), start, 'nothing written')
421
+ assert.deepEqual((await read(() => editing.clips())).map(c => c.clip), ['left-swing', 'right-swing'])
422
+ }
423
+
424
+ test('the door removed: asked, naming the clip, and nothing is written until it is answered', async () => {
425
+ await pick('left')
426
+ const start = await revision()
427
+ await press('Delete')
428
+ const prompt = await asked()
429
+ assert.match(prompt?.text ?? '', /figure\.text\.clips-would-go/)
430
+ await unchanged(start)
431
+ await click(prompt.cancel)
432
+ assert.equal(await asked(), null, 'answered: kept')
433
+ await unchanged(start)
434
+ })
435
+
436
+ test('its motion taken away: asked the same way', async () => {
437
+ await pick('left')
438
+ await click(await read(() => probe.tab('figure.label.tab-short-motion')))
439
+ const start = await revision()
440
+ await click(await read(() => editing.button('figure.button.remove-motion')))
441
+ const prompt = await asked()
442
+ assert.match(prompt?.text ?? '', /figure\.text\.clips-would-go/)
443
+ await unchanged(start)
444
+ await click(prompt.cancel)
445
+ })
446
+
447
+ test('agreed, exactly that clip goes in one step, and one undo brings it back', async () => {
448
+ const start = await revision()
449
+ await click(await read(() => editing.button('figure.button.remove-motion')))
450
+ await click((await asked()).confirm)
451
+ assert.equal(await revision(), start + 1, 'one step')
452
+ assert.deepEqual((await read(() => editing.clips())).map(c => c.clip), ['right-swing'], 'only the left door\'s clip')
453
+ await press(...UNDO)
454
+ assert.deepEqual((await read(() => editing.clips())).map(c => c.clip), ['left-swing', 'right-swing'], 'back')
455
+ })
456
+
457
+ test('the figure changed while the question was read: the answer is not taken, and the clip stays', async () => {
458
+ await click(await read(() => editing.button('figure.button.remove-motion')))
459
+ const prompt = await asked()
460
+ assert.ok(prompt, 'asked')
461
+ /* Another change lands meanwhile, as one from elsewhere would. */
462
+ await read(() => document.querySelector('figure-editor').session.command({ kind: 'rename', name: 'Doors renamed' }))
463
+ const start = await revision()
464
+ await click(prompt.confirm)
465
+ assert.equal((await state()).error, 'figure.text.clips-consent-stale')
466
+ await unchanged(start)
467
+ })
468
+ })
469
+
470
+ describe('the modeller\'s keys are the page\'s, while the editor is on it', () => {
471
+ before(() => mount('box'))
472
+
473
+ test('Delete with nothing focused removes the picked part; taken off the page, the editor hears no key', async () => {
474
+ await pick('box')
475
+ await press('Delete')
476
+ assert.deepEqual(await read(() => probe.ids()), [], 'removed')
477
+ const removed = await revision()
478
+ await read(() => {
479
+ window.__editor = document.querySelector('figure-editor')
480
+ window.__editor.remove()
481
+ })
482
+ await press(...UNDO)
483
+ assert.deepEqual(await read(() => ({ revision: __editor.session.read().revision, ids: __editor.session.read().geometry.map(g => g.id) })), { revision: removed, ids: [] })
484
+ })
485
+ })
486
+
487
+ describe('the joint fan is shown, never picked (ADR-0097 §5.5)', () => {
488
+ before(async () => {
489
+ await mount('arm')
490
+ await pick('upper-arm')
491
+ })
492
+
493
+ test('a click where the fan lies over a part picks the part behind it', async () => {
494
+ const at = await read(() => editing.fanOverPart())
495
+ assert.ok(at, 'the fan lies over a part somewhere')
496
+ await click(at)
497
+ assert.equal((await state()).selected, at.behind)
498
+ })
499
+ })
500
+
501
+ describe('the recipe catalog says nothing where a template has no guide', () => {
502
+ const templates = () =>
503
+ ['sliding-door', 'hinged-door'].map(name => {
504
+ const recipe = JSON.parse(readFileSync(resolve(HERE, `../../../figure-service/recipes/${name}/recipe.json`), 'utf-8'))
505
+ return { name, category: recipe.category, tags: recipe.tags, target: recipe.target, title: recipe.title.en, description: recipe.description.en, hasThumbnail: false, recipe }
506
+ })
507
+
508
+ test('a card with no guide shows its details and no guide at all', async () => {
509
+ await mount('room', null, templates())
510
+ await pick('room')
511
+ await click(await read(() => editing.at('button[recipe-catalog]')))
512
+ await click(await read(() => editing.at('button[card="hinged-door"]')))
513
+ await sleep(800)
514
+ assert.ok(await read(() => editing.at('button[insert]')), 'the card is told')
515
+ assert.equal(await read(() => editing.at('section[detail] ox-markdown')), null)
516
+ })
517
+ })
518
+
519
+ /*
520
+ The size gizmo's reason says what the figure's placement keeps (designer 2026-09-27). The page loads no translations;
521
+ here the two sentences are given words, so the word the reason carries can be read.
522
+ */
523
+ describe('the size gizmo withheld from a tilted part says what the placement keeps', () => {
524
+ before(async () => {
525
+ await mount('box', 'tilted-ceiling')
526
+ await read(() =>
527
+ editing.words({
528
+ 'figure.text.gizmo-size-turned-fastening': '{part} is tilted: its size is typed, keeping {extreme}',
529
+ 'figure.label.kept-extreme-ceiling': 'its highest point',
530
+ 'figure.label.kept-extreme-floor': 'its lowest point'
531
+ })
532
+ )
533
+ await pick('box')
534
+ })
535
+ after(() => read(() => editing.words(null)))
536
+
537
+ test('hung from the ceiling, the tooltip and the refusal of R both name its highest point', async () => {
538
+ const resize = (await read(() => editing.tools())).find(t => t.label === 'figure.button.gizmo-scale')
539
+ assert.equal(resize.disabled, true, 'not offered')
540
+ assert.ok(resize.title.endsWith('box is tilted: its size is typed, keeping its highest point'), resize.title)
541
+ await read(() => editing.told())
542
+ await press('KeyR')
543
+ assert.deepEqual((await read(() => editing.told())).filter(n => n.level === 'error').map(n => n.message), ['box is tilted: its size is typed, keeping its highest point'])
544
+ assert.equal((await state()).attached, null, 'no gizmo on it')
545
+ })
546
+ })
547
+
548
+ test('the page raised no error', () => {
549
+ assert.deepEqual(opened.errors, [])
550
+ })
@@ -0,0 +1,113 @@
1
+ /*
2
+ * The page `links.check.mjs` drives: the editor of `modeller-page.js`, and a few more readings of what is on the page --
3
+ * an element found through every shadow root by what it says, what the canvas draws, what the panels tell the editor.
4
+ * Only reading, apart from `tell`: the event a panel sends the editor, for a command no control on the page offers.
5
+ */
6
+ import './modeller-page.js'
7
+
8
+ function deep(selector, root = document) {
9
+ const found = [...root.querySelectorAll(selector)]
10
+ for (const element of root.querySelectorAll('*')) if (element.shadowRoot) found.push(...deep(selector, element.shadowRoot))
11
+ return found
12
+ }
13
+
14
+ const words = element => element.textContent.replace(/\s+/g, ' ').trim()
15
+ /** An element whose words or accessible name hold `text` (the page loads no translations: words are i18n keys). */
16
+ const saying = text => element => text === undefined || words(element).includes(text) || element.getAttribute('aria-label') === text
17
+ const canvas = () => deep('figure-canvas')[0]
18
+
19
+ /** A point the element itself is hit at: its centre, or the first point of a grid over it that hits it. */
20
+ function pointOf(element) {
21
+ element.scrollIntoView({ block: 'center' })
22
+ const box = element.getBoundingClientRect()
23
+ const root = element.getRootNode()
24
+ for (const fy of [0.5, 0.3, 0.7, 0.15, 0.85])
25
+ for (const fx of [0.5, 0.3, 0.7, 0.15, 0.85]) {
26
+ const x = box.left + box.width * fx,
27
+ y = box.top + box.height * fy
28
+ const hit = root.elementFromPoint ? root.elementFromPoint(x, y) : null
29
+ if (hit && (hit === element || element.contains(hit))) return { x, y }
30
+ }
31
+ return { x: box.left + box.width / 2, y: box.top + box.height / 2 }
32
+ }
33
+
34
+ const heard = {}
35
+
36
+ window.look = {
37
+ /** Every element matching the selector that says `text`: its words and state. */
38
+ all(selector, text) {
39
+ return deep(selector)
40
+ .filter(saying(text))
41
+ .map(e => ({ text: words(e), disabled: !!e.disabled, open: !!e.open, value: e.value ?? null, label: e.getAttribute('aria-label'), readOnly: !!e.readOnly, spotlit: e.hasAttribute('spotlit') }))
42
+ },
43
+ /** A face of the target box in the fastening drawing: whether it can be pressed now, and whether it is the one chosen. */
44
+ face(f) {
45
+ const path = deep(`path[face="${f}"]`)[0]
46
+ if (!path) return null
47
+ const style = path.getAttribute('style') ?? ''
48
+ return { pickable: style.includes('cursor:pointer'), chosen: style.includes('stroke-width:2') }
49
+ },
50
+ /** The page point of the first element matching the selector that says `text`, to click. */
51
+ at(selector, text) {
52
+ const element = deep(selector).find(saying(text))
53
+ return element ? { ...pointOf(element), disabled: !!element.disabled } : null
54
+ },
55
+ /** A select by the words just before it: its value and its options. */
56
+ select(label) {
57
+ const s = deep('select').find(one => one.previousElementSibling && words(one.previousElementSibling) === label)
58
+ return s ? { value: s.value, options: [...s.options].map(o => ({ value: o.value, text: words(o), disabled: o.disabled })) } : null
59
+ },
60
+ /** What the canvas draws now, by name. */
61
+ drawn() {
62
+ return canvas()?.scene?.root?.components?.map(c => c.state.name) ?? []
63
+ },
64
+ /** The face a size drag keeps, as the canvas gives it to a drawn part. */
65
+ scaleAnchor(name) {
66
+ return canvas()?.scene?.root?.components?.find(c => c.state.name === name)?.state.scaleAnchor ?? null
67
+ },
68
+ /**
69
+ * From now on, keep what every event of this type carries: anywhere on the page, or -- `section` given -- sent by that
70
+ * part panel to the editor, which it still is once the panel has left the page.
71
+ */
72
+ hear(type, section) {
73
+ const key = section ? `${section}:${type}` : type
74
+ const keep = e => heard[key].push(e.detail === undefined ? null : JSON.parse(JSON.stringify(e.detail)))
75
+ if (section) deep('figure-links').find(l => l.section === section).addEventListener(type, keep)
76
+ else if (!heard[key]) document.addEventListener(type, keep, true)
77
+ heard[key] = []
78
+ },
79
+ heard(type, section) {
80
+ return heard[section ? `${section}:${type}` : type] ?? []
81
+ },
82
+ /** The side tab on now, by its name. */
83
+ tab() {
84
+ return deep('button[tab][on]')[0]?.getAttribute('aria-label') ?? null
85
+ },
86
+ /** Whether the first element matching is wholly in the window. */
87
+ inView(selector) {
88
+ const e = deep(selector)[0]
89
+ if (!e) return false
90
+ const r = e.getBoundingClientRect()
91
+ return r.top >= 0 && r.bottom <= innerHeight
92
+ },
93
+ /** Whether the focus inside the first `host` is on an element matching `selector`. */
94
+ focusedIn(host, selector) {
95
+ const active = deep(host)[0]?.shadowRoot?.activeElement
96
+ return !!active && active.matches(selector)
97
+ },
98
+ /** What a part's row in the list says it is fastened to; null for none. */
99
+ fastenedTo(id) {
100
+ const label = deep('span[label]').find(s => s.getAttribute('title') === id)
101
+ const small = label?.querySelector('small[fastened-to]')
102
+ return small ? words(small) : null
103
+ },
104
+ /** An event a part's panel (`section`: shape, places or motion) sends the editor, as the panel would send it. */
105
+ tell(section, type, detail) {
106
+ const panel = deep('figure-links').find(l => l.section === section)
107
+ panel.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
108
+ },
109
+ /** What the canvas tells the editor when a drag on a part ends. */
110
+ transformed(id, result) {
111
+ canvas().dispatchEvent(new CustomEvent('part-transformed', { detail: { id, result }, bubbles: true, composed: true }))
112
+ }
113
+ }