@things-factory/figure-ui 10.1.147 → 10.1.149

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 (209) 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.mjs +6 -1
  141. package/test/browser/editing-page.js +224 -0
  142. package/test/browser/editing.check.mjs +550 -0
  143. package/test/browser/links-page.js +113 -0
  144. package/test/browser/links.check.mjs +568 -0
  145. package/test/browser/modeller.check.mjs +21 -0
  146. package/test/browser/parts-page.js +120 -0
  147. package/test/browser/parts.check.mjs +328 -0
  148. package/test/browser/places-mesh-page.js +36 -0
  149. package/test/browser/places-mesh.check.mjs +239 -0
  150. package/test/browser/product-page.check.mjs +682 -0
  151. package/test/browser/product-page.js +276 -0
  152. package/test/browser/screen-page.js +112 -0
  153. package/test/browser/screen-product-page.js +62 -0
  154. package/test/browser/screen.check.mjs +1003 -0
  155. package/test/browser/section-page.js +256 -0
  156. package/test/browser/section.check.mjs +719 -0
  157. package/test/browser/serve.mjs +20 -12
  158. package/test/carrier-declaration.test.ts +69 -0
  159. package/test/cavity-drawing.test.ts +2 -18
  160. package/test/clip-ai-proposal.test.ts +1 -5
  161. package/test/clip-consent.test.ts +0 -5
  162. package/test/clips.test.ts +0 -7
  163. package/test/command-preview.test.ts +0 -68
  164. package/test/controls.test.ts +0 -9
  165. package/test/detail-dock.test.ts +6 -25
  166. package/test/face-mapping.test.ts +1 -20
  167. package/test/fasten-drawing.test.ts +0 -23
  168. package/test/fields.test.ts +6 -33
  169. package/test/figure-assistant-surface.test.ts +32 -38
  170. package/test/first-minute.test.ts +3 -55
  171. package/test/first-save-suggest.test.ts +11 -8
  172. package/test/first-save.test.ts +4 -26
  173. package/test/gizmo.test.ts +0 -25
  174. package/test/joint-fan.test.ts +0 -10
  175. package/test/keys.test.ts +0 -7
  176. package/test/layout.test.ts +4 -73
  177. package/test/links.test.ts +48 -67
  178. package/test/live.test.ts +0 -34
  179. package/test/material.test.ts +17 -26
  180. package/test/maturity.test.ts +13 -16
  181. package/test/mesh-import.test.ts +76 -0
  182. package/test/modeller-route.test.ts +3 -19
  183. package/test/package-download.test.ts +6 -7
  184. package/test/parts-wiring.test.ts +9 -51
  185. package/test/preview.test.ts +5 -46
  186. package/test/properties.test.ts +11 -26
  187. package/test/proposal-record.test.ts +2 -19
  188. package/test/recipes.test.ts +0 -15
  189. package/test/refusals.test.ts +257 -151
  190. package/test/release-entry.test.ts +1 -9
  191. package/test/release-panel.test.ts +1 -22
  192. package/test/resize-with-placement.test.ts +0 -32
  193. package/test/revert-choice.test.ts +1 -22
  194. package/test/revert-keeping.test.ts +1 -12
  195. package/test/save-landed.test.ts +16 -14
  196. package/test/scene-canvas.test.ts +2 -13
  197. package/test/section-editor.test.ts +30 -83
  198. package/test/section-panel.test.ts +22 -17
  199. package/test/slot-layout-host.test.ts +117 -0
  200. package/test/theme.test.ts +5 -9
  201. package/test/verdict.test.ts +44 -41
  202. package/test/view.test.ts +5 -12
  203. package/translations/en.json +61 -1
  204. package/translations/es.json +61 -1
  205. package/translations/ja.json +61 -1
  206. package/translations/ko.json +61 -1
  207. package/translations/ms.json +61 -1
  208. package/translations/zh.json +61 -1
  209. package/test/thumbnail.test.ts +0 -29
@@ -0,0 +1,568 @@
1
+ /*
2
+ * The part's ties in a real browser -- its size rules, its fastening, its row, its motion, what detaching and a size
3
+ * drag would do -- driven as a person does: a row in the parts list clicked, a tab, a select chosen, a number typed.
4
+ * Then what is on the page and what the session holds are read. The page loads no translations, so the words read are
5
+ * i18n keys. Where no control on the page sends a command (a loop the target list does not offer, a refused preview, a
6
+ * size drag that still arrives) the event a panel or the canvas sends the editor is sent as they send it.
7
+ *
8
+ * Run: node --test test/browser/links.check.mjs
9
+ */
10
+ import { test, describe, before, after } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import {
14
+ addFigureBox,
15
+ addFigurePrimitive,
16
+ applyFigureAuthoring,
17
+ applyFigureProposal,
18
+ createFigureAsset,
19
+ planFigureResize,
20
+ proposeFigureOccupancy,
21
+ serializeFigureAsset
22
+ } from '@hatiolab/figure-model'
23
+
24
+ import { buildPage } from './build.mjs'
25
+ import { openPage } from './serve.mjs'
26
+
27
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
28
+
29
+ let opened
30
+ before(async () => {
31
+ opened = await openPage(await buildPage('links-page.js'))
32
+ })
33
+ after(async () => {
34
+ await opened?.close()
35
+ })
36
+
37
+ const page = () => opened.page
38
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
39
+ const state = () => read(() => probe.state())
40
+ const source = () => read(() => probe.source())
41
+ const at = (selector, text) => read((s, t) => look.at(s, t), selector, text)
42
+ const all = (selector, text) => read((s, t) => look.all(s, t), selector, text)
43
+ const drawn = () => read(() => look.drawn())
44
+ const heard = (type, section) => read((t, s) => look.heard(t, s), type, section)
45
+
46
+ /** Polls the page until `fn` gives something truthy, and gives it; or fails saying what was waited for. */
47
+ async function until(fn, args, what, ms = 8000) {
48
+ const deadline = Date.now() + ms
49
+ for (;;) {
50
+ const got = await read(fn, ...args)
51
+ if (got) return got
52
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
53
+ await sleep(50)
54
+ }
55
+ }
56
+ /** Exactly one step written since `start`: waited for, since the panel's command reaches the session after the click. */
57
+ async function oneStep(start) {
58
+ await until(from => probe.state().revision > from, [start.revision], 'the step written')
59
+ await settled()
60
+ await sleep(150)
61
+ assert.equal((await state()).revision, start.revision + 1, 'one step')
62
+ }
63
+ /** The editor has handled what it was told and drawn it. */
64
+ const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
65
+
66
+ async function mount(kind, asset) {
67
+ await read((kind, text) => harness.mount(kind, text), kind, asset ? serializeFigureAsset(asset) : null)
68
+ /* Drawn in 3D before anything else: a board taken down while its 3D is still coming up throws in things-scene. */
69
+ await until(
70
+ () => {
71
+ /* Not drawn yet is not an error: until the canvas stands, reading it throws. */
72
+ try {
73
+ return probe.ids().some(id => !!probe.partOnPage(id))
74
+ } catch {
75
+ return false
76
+ }
77
+ },
78
+ [],
79
+ 'the figure drawn in 3D',
80
+ 15000
81
+ )
82
+ }
83
+ async function click(point, what = 'something') {
84
+ assert.ok(point, `${what} is on the page to click`)
85
+ await page().mouse.click(point.x, point.y)
86
+ await settled()
87
+ }
88
+ async function pick(part) {
89
+ await click(await read(id => probe.row(id), part), `the row of ${part}`)
90
+ await until(id => probe.state().selected === id, [part], `${part} selected`)
91
+ }
92
+ const tab = name => read(l => probe.tab(l), `figure.label.tab-short-${name}`).then(point => click(point, `the ${name} tab`))
93
+ /** An element handle found through every shadow root. */
94
+ const handle = (selector, test) =>
95
+ page().evaluateHandle(
96
+ (selector, test) => {
97
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
98
+ const label = e => e.previousElementSibling?.textContent.replace(/\s+/g, ' ').trim()
99
+ return deep().find(e => !test || label(e) === test) ?? null
100
+ },
101
+ selector,
102
+ test
103
+ )
104
+ /** Chooses an option of the select labelled `label`, as a person does: the select changes, once. */
105
+ async function choose(label, value, selector = 'select') {
106
+ const select = await handle(selector, label)
107
+ assert.ok(await select.evaluate(s => !!s), `the select ${label}`)
108
+ await select.evaluate(s => s.scrollIntoView({ block: 'center' }))
109
+ await select.select(value)
110
+ await settled()
111
+ }
112
+ /** Types into the field: selected whole, the text, Tab. */
113
+ async function type(field, text) {
114
+ assert.ok(await field.evaluate(i => !!i), 'the field is on the page')
115
+ await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
116
+ await field.click({ clickCount: 3 })
117
+ await page().keyboard.type(text)
118
+ await page().keyboard.press('Tab')
119
+ await settled()
120
+ }
121
+
122
+ const box = (asset, id, [x, y, z], at = [0, 0, 0]) => addFigureBox(asset, { id, size: { x, y, z }, position: { x: at[0], y: at[1], z: at[2] } })
123
+ const fasten = (asset, part, to, more = {}) => applyFigureAuthoring(asset, { kind: 'attach', part, face: 'bottom', to: to === 'plane' ? { plane: 'mounting-plane' } : { part: to, face: 'top' }, ...more })
124
+ const SIZE = { x: { min: 100, max: 4000 }, y: { min: 100, max: 4000 }, z: { min: 100, max: 4000 } }
125
+
126
+ describe('a size rule that would follow itself, and a fastening that would hang from itself, are refused and said', () => {
127
+ before(async () => {
128
+ /* a's width follows b's; b stands fastened on a. */
129
+ let a = box(box(createFigureAsset('Linked'), 'a', [200, 100, 200], [0, 50, 0]), 'b', [300, 100, 200], [0, 150, 0])
130
+ a = applyFigureAuthoring(a, { kind: 'link-dimension', part: 'a', axis: 'x', source: { of: 'part', part: 'b', axis: 'x' } })
131
+ await mount('linked', fasten(a, 'b', 'a'))
132
+ })
133
+
134
+ test('b made to follow a\'s width: refused, said as a loop, nothing written', async () => {
135
+ await pick('b')
136
+ await click(await at('details[resize-choice] > summary'), 'the advanced size choice')
137
+ const start = await state()
138
+ await choose('X', 'part:a:x', 'details[resize-choice] select')
139
+ await until(() => probe.state().error === 'figure.text.follow-would-loop', [], 'the loop said')
140
+ assert.equal((await state()).revision, start.revision, 'nothing written')
141
+ })
142
+
143
+ test('a fastened to b, which hangs from it: refused, said as a loop, nothing written', async () => {
144
+ await pick('a')
145
+ const start = await state()
146
+ await read(() => look.tell('shape', 'command', { action: { kind: 'attach', part: 'a', face: 'bottom', to: { part: 'b', face: 'top' } } }))
147
+ await until(() => probe.state().error === 'figure.text.fasten-would-loop', [], 'the loop said')
148
+ assert.equal((await state()).revision, start.revision, 'nothing written')
149
+ })
150
+ })
151
+
152
+ describe('a part made to follow the figure size, and let go of it', () => {
153
+ before(async () => {
154
+ let a = box(createFigureAsset('Sized'), 'box', [400, 400, 400], [0, 200, 0])
155
+ a = applyFigureAuthoring(a, { kind: 'declare-instance-size', range: SIZE, at: { x: 400, y: 400, z: 400 } })
156
+ await mount('sized', applyFigureAuthoring(a, { kind: 'link-dimension', part: 'box', axis: 'x', source: { of: 'instance', axis: 'x' } }))
157
+ await pick('box')
158
+ })
159
+
160
+ test('on a figure with a size, follow-size is one step, says nothing of a size declared, and is not offered again', async () => {
161
+ await read(() => look.hear('notify'))
162
+ const start = await state()
163
+ await click(await at('button[follow-size]'), 'follow-size')
164
+ await oneStep(start)
165
+ await until(() => !look.at('button[follow-size]'), [], 'the button gone once every side follows')
166
+ assert.equal((await all('p[hint]', 'figure.text.resize-all-following')).length, 1, 'said that every side follows')
167
+ const told = (await heard('notify')).filter(n => n?.level === 'info' && String(n.message).includes('figure.text.size-declared-to-follow'))
168
+ assert.deepEqual(told, [], 'the figure had its size: no line says it was declared')
169
+ })
170
+
171
+ test('a side let go is one step and keeps the size it had', async () => {
172
+ const start = await state()
173
+ await click(await at('button[resize-unfollow="x"]'), 'let go of X')
174
+ await oneStep(start)
175
+ assert.equal(await read(() => probe.value('box.size.x')), 400, 'its own width is the 400 it was')
176
+ assert.equal(Math.round((await read(() => probe.body('box'))).dimensions['size.x']), 400)
177
+ assert.ok(await at('button[follow-size]'), 'follow-size is offered again')
178
+ })
179
+ })
180
+
181
+ /* A base and a flange, sized; the height may be placed from 90 mm though the parts measure down to 1. */
182
+ function sizedPair(turning) {
183
+ let a = box(createFigureAsset('Probe'), 'base', [200, 100, 200], [0, 50, 0])
184
+ a = addFigurePrimitive(a, { id: 'flange', kind: 'cylinder', dimensions: { radius: 20, length: 10 }, position: { x: 0, y: 105, z: 0 } })
185
+ for (const axis of ['x', 'y', 'z']) {
186
+ a.document.model.inputs.push({ id: `size.${axis}`, unit: 'mm', min: axis === 'y' ? 90 : 1, max: axis === 'y' ? 1000 : 20000, role: 'design' })
187
+ a.designInputs[`size.${axis}`] = axis === 'y' ? 100 : 200
188
+ }
189
+ if (!turning) return a
190
+ a = fasten(fasten(a, 'base', 'plane'), 'flange', 'base')
191
+ const j = a.joints.find(o => o.body1.part === 'flange')
192
+ return applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', lowerLimit: 0, upperLimit: 90, state: { id: 'flange-turn' } } })
193
+ }
194
+
195
+ describe('the sizes a figure may be placed at, and how it grows', () => {
196
+ test('measured again, the declared range becomes the measured one in one step, and the offer goes', async () => {
197
+ await mount('sized-pair', sizedPair(false))
198
+ const start = await state()
199
+ await click(await at('button', 'figure.button.remeasure-range'), 'remeasure')
200
+ await oneStep(start)
201
+ const y = (await source()).document.model.inputs.find(i => i.id === 'size.y')
202
+ assert.deepEqual([y.min, y.max], [1, 1000], 'the height from 1 mm, as measured')
203
+ assert.equal(await at('button', 'figure.button.remeasure-range'), null, 'nothing left to measure again')
204
+ })
205
+
206
+ test('a figure with no joint is not urged to grow by one ratio; declared, the rule is said', async () => {
207
+ assert.deepEqual(await all('p[hint]', 'figure.text.sizing-uniform-proposed'), [], 'not urged')
208
+ assert.deepEqual(await all('div[inline-field] span', 'figure.label.sizing-uniform'), [], 'no rule yet')
209
+ await click(await at('button', 'figure.button.scale-uniformly'), 'scale uniformly')
210
+ assert.equal((await all('div[inline-field] span', 'figure.label.sizing-uniform')).length, 1, 'the rule said')
211
+ })
212
+
213
+ test('a figure that turns about a joint is urged to grow by one ratio', async () => {
214
+ await mount('turning', sizedPair(true))
215
+ assert.equal((await all('p[hint]', 'figure.text.sizing-uniform-proposed')).length, 1)
216
+ })
217
+ })
218
+
219
+ /* A conveyor's rollers in a row across its frame, 200 mm apart. */
220
+ function conveyor() {
221
+ let a = fasten(box(createFigureAsset('Conveyor'), 'frame', [2000, 100, 600], [0, 50, 0]), 'frame', 'plane')
222
+ a = fasten(box(a, 'roller', [60, 60, 560], [0, 130, 0]), 'roller', 'frame')
223
+ return applyFigureAuthoring(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
224
+ }
225
+
226
+ describe('a row\'s pitch', () => {
227
+ test('a pitch that follows the frame\'s length shows as the number it comes to, and the row is said as drawn', async () => {
228
+ await mount('pitch-follows', applyFigureAuthoring(conveyor(), { kind: 'set-repeat', part: 'roller', pitch: { from: { of: 'part', part: 'frame', axis: 'x' }, times: 0.1 } }))
229
+ await pick('roller')
230
+ assert.deepEqual((await all('input[aria-label="roller.repeat.pitch"]')).map(i => i.value), ['200'], 'a tenth of 2000')
231
+ assert.ok((await all('ul[row-reading] li')).length > 0, 'the row said as it stands')
232
+ })
233
+
234
+ test('a pitch the board may set, typed into its field, is one edit of the input it reads', async () => {
235
+ const published = applyFigureProposal(conveyor(), [
236
+ { kind: 'add-design-input', id: 'pitch', unit: 'mm', min: 100, max: 400, value: 200 },
237
+ { kind: 'set-repeat', part: 'roller', pitch: { from: { of: 'input', input: 'pitch' } } },
238
+ { kind: 'publish-input', input: 'pitch', label: 'pitch' }
239
+ ])
240
+ await mount('published-pitch', published)
241
+ await pick('roller')
242
+ const start = await state()
243
+ await type(await handle('input[aria-label="roller.repeat.pitch"]'), '300')
244
+ await oneStep(start)
245
+ const now = await source()
246
+ assert.equal(now.designInputs.pitch, 300, 'the input is 300')
247
+ assert.deepEqual(now.joints.find(j => j.body1.part === 'roller').repeat.pitch, { from: { of: 'input', input: 'pitch' } }, 'the row still reads it')
248
+ })
249
+ })
250
+
251
+ describe('holding places on a part', () => {
252
+ test('a place needs kinds it takes; a figure that holds nothing says so, and declaring it holds things is one step', async () => {
253
+ await mount('box')
254
+ await pick('box')
255
+ await tab('capability')
256
+ assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, true, 'no kinds: no place')
257
+ await type(await handle('input[placeholder="BOX, TOTE"]'), 'BOX')
258
+ assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, false, 'a kind: a place may be made')
259
+ assert.equal((await all('p[hint]', 'figure.text.place-needs-holding')).length, 1, 'said: nothing is passed without holding')
260
+ const start = await state()
261
+ await click(await at('button[act]', 'figure.button.declare-holding'), 'declare holding')
262
+ await oneStep(start)
263
+ assert.ok((await source()).document.capabilities.names.includes('Capacity'))
264
+ assert.deepEqual(await all('p[hint]', 'figure.text.place-needs-holding'), [], 'not said again')
265
+ })
266
+ })
267
+
268
+ describe('a position worked out from the figure size', () => {
269
+ test('offered to keep its share of the size; taken, one step, said so and not offered again', async () => {
270
+ let a = box(createFigureAsset('Lifted'), 'plate', [600, 100, 400], [0, 50, 0])
271
+ a = applyFigureAuthoring(a, { kind: 'declare-instance-size', range: { x: { min: 100, max: 6000 }, y: { min: 50, max: 1000 }, z: { min: 100, max: 4000 } }, at: { x: 600, y: 100, z: 400 } })
272
+ a = box(a, 'lamp', [40, 20, 40], [0, 10, 0])
273
+ /* The lamp stands the figure's height above its own number. */
274
+ a.document.model.nodes.push({ id: 'lamp.lift', op: 'add@1', args: ['size.y', 'lamp.position.y'], outputs: { value: 'lamp.lift.value' } })
275
+ a.document.model.nodes.find(n => n.id === 'lamp.pose').args[1] = 'lamp.lift.value'
276
+ await mount('lifted', a)
277
+ await pick('lamp')
278
+ assert.equal((await all('div[pair] span', 'figure.label.position-worked-out')).length, 1)
279
+ const start = await state()
280
+ await click(await at('button[act]', 'figure.button.scale-position'), 'scale position')
281
+ await oneStep(start)
282
+ assert.equal((await all('div[pair] span', 'figure.label.position-in-proportion')).length, 1, 'said in proportion')
283
+ assert.equal(await at('button[act]', 'figure.button.scale-position'), null, 'not offered again')
284
+ })
285
+ })
286
+
287
+ describe('the fastening form on the robot arm', () => {
288
+ test('the upper arm opens on what it is fastened to; a part hanging from it is listed, not choosable, with the reason', async () => {
289
+ await mount('arm')
290
+ await pick('upper-arm')
291
+ const own = (await source()).joints.find(j => j.body1.part === 'upper-arm').body0.part
292
+ const target = await read(() => look.select('figure.label.target-part'))
293
+ assert.equal(target.value, own, 'its own fastening')
294
+ const forearm = target.options.find(o => o.value === 'forearm')
295
+ assert.deepEqual(forearm, { value: 'forearm', text: 'figure.text.target-would-loop', disabled: true })
296
+ })
297
+ })
298
+
299
+ describe('a new fastening for a door leaf: seen before it is done', () => {
300
+ before(async () => {
301
+ await mount('door')
302
+ await read(() => look.hear('preview-command'))
303
+ })
304
+ const ghosts = () => read(() => look.drawn().filter(n => n.startsWith('__preview')))
305
+
306
+ test('started, it asks what to fasten to, with nothing chosen', async () => {
307
+ await pick('leaf')
308
+ await click(await at('button[act]', 'figure.button.fasten-start'), 'fasten-start')
309
+ /* The form is the panel's, drawn after the editor's update. */
310
+ const target = await until(() => look.select('figure.label.target-part'), [], 'the fastening form')
311
+ assert.deepEqual([target.value, target.options[0].text], ['', 'figure.label.target-part-placeholder'])
312
+ })
313
+
314
+ test('the frame chosen: the editor is told the command, and the canvas draws where the leaf would go and the face it meets', async () => {
315
+ await choose('figure.label.target-part', 'frame')
316
+ await until(() => look.drawn().includes('__preview:leaf') && look.drawn().includes('__target-face'), [], 'the leaf and the face drawn')
317
+ const told = (await heard('preview-command')).filter(Boolean).at(-1)
318
+ assert.equal(told?.action.kind, 'attach')
319
+ assert.equal((await source()).joints?.some(j => j.body1.part === 'leaf') ?? false, false, 'nothing written')
320
+ })
321
+
322
+ test('the details fold; a gap set in them is said on the fold, nothing at the default', async () => {
323
+ const details = await all('details[fasten-detail]')
324
+ assert.deepEqual(details.map(d => d.open), [false], 'folded')
325
+ assert.deepEqual(await all('span[fasten-summary]'), [], 'nothing said at the default')
326
+ await click(await at('details[fasten-detail] > summary'), 'the details')
327
+ await type(await handle('details[fasten-detail] input', 'figure.label.gap'), '40')
328
+ assert.deepEqual((await all('span[fasten-summary]')).map(s => s.text), ['figure.text.fasten-summary-gap'])
329
+ })
330
+
331
+ test('on another tab it is not drawn; back on the properties it is', async () => {
332
+ await tab('motion')
333
+ await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing drawn on the motion tab')
334
+ await tab('properties')
335
+ await until(() => look.drawn().includes('__preview:leaf'), [], 'drawn again')
336
+ })
337
+
338
+ test('a refused command clears what was drawn and says why', async () => {
339
+ await read(() => look.tell('shape', 'preview-command', { action: { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'nowhere' } } }))
340
+ await until(() => look.all('[preview-words]', 'figure.text.preview-refused').length > 0, [], 'the refusal said')
341
+ assert.deepEqual(await ghosts(), [], 'nothing of the earlier result drawn')
342
+ })
343
+
344
+ test('the part let go of, the panel leaving tells the editor to clear it', async () => {
345
+ await read(() => look.hear('preview-command', 'shape'))
346
+ await click(await read(id => probe.row(id), 'leaf'), 'the leaf row')
347
+ await page().keyboard.press('Escape')
348
+ await until(() => probe.state().selected === '', [], 'nothing selected')
349
+ assert.deepEqual(await heard('preview-command', 'shape'), [null], 'told once: nothing to show')
350
+ })
351
+
352
+ test('"fasten it first" from the motion tab opens this part\'s fastening, as it is now, in view, on the properties tab', async () => {
353
+ await pick('leaf')
354
+ await click(await at('button[act]', 'figure.button.fasten-start'), 'fasten-start')
355
+ await choose('figure.label.target-part', 'frame')
356
+ /* The panel scrolled back to its head, the fastening below the fold. */
357
+ const panel = await at('section[fastening]')
358
+ await page().mouse.move(panel.x, panel.y)
359
+ await page().mouse.wheel({ deltaY: -5000 })
360
+ await until(() => !look.inView('section[fastening]'), [], 'the fastening section scrolled out of view')
361
+ await tab('motion')
362
+ await click(await at('button[go-fasten]'), 'go-fasten')
363
+ await until(() => look.all('section[fastening]').some(s => s.spotlit), [], 'the fastening section marked')
364
+ assert.equal(await read(() => look.tab()), 'figure.label.tab-short-properties', 'on the properties tab, not another')
365
+ assert.equal((await read(() => look.select('figure.label.target-part'))).value, '', 'the form is the fastening it has: none')
366
+ await until(() => look.inView('section[fastening]'), [], 'the section in view')
367
+ })
368
+
369
+ test('the frame\'s front chosen in the drawing, fastened: the leaf keeps its place across that face', async () => {
370
+ const was = (await read(() => probe.body('leaf'))).t.map(Math.round)
371
+ await choose('figure.label.target-part', 'frame')
372
+ /* The frame's faces are offered once the drawing has the frame: a press before that is not a choice. */
373
+ await until(() => look.face('front')?.pickable, [], 'the front face offered')
374
+ await click(await at('path[face="front"]'), 'the front face in the drawing')
375
+ await until(() => look.face('front').chosen, [], 'the front face drawn as the one chosen')
376
+ const start = await state()
377
+ await click(await at('button[act]', 'figure.button.fasten'), 'fasten')
378
+ assert.equal((await state()).error, '', 'not refused')
379
+ await oneStep(start)
380
+ assert.equal((await source()).joints.find(j => j.body1.part === 'leaf').body0.face, 'front')
381
+ const now = (await read(() => probe.body('leaf'))).t.map(Math.round)
382
+ assert.deepEqual([now[0], now[1]], [was[0], was[1]], `left-right and height kept: was ${was}, now ${now}`)
383
+ })
384
+ })
385
+
386
+ describe('past the edge of the face a part is fastened to', () => {
387
+ before(async () => {
388
+ let a = fasten(box(createFigureAsset('Overhangs'), 'frame', [1000, 100, 400], [0, 50, 0]), 'frame', 'plane')
389
+ /* Pushed 30 mm past the frame's right edge; the same, turned; on a drum's round end; wider than the frame, off to one side. */
390
+ a = fasten(box(a, 'out', [100, 100, 100]), 'out', 'frame', { align: { x: { mm: 480 } } })
391
+ a = fasten(box(a, 'tilted', [100, 100, 100]), 'tilted', 'frame', { align: { x: { mm: 460 } }, twist: 30 })
392
+ a = fasten(addFigurePrimitive(a, { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 200 }, position: { x: 0, y: 0, z: 0 } }), 'drum', 'frame')
393
+ a = fasten(box(a, 'cap', [50, 50, 50]), 'cap', 'drum')
394
+ await mount('overhangs', fasten(box(a, 'plate', [1600, 20, 200]), 'plate', 'frame', { align: { x: { mm: 200 } } }))
395
+ })
396
+ const overhang = () => read(() => look.all('div[overhang] p').map(p => p.text))
397
+
398
+ test('past it: said, and "fix alignment" puts the focus on the face in the drawing', async () => {
399
+ await pick('out')
400
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 1, [], 'said past the edge')
401
+ await click(await at('div[overhang] button', 'figure.button.fix-alignment'), 'fix alignment')
402
+ await until(() => look.focusedIn('figure-fasten-drawing', 'div[where]'), [], 'the face in the drawing focused')
403
+ })
404
+
405
+ test('turned, it may be past it: said so', async () => {
406
+ await pick('tilted')
407
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-possible').length === 1, [], 'said it may be')
408
+ })
409
+
410
+ test('on a round face it is not known: said so', async () => {
411
+ await pick('cap')
412
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-unknown').length === 1, [], 'said not known')
413
+ })
414
+
415
+ test('wider than the face: said in the relation with which side, not as a warning', async () => {
416
+ await pick('plate')
417
+ await until(() => look.all('p[overhang-note]').length === 1, [], 'the note')
418
+ const note = (await all('p[overhang-note]'))[0].text
419
+ assert.ok(note.includes('figure.text.overhang-larger') && note.includes('figure.text.overhang-lopsided'), note)
420
+ assert.deepEqual(await overhang(), [], 'no warning')
421
+ })
422
+ })
423
+
424
+ describe('a bench: what each part is fastened to, what detaching would do, a row seen before it is made', () => {
425
+ before(async () => {
426
+ let a = fasten(box(createFigureAsset('Bench'), 'leg', [60, 700, 60], [-400, 350, 0]), 'leg', 'plane')
427
+ a = fasten(box(a, 'top', [1200, 40, 600], [0, 900, 0]), 'top', 'leg')
428
+ a = fasten(box(a, 'lamp', [80, 80, 80], [200, 960, 100]), 'lamp', 'top')
429
+ await mount('bench', box(a, 'loose', [100, 100, 100], [800, 50, 0]))
430
+ await read(() => look.hear('preview-command'))
431
+ })
432
+
433
+ test('the list names what each part is fastened to, beside its name', async () => {
434
+ const said = await read(() => ['leg', 'top', 'lamp', 'loose'].map(id => look.fastenedTo(id)))
435
+ assert.deepEqual(said, ['figure.label.mounting-plane', 'leg · figure.label.face-top', 'top · figure.label.face-top', null])
436
+ })
437
+
438
+ test('a fastened part picked: the face it is fastened to is lit, and taking it off is said to keep what hangs from it', async () => {
439
+ await pick('top')
440
+ await until(() => look.drawn().includes('__target-face'), [], 'the leg\'s top lit')
441
+ await until(() => look.all('div[fastened] [detach-words]', 'figure.text.detach-keeps-children').length === 1, [], 'said beside the detach button')
442
+ })
443
+
444
+ test('a part that would stay where it stands: nothing is said beside the detach button', async () => {
445
+ await pick('lamp')
446
+ await until(() => look.all('section[fastening] button[act]', 'figure.button.detach').length === 1, [], 'the lamp\'s detach button')
447
+ /* What detaching would do is worked out once the figure rests (150 ms after the pick). */
448
+ await sleep(500)
449
+ assert.deepEqual(await all('[detach-words]'), [])
450
+ })
451
+
452
+ test('a part fastened to nothing: the row is folded under its head and says why', async () => {
453
+ await pick('loose')
454
+ assert.deepEqual((await all('details[row-fold] > summary h3')).map(h => h.text), ['figure.label.repeat'])
455
+ assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [false], 'folded')
456
+ assert.deepEqual((await all('p[row-blocked]')).map(p => p.text), ['figure.text.row-blocked-unfastened'])
457
+ })
458
+
459
+ test('the row worked on is the one seen: its copies drawn and said; back on the fastening, it folds', async () => {
460
+ await pick('lamp')
461
+ assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [false], 'folded')
462
+ await read(() => look.hear('preview-command'))
463
+ /*
464
+ Clicked until it is open: the first click puts the focus in the row, which opens the fold, and the summary's own
465
+ toggle then closes it again -- two clicks open it (seen 2026-10-11).
466
+ */
467
+ for (let i = 0; i < 2 && !(await all('details[row-fold]'))[0].open; i++) await click(await at('details[row-fold] > summary'), 'the row')
468
+ assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [true], 'open while it is worked')
469
+ await until(() => look.all('section[row] input[type=number]').length > 0, [], 'the pitch field in the open row')
470
+ await type(await handle('section[row] input[type=number]'), '250')
471
+ await until(() => look.heard('preview-command').filter(Boolean).at(-1)?.action.pitch === 250, [], 'the row told')
472
+ assert.equal((await heard('preview-command')).filter(Boolean).at(-1).action.kind, 'make-repeat')
473
+ await until(() => look.drawn().filter(n => n.startsWith('__preview')).length > 1, [], 'the copies drawn')
474
+ await until(() => look.all('section[row] [preview-words]', 'figure.text.row-count-pitch').length === 1, [], 'the row said')
475
+ await click(await at('section[fastening] h3 button'), 'the fastening section')
476
+ await until(() => look.all('details[row-fold]').every(d => !d.open), [], 'the row folded again')
477
+ })
478
+ })
479
+
480
+ /* A blade fastened twisted on a hub: no face figure-model can keep for a size drag. */
481
+ function twisted() {
482
+ const a = fasten(box(createFigureAsset('Fan'), 'hub', [200, 100, 200], [0, 50, 0]), 'hub', 'plane')
483
+ return fasten(box(a, 'blade', [300, 20, 60], [0, 110, 0]), 'blade', 'hub', { twist: 30 })
484
+ }
485
+
486
+ describe('a part fastened twisted is not sized by a drag', () => {
487
+ test('the resize tool is off, and a size drag that still arrives is not written and is said', async () => {
488
+ await mount('twisted', twisted())
489
+ await pick('blade')
490
+ assert.equal((await at('button[tool]', 'figure.button.gizmo-scale'))?.disabled, true, 'the resize tool is off')
491
+ const start = await state()
492
+ await read(() => look.transformed('blade', { scale: [1.5, 1, 1] }))
493
+ await settled()
494
+ assert.equal((await state()).revision, start.revision, 'not written')
495
+ assert.equal((await state()).error, 'figure.text.gizmo-size-turned-fastening')
496
+ })
497
+
498
+ test('the R key says why', async () => {
499
+ await mount('twisted', twisted())
500
+ await pick('blade')
501
+ assert.equal((await state()).error, '')
502
+ await page().keyboard.press('KeyR')
503
+ await until(() => probe.state().error === 'figure.text.gizmo-size-turned-fastening', [], 'the reason said')
504
+ })
505
+ })
506
+
507
+ describe('a box made to follow the figure size on the floor', () => {
508
+ before(async () => {
509
+ let a = box(createFigureAsset('Floor'), 'box', [100, 100, 100], [20, 50, 30])
510
+ a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
511
+ await mount('floor', applyFigureProposal(a, planFigureResize(a, { part: 'box' }).steps))
512
+ await pick('box')
513
+ })
514
+
515
+ test('its height is the room\'s only, shown apart; its row shows the number the floor gives; a size drag keeps the bottom', async () => {
516
+ const roomOnly = await all('input[aria-label="box.position.y"]')
517
+ assert.equal(roomOnly.length, 1, 'one field for it, apart from the position row')
518
+ assert.equal(roomOnly[0].disabled, true, 'not typed into')
519
+ assert.equal((await all('section[room-only] input[aria-label="box.position.y"]')).length, 1, 'in the room\'s section')
520
+ const held = (await all('div[vector-row] input[readonly]')).filter(i => i.label?.startsWith('position.y'))
521
+ assert.deepEqual(held.map(i => i.value), ['50'], 'the height the floor gives, read-only')
522
+ assert.deepEqual(await read(() => look.scaleAnchor('box')), { x: 0, y: 1, z: 0 }, 'the bottom kept')
523
+ })
524
+
525
+ test('the room is declared again only when the author confirms', async () => {
526
+ const prompt = id =>
527
+ read(id => {
528
+ const deep = (root = document) => [...root.querySelectorAll('ox-prompt')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
529
+ const button = deep().at(-1)?.shadowRoot?.querySelector(`#${id}`)
530
+ if (!button) return null
531
+ const r = button.getBoundingClientRect()
532
+ return r.width ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null
533
+ }, id)
534
+ const start = await state()
535
+ await click(await at('button[room-refit]'), 'declare the room again')
536
+ await until(() => !!document.querySelector('ox-prompt'), [], 'asked')
537
+ await click(await poll(() => prompt('cancel')), 'cancel')
538
+ await until(() => !document.querySelector('ox-prompt'), [], 'the question gone')
539
+ assert.equal((await state()).revision, start.revision, 'cancelled: nothing written')
540
+ await click(await at('button[room-refit]'), 'declare the room again')
541
+ await click(await poll(() => prompt('confirm')), 'confirm')
542
+ await until(r => probe.state().revision === r + 1, [start.revision], 'confirmed: one step')
543
+ })
544
+ })
545
+
546
+ /** Asks until the answer is something, for a few seconds. */
547
+ async function poll(ask, ms = 5000) {
548
+ const deadline = Date.now() + ms
549
+ for (;;) {
550
+ const answer = await ask()
551
+ if (answer || Date.now() > deadline) return answer
552
+ await sleep(50)
553
+ }
554
+ }
555
+
556
+ describe('a motion on the motion tab', () => {
557
+ test('named by its parts, and drawn at both ends of its range', async () => {
558
+ await mount('arm')
559
+ await pick('upper-arm')
560
+ await tab('motion')
561
+ await until(() => look.drawn().includes('__preview:min:upper-arm') && look.drawn().includes('__preview:max:upper-arm'), [], 'both ends drawn')
562
+ assert.equal((await all('p[motion-summary]', 'figure.text.motion-summary')).length, 1)
563
+ })
564
+ })
565
+
566
+ test('the page raised no error', () => {
567
+ assert.deepEqual(opened.errors, [])
568
+ })
@@ -881,6 +881,27 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
881
881
  })
882
882
  })
883
883
 
884
+ describe('the row section opens on one click', () => {
885
+ test('a part that can be made a row: one click on the folded heading opens it, a second folds it', async () => {
886
+ await mount('rollers')
887
+ await click(await read(() => probe.row('mark')))
888
+ const summary = await page().evaluateHandle(() => {
889
+ const deep = (root = document) => [...root.querySelectorAll('details[row-fold] > summary')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
890
+ return deep()[0] ?? null
891
+ })
892
+ assert.ok(await summary.evaluate(s => !!s && !!s.parentElement.querySelector('button[act]')), 'the mark is offered a row to make')
893
+ const at = await summary.evaluate(s => {
894
+ s.scrollIntoView({ block: 'center' })
895
+ const r = s.getBoundingClientRect()
896
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
897
+ })
898
+ await click(at)
899
+ assert.equal(await summary.evaluate(s => s.parentElement.open), true, 'open after one click')
900
+ await click(at)
901
+ assert.equal(await summary.evaluate(s => s.parentElement.open), false, 'folded after a second')
902
+ })
903
+ })
904
+
884
905
  test('the page raised no error', () => {
885
906
  assert.deepEqual(opened.errors, [])
886
907
  })