@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,1003 @@
1
+ /*
2
+ * The modeller's screen in a real browser: its frame (three panes, the side's six tabs, the bar over the work), a new
3
+ * figure's first minute, the side panel as parts are chosen, the view tab, the preview, the verdict tab and the card's
4
+ * picture -- each read as a person meets it, after doing what a person does (a click, a key, a narrower window).
5
+ *
6
+ * These were source-text checks in the unit suite (layout, first-minute, theme, view, preview, verdict, maturity,
7
+ * thumbnail, scene-canvas); a string found in a file said nothing of what the screen does, and broke on any rewording.
8
+ *
9
+ * The page is `screen-product-page.js`: the editor harness (`window.editorHarness`, `window.probe`) and the modeller page
10
+ * over a server in memory (`window.product`), with the assistant dock's stand-in. The check loads no translations: the
11
+ * words read are the i18n keys.
12
+ *
13
+ * Run: node --test test/browser/screen.check.mjs
14
+ */
15
+ import { test, describe, before, after } from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+
18
+ import { buildPage } from './build.mjs'
19
+ import { openPage } from './serve.mjs'
20
+
21
+ let opened
22
+ before(async () => {
23
+ const bundle = await buildPage('screen-product-page.js', { standIns: { '@things-factory/ai-assistant/dist-client/index.js': 'assistant-stand-in.js' } })
24
+ opened = await openPage(bundle)
25
+ })
26
+ after(async () => {
27
+ await opened?.close()
28
+ })
29
+
30
+ const page = () => opened.page
31
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
32
+ /** Until the condition holds in the page; a condition that never holds fails the test with its name. */
33
+ async function until(what, fn, ...args) {
34
+ try {
35
+ await page().waitForFunction(fn, { timeout: 15000, polling: 50 }, ...args)
36
+ } catch {
37
+ assert.fail(`never: ${what}`)
38
+ }
39
+ }
40
+ /** Clicks, with the mouse, the element `fn` finds in the page. */
41
+ async function press(what, fn, ...args) {
42
+ const element = (await page().evaluateHandle(fn, ...args)).asElement()
43
+ assert.ok(element, `nothing to press: ${what}`)
44
+ await element.click()
45
+ }
46
+ /** The element `fn` finds, for puppeteer's own select and type. */
47
+ async function element(what, fn, ...args) {
48
+ const found = (await page().evaluateHandle(fn, ...args)).asElement()
49
+ assert.ok(found, `not on the page: ${what}`)
50
+ return found
51
+ }
52
+
53
+ /** The editor over a figure: a harness kind (`box`, `arm`, `door`) or one of `screen-page.js`'s figures. */
54
+ async function mount(kind) {
55
+ const harnessKinds = ['box', 'arm', 'door', 'room', 'rollers']
56
+ await boardsUp()
57
+ const text = harnessKinds.includes(kind) ? null : await read(name => figureText(name), kind)
58
+ await read((kind, text) => editorHarness.mount(kind, text), harnessKinds.includes(kind) ? kind : 'given', text)
59
+ /* Standing: the scene up over a figure with parts, the editor drawn over one without. */
60
+ await until('the editor stands', () => {
61
+ const editor = screenCheck.editor()
62
+ const view = editor?.session?.read()
63
+ if (!view) return false
64
+ const canvas = screenCheck.deep('figure-canvas')[0]
65
+ return view.geometry.length === 0 ? !!canvas : !!canvas?.scene?.root?._threeCapability
66
+ })
67
+ }
68
+ /*
69
+ * Every board standing has its 3D up, and two frames drawn: a board taken away while the scene is still bringing its 3D
70
+ * up throws inside things-scene, so a test leaves the boards standing before the next one replaces them.
71
+ */
72
+ async function boardsUp() {
73
+ await until('the boards are up', () => screenCheck.deep('figure-canvas, figure-preview').every(e => !e.scene || !!e.scene.root?._threeCapability))
74
+ for (let i = 0; i < 2; i++) await page().waitForFunction(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done(true)))))
75
+ }
76
+ const revision = () => read(() => screenCheck.editor().session.read().revision)
77
+ const source = () => read(() => screenCheck.editor().session.source())
78
+ const selected = () => read(() => screenCheck.editor().selected)
79
+ /** The side tab now on, by its name (the i18n key). */
80
+ const tabOn = () => read(() => screenCheck.editor().shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') ?? null)
81
+ /** A button of the bar over the work (ox-canvas-bar), by its action. */
82
+ const pressBar = action => press(action, a => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').shadowRoot.querySelector(`button[data-action=${a}]`), action)
83
+ const pressTab = name => press(name, name => screenCheck.editor().shadowRoot.querySelector(`button[tab][aria-label="${name}"]`), name)
84
+ const TAB = name => `figure.label.tab-short-${name}`
85
+ /** A row of the parts list, by the part's name. */
86
+ const pressRow = id => press(`the ${id} row`, id => screenCheck.deep('span[label]').find(s => s.getAttribute('title') === id), id)
87
+
88
+ describe('the verdict tab over a figure, as the modeller page mounts it', () => {
89
+ /* The server's answers set here are this describe's. */
90
+ after(() =>
91
+ read(() => {
92
+ product.answer('inspectFigureDraft')
93
+ product.answer('inspectFigure')
94
+ })
95
+ )
96
+
97
+ test('it draws the report in its order -- how many fit, by axis, budgets, weight, trend, release -- and its scale from the bounds the grade is computed with', async () => {
98
+ await read(text => alone.mount(text, { figureId: 'fig-1' }), await read(() => figureText('two')))
99
+ await until('the report is drawn', () => alone.verdict.shadowRoot.querySelectorAll('section').length >= 6)
100
+ const headings = await read(() => [...alone.verdict.shadowRoot.querySelectorAll('section > h3')].map(h => h.textContent.trim()))
101
+ assert.deepEqual(headings, [
102
+ 'figure.label.maturity',
103
+ 'figure.label.by-axis',
104
+ 'figure.label.budgets',
105
+ 'figure.label.weight',
106
+ 'figure.label.reuse-trend',
107
+ 'figure.label.release-verdict'
108
+ ])
109
+ const scale = await read(() => [...alone.verdict.shadowRoot.querySelectorAll('p[scale] > span')].slice(1).map(s => s.textContent.replace(/\s+/g, ' ').trim()))
110
+ assert.deepEqual(scale, ['A ≤ 50%', 'B ≤ 75%', 'C ≤ 100%', 'D > 100%'])
111
+ })
112
+
113
+ test('the release verdict comes from the server\'s door: the source on screen and the figure\'s id are what is sent', async () => {
114
+ await read(() => product.forget())
115
+ await read(text => alone.mount(text, { figureId: 'fig-1' }), await read(() => figureText('two')))
116
+ await until('the server is asked', () => product.calls().some(c => c.field === 'inspectFigureDraft'))
117
+ const sent = await read(() => product.calls().filter(c => c.field === 'inspectFigureDraft').at(-1).variables)
118
+ assert.deepEqual(sent, { source: await read(() => alone.verdict.revision.text), id: 'fig-1' })
119
+ await until('the answer is shown', () => !!alone.verdict.shadowRoot.querySelector('p[clear]'))
120
+ })
121
+
122
+ test('no 「부품에 맞추기」 over a figure with no parts; over one with parts, it is offered for the same finding', async () => {
123
+ const undeclared = await read(() => [alone.finding('occupancy-undeclared')])
124
+ await read((text, findings) => alone.mount(text, { findings }), await read(() => figureText('empty')), undeclared)
125
+ await until('the finding is shown', () => !!alone.verdict.shadowRoot.querySelector('li[finding]'))
126
+ assert.equal(await read(() => alone.verdict.shadowRoot.querySelectorAll('button[fit]').length), 0, 'nothing to measure, nothing offered')
127
+ await read((text, findings) => alone.mount(text, { findings }), await read(() => figureText('two')), undeclared)
128
+ await until('the finding is shown', () => !!alone.verdict.shadowRoot.querySelector('li[finding]'))
129
+ assert.equal(await read(() => alone.verdict.shadowRoot.querySelectorAll('li[finding] button[fit]').length), 1)
130
+ })
131
+
132
+ test('the verdict tab and the release history offer the fit for the same findings, from questions that ask the same fields', async () => {
133
+ const findings = await read(() => ['part-outside-occupancy', 'occupancy-undeclared', 'mounting-face-floating'].map(code => alone.finding(code)))
134
+ await read((text, findings) => alone.mount(text, { figureId: 'fig-1', findings, history: true }), await read(() => figureText('two')), findings)
135
+ const offered = (host, button) =>
136
+ read(
137
+ (host, button) => {
138
+ const root = document.querySelector(host)?.shadowRoot
139
+ return root ? [...root.querySelectorAll('li[finding]')].filter(li => li.querySelector(button)).map(li => li.querySelector('span[msg]').textContent.trim()).sort() : null
140
+ },
141
+ host,
142
+ button
143
+ )
144
+ await until('both judged', () => ['figure-verdict', 'figure-history-panel'].every(host => document.querySelector(host)?.shadowRoot?.querySelectorAll('li[finding]').length === 3))
145
+ assert.deepEqual(await offered('figure-verdict', 'button[fit]'), ['occupancy-undeclared', 'part-outside-occupancy'])
146
+ assert.deepEqual(await offered('figure-history-panel', 'button[fit-base]'), await offered('figure-verdict', 'button[fit]'), 'the same two, in both places')
147
+ const draft = await read(() => alone.fieldsAsked('inspectFigureDraft'))
148
+ assert.ok(draft?.includes('why') && draft.includes('how'), `the draft's question asks why and how: ${draft}`)
149
+ assert.equal(draft, await read(() => alone.fieldsAsked('inspectFigure')), 'the saved figure\'s question asks the same fields')
150
+ })
151
+ })
152
+
153
+ describe('the frame: parts · canvas · one side of six tabs, and the bar over the work', () => {
154
+ before(() => mount('box'))
155
+
156
+ test('three panes: the parts list on the left, the canvas in the middle, the side on the right -- one side, nothing under the canvas', async () => {
157
+ const frame = await read(() => {
158
+ const root = screenCheck.editor().shadowRoot
159
+ const panes = root.querySelector('ox-three-pane')
160
+ if (!panes) return null
161
+ const left = el => Math.round(el.getBoundingClientRect().left)
162
+ const parts = panes.querySelector(':scope > figure-parts')
163
+ const canvas = panes.querySelector('figure-canvas')
164
+ const side = panes.querySelector(':scope > aside')
165
+ return {
166
+ partsSlot: parts?.getAttribute('slot'),
167
+ sideSlot: side?.getAttribute('slot'),
168
+ lefts: [parts, canvas, side].map(el => (el ? left(el) : null)),
169
+ asides: root.querySelectorAll('aside').length,
170
+ under: (() => {
171
+ const c = canvas.getBoundingClientRect()
172
+ return [...panes.children].filter(el => el !== canvas && !el.contains(canvas) && el.getBoundingClientRect().top >= c.bottom - 1 && el.getBoundingClientRect().height > 0).map(el => el.tagName)
173
+ })()
174
+ }
175
+ })
176
+ assert.ok(frame, 'the frame is ox-three-pane')
177
+ assert.equal(frame.partsSlot, 'start', 'the parts list is the left pane')
178
+ assert.equal(frame.sideSlot, 'end', 'the side is the right pane')
179
+ assert.ok(frame.lefts.every(x => x !== null) && frame.lefts[0] < frame.lefts[1] && frame.lefts[1] < frame.lefts[2], `parts · canvas · side, left to right: ${frame.lefts}`)
180
+ assert.equal(frame.asides, 1, 'one side')
181
+ assert.deepEqual(frame.under, [], 'nothing under the canvas')
182
+ })
183
+
184
+ test('the editor answers the width it is given, not the window\'s: it is a size container', async () => {
185
+ assert.equal(await read(() => getComputedStyle(screenCheck.editor()).containerType), 'inline-size')
186
+ })
187
+
188
+ test('the side\'s six tabs, in order, each saying its name under its icon -- the name it is read by', async () => {
189
+ const tabs = await read(() =>
190
+ [...screenCheck.editor().shadowRoot.querySelectorAll('aside button[tab]')].map(b => ({ label: b.getAttribute('aria-label'), shows: b.querySelector('span[tab-label]')?.textContent.trim() }))
191
+ )
192
+ assert.deepEqual(
193
+ tabs.map(t => t.label),
194
+ ['properties', 'capability', 'motion', 'verdict', 'view', 'history'].map(TAB)
195
+ )
196
+ for (const tab of tabs) assert.equal(tab.shows, tab.label, `${tab.label} shows its name`)
197
+ assert.equal(await read(() => screenCheck.editor().shadowRoot.querySelector('aside').firstElementChild.matches('div[tabs]')), true, 'the tabs are the side\'s first row')
198
+ })
199
+
200
+ test('a tab shows its panel and hides the others without taking them down -- the verdict keeps what it holds', async () => {
201
+ const panels = () =>
202
+ read(() => [...screenCheck.editor().shadowRoot.querySelectorAll('aside > div[panel]')].map(p => ({ seen: p.getBoundingClientRect().height > 0, connected: p.isConnected, verdict: !!p.querySelector('slot[name=verdict]') })))
203
+ const tabs = ['properties', 'capability', 'motion', 'verdict', 'view', 'history']
204
+ for (const [at, tab] of tabs.entries()) {
205
+ await pressTab(TAB(tab))
206
+ await until(`${tab} on`, name => screenCheck.editor().shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === name, TAB(tab))
207
+ const now = await panels()
208
+ assert.equal(now.length, 6, 'six panels, all standing')
209
+ assert.deepEqual(now.map(p => p.seen), tabs.map((_, i) => i === at), `${tab}: its panel seen, the rest hidden`)
210
+ assert.ok(now.every(p => p.connected))
211
+ assert.ok(now[3].verdict, 'the verdict panel holds the verdict slot whichever tab is on')
212
+ }
213
+ await pressTab(TAB('properties'))
214
+ })
215
+
216
+ test('the verdict tab carries the count the page measured', async () => {
217
+ await read(() => (screenCheck.editor().instances = 42))
218
+ await until('the count on the tab', () => screenCheck.editor().shadowRoot.querySelector(`button[tab][aria-label="figure.label.tab-short-verdict"] span[badge]`)?.textContent.trim() === '42')
219
+ })
220
+
221
+ test('nothing chosen: the properties panel opens on the figure\'s own settings, with no part head', async () => {
222
+ const panel = await read(() => {
223
+ const p = screenCheck.shownPanel()
224
+ return { figure: !!p.querySelector('slot[name=figure]'), subject: !!p.querySelector('div[subject]') }
225
+ })
226
+ assert.deepEqual(panel, { figure: true, subject: false })
227
+ })
228
+
229
+ test('the bar: undo and redo with their keys, the status, save, and the left pane folded to give the canvas its width', async () => {
230
+ assert.equal(await read(() => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').pane === screenCheck.editor().shadowRoot.querySelector('ox-three-pane')), true, 'the bar folds the editor\'s own frame')
231
+ const titles = await read(() => ['undo', 'redo'].map(a => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').shadowRoot.querySelector(`button[data-action=${a}]`).title))
232
+ assert.ok(titles[0].endsWith('(Ctrl+Z)') && titles[1].endsWith('(Ctrl+Y)'), `the keys in the titles: ${titles}`)
233
+ const status = () => read(() => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar [role=status]')?.textContent.trim())
234
+ assert.equal(await status(), 'figure.text.saved')
235
+
236
+ await pressRow('box')
237
+ const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box.size.y'))
238
+ await height.click({ clickCount: 3 })
239
+ await page().keyboard.type('600')
240
+ await page().keyboard.press('Enter')
241
+ await until('the height is 600', () => probe.value('box.size.y') === 600)
242
+ await until('said unsaved', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar [role=status]')?.textContent.trim() === 'figure.text.unsaved-changes')
243
+
244
+ await pressBar('undo')
245
+ await until('undone', () => probe.value('box.size.y') === 400)
246
+ await pressBar('redo')
247
+ await until('redone', () => probe.value('box.size.y') === 600)
248
+ await press('save', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]'))
249
+ await until('said saved', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar [role=status]')?.textContent.trim() === 'figure.text.saved')
250
+
251
+ const width = () => read(() => Math.round(screenCheck.deep('figure-canvas')[0].getBoundingClientRect().width))
252
+ const was = await width()
253
+ await pressBar('fold-start')
254
+ await until('folded', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === true)
255
+ await until('the canvas wider', was => Math.round(screenCheck.deep('figure-canvas')[0].getBoundingClientRect().width) > was + 50, was)
256
+ await pressBar('fold-start')
257
+ await until('unfolded', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === false)
258
+ })
259
+
260
+ test('fit puts the whole figure back in view after the camera was moved', async () => {
261
+ const camera = () => read(() => screenCheck.deep('figure-canvas')[0].scene.root._threeCapability.cameraManager.activeCamera.position.toArray().map(v => Math.round(v)))
262
+ /* Where fit puts the camera for the figure as it stands, once it rests there. */
263
+ const fitted = async () => {
264
+ await pressBar('fit')
265
+ let last = await camera()
266
+ for (let i = 0; i < 100; i++) {
267
+ await page().waitForFunction(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done(true)))))
268
+ const now = await camera()
269
+ if (now.every((v, k) => v === last[k])) return now
270
+ last = now
271
+ }
272
+ assert.fail('the camera never came to rest')
273
+ }
274
+ const fit = await fitted()
275
+ const at = await read(() => {
276
+ const r = screenCheck.deep('figure-canvas')[0].getBoundingClientRect()
277
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
278
+ })
279
+ await page().mouse.move(at.x, at.y)
280
+ for (let i = 0; i < 5; i++) await page().mouse.wheel({ deltaY: -400 })
281
+ await until('the camera moved', fit => {
282
+ const p = screenCheck.deep('figure-canvas')[0].scene.root._threeCapability.cameraManager.activeCamera.position.toArray().map(v => Math.round(v))
283
+ return p.some((v, i) => Math.abs(v - fit[i]) > 50)
284
+ }, fit)
285
+ const again = await fitted()
286
+ assert.ok(again.every((v, i) => Math.abs(v - fit[i]) <= 5), `back where fit puts it: ${again} against ${fit}`)
287
+ })
288
+ })
289
+
290
+ describe('a part chosen: each tab in its place, the figure-wide tabs give way, the list\'s root goes back', () => {
291
+ before(() => mount('arm'))
292
+
293
+ test('properties hold the part\'s shape and material, capability its places, motion its motion and controls -- each under the part\'s head', async () => {
294
+ await pressRow('upper-arm')
295
+ await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
296
+ const holds = () =>
297
+ read(() => {
298
+ const panel = screenCheck.shownPanel()
299
+ const links = panel.querySelector('figure-links')
300
+ return {
301
+ subject: !!panel.querySelector('div[subject]'),
302
+ links: links ? [...links.shadowRoot.querySelectorAll('section > h3')].map(h => screenCheck.ownText(h)) : [],
303
+ material: !!panel.querySelector('figure-material-panel'),
304
+ capability: !!panel.querySelector('figure-capability-panel'),
305
+ controls: screenCheck.deep('input[type=range]', panel.querySelector('figure-controls')?.shadowRoot ?? document.createElement('div')).length
306
+ }
307
+ })
308
+ await pressTab(TAB('properties'))
309
+ let now = await holds()
310
+ assert.ok(now.subject, 'the part\'s head')
311
+ assert.ok(now.links.includes('figure.label.resize-intent') && now.links.includes('figure.label.placement'), `the shape's links: ${now.links}`)
312
+ assert.ok(now.material, 'its material')
313
+ await pressTab(TAB('capability'))
314
+ now = await holds()
315
+ assert.ok(now.subject, 'the part\'s head')
316
+ assert.ok(now.links.length === 1 && now.links[0].startsWith('figure.label.places-on-part'), `the places on the part: ${now.links}`)
317
+ assert.ok(now.capability, 'its capabilities')
318
+ await pressTab(TAB('motion'))
319
+ now = await holds()
320
+ assert.ok(now.subject, 'the part\'s head')
321
+ assert.deepEqual(now.links, ['figure.label.motion'])
322
+ assert.ok(now.controls > 0, 'the controls that pose it')
323
+ })
324
+
325
+ test('choosing a part from the verdict, view or history tab opens its properties; from a part\'s own tab, that tab stays', async () => {
326
+ for (const tab of ['verdict', 'view', 'history']) {
327
+ await pressTab(TAB(tab))
328
+ await pressRow((await selected()) === 'forearm' ? 'elbow' : 'forearm')
329
+ await until(`from ${tab}, the properties`, () => screenCheck.editor().shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === 'figure.label.tab-short-properties')
330
+ }
331
+ await pressTab(TAB('motion'))
332
+ const next = (await selected()) === 'forearm' ? 'elbow' : 'forearm'
333
+ await pressRow(next)
334
+ await until(`${next} chosen`, next => screenCheck.editor().selected === next, next)
335
+ assert.equal(await tabOn(), TAB('motion'), 'motion stays, now of the new part')
336
+ })
337
+
338
+ test('the list\'s root shows the model\'s settings on the properties tab, and changes nothing', async () => {
339
+ await pressTab(TAB('motion'))
340
+ const before = { revision: await revision(), source: await source() }
341
+ await press('the model root', () => screenCheck.deep('button[model-root]')[0])
342
+ await until('nothing chosen', () => screenCheck.editor().selected === '')
343
+ assert.equal(await tabOn(), TAB('properties'))
344
+ assert.deepEqual({ revision: await revision(), source: await source() }, before, 'no step, no change')
345
+ })
346
+
347
+ test('the parts list\'s cost line opens the verdict tab', async () => {
348
+ await pressTab(TAB('properties'))
349
+ await until('the cost line', () => !!probe.budgetLine())
350
+ await press('the cost line', () => screenCheck.deep('button[budget]')[0])
351
+ await until('the verdict tab on', () => screenCheck.editor().shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === 'figure.label.tab-short-verdict')
352
+ })
353
+ })
354
+
355
+ describe('the cost line over a figure past a limit', () => {
356
+ test('graded D, it says which limit it is past', async () => {
357
+ await mount('glass')
358
+ await until('the cost line', () => !!probe.budgetLine())
359
+ const line = await read(() => probe.budgetLine())
360
+ assert.equal(line.grade, 'D')
361
+ assert.ok(line.text.includes('figure.text.parts-budget-over'), line.text)
362
+ await mount('two')
363
+ await until('the cost line', () => !!probe.budgetLine())
364
+ assert.ok(!(await read(() => probe.budgetLine())).text.includes('figure.text.parts-budget-over'), 'within its limits, nothing past')
365
+ })
366
+ })
367
+
368
+ describe('the tools are words, in two rows: what moves the part, and what makes a copy', () => {
369
+ before(() => mount('ceiling'))
370
+
371
+ test('a part hung from the ceiling: centre, align to the ceiling and snap in one row; the mirrored copies in the other; no icon alone', async () => {
372
+ await pressRow('box')
373
+ await until('box chosen', () => screenCheck.editor().selected === 'box')
374
+ const rows = await read(() => {
375
+ const panel = screenCheck.shownPanel()
376
+ return [...panel.querySelectorAll('div[vector-row]')]
377
+ .filter(row => row.querySelector('div[tools]'))
378
+ .map(row => ({
379
+ label: row.querySelector('label').textContent.trim(),
380
+ buttons: [...row.querySelectorAll('div[tools] button')].map(b => ({ text: b.textContent.trim(), icon: !!b.querySelector('md-icon') }))
381
+ }))
382
+ })
383
+ assert.deepEqual(rows.map(r => r.label), ['figure.label.tools', 'figure.label.mirror-copy'])
384
+ assert.deepEqual(rows[0].buttons.map(b => b.text), ['figure.button.center-x', 'figure.button.center-z', 'figure.button.align-to-ceiling', 'figure.button.snap'])
385
+ assert.deepEqual(rows[1].buttons.map(b => b.text), ['figure.button.mirror-x', 'figure.button.mirror-z'])
386
+ assert.ok(rows.flatMap(r => r.buttons).every(b => !b.icon), 'words, not icons')
387
+ })
388
+ })
389
+
390
+ describe('a new figure: what to start with, and nothing added by itself', () => {
391
+ before(() => mount('empty'))
392
+
393
+ test('the stage is bare while there is nothing: no line of its own over it', async () => {
394
+ const stage = await read(() => {
395
+ const root = screenCheck.deep('figure-canvas')[0].shadowRoot
396
+ return { children: [...root.children].filter(e => e.tagName !== 'STYLE').map(e => e.outerHTML.replace(/<!---->/g, '')), text: root.textContent.trim() }
397
+ })
398
+ assert.deepEqual(stage, { children: ['<div stage=""></div>'], text: '' })
399
+ })
400
+
401
+ test('the figure\'s own settings wait, folded; the detail level waits for the first part', async () => {
402
+ const folded = await read(() => {
403
+ const details = [...screenCheck.shownPanel().querySelectorAll('details')].find(d => d.querySelector('summary')?.textContent.trim() === 'figure.label.figure-info-later')
404
+ return details ? { open: details.open, figure: !!details.querySelector('slot[name=figure]') } : null
405
+ })
406
+ assert.deepEqual(folded, { open: false, figure: true })
407
+ assert.equal(await read(() => screenCheck.deep('div[level]').length), 0, 'no detail level before a part')
408
+ })
409
+
410
+ test('an AI draft only where the page offers it, and it asks the page', async () => {
411
+ assert.equal(await read(() => screenCheck.deep('button[start-ai]').length), 0, 'not offered by default')
412
+ await read(() => {
413
+ window.__asked = 0
414
+ document.addEventListener('ask-ai-draft', () => window.__asked++)
415
+ screenCheck.editor().aiDraft = true
416
+ })
417
+ await until('offered', () => screenCheck.deep('button[start-ai]').length === 1)
418
+ await press('the AI draft', () => screenCheck.deep('button[start-ai]')[0])
419
+ await until('asked', () => window.__asked === 1)
420
+ })
421
+
422
+ test('the placement is chosen on the start card as the figure\'s own setting: one step, no part made', async () => {
423
+ const before = await revision()
424
+ await press('ceiling', () => screenCheck.deep('button[start-button][role=radio]').find(b => b.textContent.trim() === 'figure.label.resize-place-ceiling'))
425
+ await until('placed', () => JSON.parse(screenCheck.editor().session.source()).placement === 'ceiling')
426
+ assert.equal(await revision(), before + 1, 'one step')
427
+ assert.equal(await read(() => screenCheck.editor().session.read().geometry.length), 0, 'no part')
428
+ assert.equal(await read(() => screenCheck.deep('button[start-button][aria-checked=true]')[0]?.textContent.trim()), 'figure.label.resize-place-ceiling')
429
+ })
430
+
431
+ test('a shape, on the author\'s click: one step, one part, and the card goes; the detail level comes with it', async () => {
432
+ const shapes = await read(() => screenCheck.deep('button[start-shape]').map(b => b.getAttribute('start-shape')))
433
+ assert.deepEqual(shapes, ['box', 'cylinder', 'plate'])
434
+ const before = await revision()
435
+ await press('a box', () => screenCheck.deep('button[start-shape=box]')[0])
436
+ await until('one part', () => screenCheck.editor().session.read().geometry.length === 1)
437
+ assert.equal(await revision(), before + 1, 'one step')
438
+ await until('the card goes', () => screenCheck.deep('div[start]').length === 0)
439
+ await until('the detail level', () => screenCheck.deep('div[level]').length === 1)
440
+ await until('the box stands on the board', () => !!screenCheck.deep('figure-canvas')[0].scene?.root?._threeCapability)
441
+ })
442
+ })
443
+
444
+ describe('narrow, the parts list folds for the canvas, said once in this browser, and stays open once the author opens it', () => {
445
+ /* The editor given a width, as a rail or a narrower window gives it. */
446
+ const width = px => read(px => (screenCheck.editor().style.flex = px ? `0 0 ${px}px` : ''), px)
447
+ const folded = () => read(() => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart)
448
+ const said = () => read(() => screenCheck.told().filter(n => n.message === 'figure.text.parts-panel-folded-narrow').length)
449
+ const settle = () => page().waitForFunction(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done(true)))))
450
+ before(async () => {
451
+ await read(() => {
452
+ localStorage.removeItem('figure-fold-said')
453
+ screenCheck.forgetTold()
454
+ })
455
+ await mount('box')
456
+ })
457
+ after(() => width(null))
458
+
459
+ test('at 1101 px it stands open; at 1099 it folds and says so once; the browser keeps that it was said', async () => {
460
+ await width(1101)
461
+ await settle()
462
+ assert.equal(await folded(), false)
463
+ await width(1099)
464
+ await until('folded', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === true)
465
+ assert.equal(await said(), 1)
466
+ assert.equal(await read(() => localStorage.getItem('figure-fold-said')), '1')
467
+ })
468
+
469
+ test('wide again, the fold made for the width is opened again', async () => {
470
+ await width(1400)
471
+ await until('open', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === false)
472
+ })
473
+
474
+ test('opened by the author while narrow, it stays open as the editor narrows further', async () => {
475
+ await width(1099)
476
+ await until('folded', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === true)
477
+ await pressBar('fold-start')
478
+ await until('opened', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === false)
479
+ await width(1000)
480
+ await settle()
481
+ await settle()
482
+ assert.equal(await folded(), false, 'kept open')
483
+ })
484
+
485
+ test('folded again in this browser, even in another editor, it is not said again', async () => {
486
+ await width(null)
487
+ await mount('box')
488
+ await width(1099)
489
+ await until('folded', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === true)
490
+ assert.equal(await said(), 1, 'said once')
491
+ })
492
+
493
+ test('a browser that keeps nothing says it each time, as before', async () => {
494
+ await width(null)
495
+ await read(() => {
496
+ window.__getItem = Storage.prototype.getItem
497
+ Storage.prototype.getItem = () => {
498
+ throw Error('storage refused')
499
+ }
500
+ })
501
+ try {
502
+ await mount('box')
503
+ const before = await said()
504
+ await width(1099)
505
+ await until('folded', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === true)
506
+ assert.equal(await said(), before + 1, 'said')
507
+ } finally {
508
+ await read(() => (Storage.prototype.getItem = window.__getItem))
509
+ }
510
+ })
511
+ })
512
+
513
+ describe('the view tab: the workbench\'s settings, in the board\'s own names, kept in this browser and never in the figure', () => {
514
+ /* The board's state as the scene holds it, and its renderer's sampling. */
515
+ const board = () =>
516
+ read(() => {
517
+ const root = screenCheck.deep('figure-canvas')[0].scene.root
518
+ return { sky: root.state.sky, skyColor: root.state.skyColor, grid: root.state.grid, pixelRatio: root._threeCapability.rendererManager.renderer.getPixelRatio() }
519
+ })
520
+ before(async () => {
521
+ await read(() => localStorage.removeItem('figure-view'))
522
+ await mount('box')
523
+ })
524
+ after(() => read(() => localStorage.removeItem('figure-view')))
525
+
526
+ test('the canvas stands on the workbench grey, before the scene and in it; the board is sampled twice a pixel', async () => {
527
+ assert.equal(await read(() => getComputedStyle(screenCheck.deep('figure-canvas')[0]).backgroundColor), 'rgb(124, 124, 124)')
528
+ const now = await board()
529
+ assert.deepEqual({ sky: now.sky, skyColor: now.skyColor }, { sky: 'color', skyColor: '#7c7c7c' }, 'the 3D sky is the view\'s colour')
530
+ assert.equal(now.pixelRatio, await read(() => Math.min(3, (window.devicePixelRatio || 1) * 2)))
531
+ })
532
+
533
+ test('the coordinate ground switched off in the tab: the board follows, the browser keeps it, the figure is untouched', async () => {
534
+ await pressTab(TAB('view'))
535
+ assert.ok((await read(() => screenCheck.deep('figure-settings')[0].shadowRoot.textContent)).includes('figure.text.view-settings-are-not-saved'), 'it says these are not saved')
536
+ const before = { revision: await revision(), source: await source(), grid: (await board()).grid }
537
+ assert.equal(before.grid, true)
538
+ await press('the coordinate ground', () => screenCheck.setting('figure.label.coordinate-ground').querySelector('input[type=checkbox]'))
539
+ await until('the board without its grid', () => screenCheck.deep('figure-canvas')[0].scene.root.state.grid === false)
540
+ assert.equal(await read(() => JSON.parse(localStorage.getItem('figure-view')).grid), false, 'kept in this browser')
541
+ assert.deepEqual({ revision: await revision(), source: await source() }, { revision: before.revision, source: before.source }, 'the figure is untouched')
542
+ })
543
+
544
+ test('the sky colour chosen in the tab is the board\'s sky', async () => {
545
+ const colour = await element('the sky colour', () => screenCheck.setting('figure.label.sky-color').querySelector('input[type=color]'))
546
+ /* A colour picker is the browser's own window; its value is set as the picker sets it, and changed. */
547
+ await colour.evaluate(input => {
548
+ input.value = '#336699'
549
+ input.dispatchEvent(new Event('change', { bubbles: true }))
550
+ })
551
+ await until('the sky follows', () => screenCheck.deep('figure-canvas')[0].scene.root.state.skyColor === '#336699')
552
+ assert.equal(await read(() => JSON.parse(localStorage.getItem('figure-view')).skyColor), '#336699')
553
+ })
554
+ })
555
+
556
+ describe('the preview: registered as a board registers it, posed and played, the part chosen kept', () => {
557
+ const mode = which => press(`the ${which} mode`, which => screenCheck.deep('button[mode]').find(b => b.title === `figure.text.mode-${which}-explained`), which)
558
+ const preview = () => `screenCheck.deep('figure-preview')[0]`
559
+ before(() => mount('arm-clip'))
560
+
561
+ test('going to the preview and back keeps the part chosen in the edit canvas, and the preview\'s size help starts on it', async () => {
562
+ await pressRow('forearm')
563
+ await until('forearm chosen', () => screenCheck.editor().selected === 'forearm')
564
+ await mode('preview')
565
+ await until('the preview stands', () => !!screenCheck.deep('figure-preview')[0]?.shadowRoot?.querySelector('div[setup] select'))
566
+ assert.equal(await read(() => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[setup] select').value), 'forearm', 'the help speaks of the part chosen')
567
+ await mode('edit')
568
+ await until('the edit canvas in front', () => screenCheck.deep('figure-preview')[0].hidden)
569
+ assert.equal(await selected(), 'forearm', 'still chosen')
570
+ })
571
+
572
+ test('each angle is posed with a dial; a pose tried is kept as the default in one step', async () => {
573
+ await mode('preview')
574
+ await until('the preview stands', () => !!screenCheck.deep('figure-preview')[0]?.shadowRoot?.querySelector('div[clips] label'))
575
+ const dials = await read(() => {
576
+ const labels = [...screenCheck.deep('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] label')]
577
+ return labels.map(l => ({ name: screenCheck.ownText(l.querySelector('span')), unit: l.querySelector('span[drive]')?.textContent.trim(), dial: !!l.querySelector('svg[dial]') }))
578
+ })
579
+ const turns = dials.filter(d => d.unit === 'deg')
580
+ assert.ok(turns.length >= 6, `the arm's six turns: ${JSON.stringify(dials)}`)
581
+ assert.ok(turns.every(d => d.dial), 'each with a dial')
582
+ assert.ok(dials.filter(d => d.unit !== 'deg').every(d => !d.dial), 'no dial for what does not turn')
583
+
584
+ const before = { revision: await revision(), pose: await read(() => JSON.parse(screenCheck.editor().session.source()).stateDefaults['j2-shoulder'] ?? 0) }
585
+ const slider = await element('the shoulder', () => screenCheck.previewControl('j2-shoulder').querySelector('input[type=range]'))
586
+ await slider.focus()
587
+ for (let i = 0; i < 10; i++) await page().keyboard.press('ArrowRight')
588
+ const tried = Number(await slider.evaluate(i => i.value))
589
+ assert.notEqual(tried, before.pose, 'a pose tried')
590
+ assert.equal(await revision(), before.revision, 'trying is not an edit')
591
+ await press('keep this pose', () => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('button[pose]'))
592
+ await until('one step', was => screenCheck.editor().session.read().revision === was + 1, before.revision)
593
+ assert.equal(await read(() => JSON.parse(screenCheck.editor().session.source()).stateDefaults['j2-shoulder']), tried, 'the pose kept is the one tried')
594
+ })
595
+
596
+ test('a clip stands still until it is played: its speed reads 0, and the speed played is what every instance is given', async () => {
597
+ const clip = () =>
598
+ read(() => {
599
+ const l = screenCheck.previewControl('cycle')
600
+ return { value: Number(l.querySelector('input[type=range]').value), shown: l.querySelector('output').textContent.trim() }
601
+ })
602
+ assert.deepEqual(await clip(), { value: 0, shown: '0.00' }, 'not played yet')
603
+ const slider = await element('the clip\'s speed', () => screenCheck.previewControl('cycle').querySelector('input[type=range]'))
604
+ await slider.focus()
605
+ await page().keyboard.press('ArrowRight')
606
+ await until('played', () => screenCheck.previewControl('cycle').querySelector('output').textContent.trim() !== '0.00')
607
+ const now = await clip()
608
+ assert.equal(now.shown, now.value.toFixed(2))
609
+ const given = await read(() => screenCheck.deep('figure-preview')[0].scene.root.components.map(c => c.state.clips?.cycle))
610
+ assert.equal(given.length, 8, 'eight instances')
611
+ assert.ok(given.every(v => v === now.value), `each given the speed shown: ${given}`)
612
+ })
613
+
614
+ test('narrow (1024 wide and less, both sides open), the size help and the controls share the bottom row without covering each other', async () => {
615
+ const viewport = page().viewport()
616
+ await page().setViewport({ width: 1024, height: 768 })
617
+ try {
618
+ /* Under 1100 the parts list folds for the canvas; opened again by the author, both sides stand. */
619
+ await until('folded for the width', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === true)
620
+ await pressBar('fold-start')
621
+ await until('both sides open', () => screenCheck.editor().shadowRoot.querySelector('ox-three-pane').foldStart === false)
622
+ await until('the preview between them', () => screenCheck.deep('figure-preview')[0].getBoundingClientRect().width < 600)
623
+ for (const width of [1024, 960]) {
624
+ await page().setViewport({ width, height: 768 })
625
+ await until(`the preview at ${width}`, width => screenCheck.deep('figure-preview')[0].getBoundingClientRect().right < width - 300, width)
626
+ await boardsUp()
627
+ const boxes = await read(() => {
628
+ const root = screenCheck.deep('figure-preview')[0].shadowRoot
629
+ const rect = el => {
630
+ const r = el.getBoundingClientRect()
631
+ return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }
632
+ }
633
+ return { host: rect(screenCheck.deep('figure-preview')[0]), foot: rect(root.querySelector('div[foot]')), setup: rect(root.querySelector('div[foot] > div[setup]')), clips: rect(root.querySelector('div[foot] > div[clips]')) }
634
+ })
635
+ const inside = (a, b) => a.left >= b.left - 0.5 && a.right <= b.right + 0.5 && a.top >= b.top - 0.5 && a.bottom <= b.bottom + 0.5
636
+ const apart = (a, b) => a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top
637
+ assert.ok(inside(boxes.setup, boxes.foot) && inside(boxes.clips, boxes.foot), `${width}: both in the bottom row: ${JSON.stringify(boxes)}`)
638
+ assert.ok(apart(boxes.setup, boxes.clips), `${width}: neither covers the other: ${JSON.stringify(boxes)}`)
639
+ assert.ok(boxes.setup.bottom >= boxes.clips.bottom - 0.5, `${width}: the help on the bottom line, the controls above it when they wrap`)
640
+ assert.ok(Math.abs(boxes.host.bottom - 8 - boxes.foot.bottom) <= 1, `${width}: the row stands on the stage's bottom edge: ${JSON.stringify(boxes)}`)
641
+ }
642
+ } finally {
643
+ await page().setViewport(viewport)
644
+ }
645
+ })
646
+
647
+ test('the figure is registered under its own preview type while the preview stands, and goes with the editor', async () => {
648
+ await mode('preview')
649
+ await until('the preview stands', () => !!screenCheck.deep('figure-preview')[0]?.shadowRoot?.querySelector('div[clips]'))
650
+ const type = await read(() => screenCheck.previewTypeOf(screenCheck.editor().session.read().context))
651
+ assert.equal(await read(type => screenCheck.registered(type), type), true, `registered as ${type}`)
652
+ assert.equal(await read(() => screenCheck.registered('figure-preview-new') && screenCheck.editor().session.read().context !== ''), false, 'not under another figure\'s type')
653
+ await boardsUp()
654
+ await read(() => {
655
+ window.__editor = screenCheck.editor()
656
+ window.__parent = window.__editor.parentNode
657
+ window.__editor.remove()
658
+ })
659
+ assert.equal(await read(type => screenCheck.registered(type), type), false, 'taken down with the editor')
660
+ await read(() => window.__parent.appendChild(window.__editor))
661
+ await boardsUp()
662
+ })
663
+
664
+ test('a canvas taken off the page says nothing of the choice: the editor moved keeps its part', async () => {
665
+ await boardsUp()
666
+ await read(() => {
667
+ const editor = screenCheck.editor()
668
+ const parent = editor.parentNode
669
+ editor.remove()
670
+ parent.appendChild(editor)
671
+ })
672
+ await read(() => screenCheck.editor().updateComplete)
673
+ assert.equal(await selected(), 'forearm')
674
+ await boardsUp()
675
+ })
676
+ })
677
+
678
+ describe('the preview of a figure that does not declare every size', () => {
679
+ test('a variant drawn at another size than it asks says so in the legend', async () => {
680
+ await mount('tower')
681
+ await press('the preview mode', () => screenCheck.deep('button[mode]').find(b => b.title === 'figure.text.mode-preview-explained'))
682
+ await until('the legend', () => !!screenCheck.deep('figure-preview')[0]?.shadowRoot?.querySelector('div[legend]'))
683
+ const clamped = await read(() => [...screenCheck.deep('figure-preview')[0].shadowRoot.querySelectorAll('div[legend] small[clamp]')].map(s => s.textContent.trim()))
684
+ assert.ok(clamped.length > 0 && clamped.every(c => c.startsWith('figure.text.preview-clamped')), `said: ${clamped}`)
685
+ await boardsUp()
686
+ })
687
+ })
688
+
689
+ describe('a part\'s size row: a rule\'s numbers only where a rule sets every side', () => {
690
+ /* The size row of the part chosen, and how many of its cells a rule holds. */
691
+ const sizeRow = () =>
692
+ read(() => {
693
+ const row = [...screenCheck.shownPanel().querySelectorAll('div[vector-row]')].find(r => r.querySelector('label')?.textContent.trim().startsWith('figure.label.group-size'))
694
+ return row ? { held: row.querySelectorAll('div[cell][held]').length } : null
695
+ })
696
+
697
+ test('a palette drum\'s own sizes are its shape\'s fields: no size row set by a rule', async () => {
698
+ await mount('drum')
699
+ await pressRow('drum')
700
+ await until('drum chosen', () => screenCheck.editor().selected === 'drum')
701
+ assert.equal(await sizeRow(), null)
702
+ })
703
+
704
+ test('a box made to follow the figure\'s size on every side shows the size the rule gives it', async () => {
705
+ await mount('box')
706
+ await pressRow('box')
707
+ await until('box chosen', () => screenCheck.editor().selected === 'box')
708
+ await press('follow the figure\'s size', () => screenCheck.deep('button[follow-size]')[0])
709
+ await until('following', () => ['x', 'y', 'z'].every(axis => !screenCheck.editor().session.read().fields.some(f => f.id === `box.size.${axis}`)))
710
+ assert.deepEqual(await sizeRow(), { held: 3 }, 'three sides, each held by the rule')
711
+ })
712
+ })
713
+
714
+ describe('the side panel is found as it was left, per part', () => {
715
+ const panelTop = () => read(() => screenCheck.shownPanel().scrollTop)
716
+ /* The open state of the folds in the properties panel, the fastening form's included. */
717
+ const folds = () =>
718
+ read(() => {
719
+ const panel = screenCheck.shownPanel()
720
+ return [...panel.querySelectorAll('details'), ...[...panel.querySelectorAll('figure-links')].flatMap(l => [...l.shadowRoot.querySelectorAll('details')])].map(d => d.open)
721
+ })
722
+ /* Scrolled with the wheel over the panel, as a hand scrolls it. */
723
+ async function scrollPanel(by) {
724
+ const at = await read(() => {
725
+ const r = screenCheck.shownPanel().getBoundingClientRect()
726
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
727
+ })
728
+ await page().mouse.move(at.x, at.y)
729
+ await page().mouse.wheel({ deltaY: by })
730
+ await until('scrolled', () => screenCheck.shownPanel().scrollTop > 0)
731
+ }
732
+ before(() => mount('arm'))
733
+
734
+ test('scrolled and a fold opened on one part; another part opens at its top; back, the first is as it was left', async () => {
735
+ await pressRow('upper-arm')
736
+ await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
737
+ await press('a fold', () => {
738
+ const panel = screenCheck.shownPanel()
739
+ const closed = [...panel.querySelectorAll('figure-links')].flatMap(l => [...l.shadowRoot.querySelectorAll('details')]).find(d => !d.open)
740
+ return closed?.querySelector('summary')
741
+ })
742
+ const opened = await folds()
743
+ await scrollPanel(400)
744
+ const top = await panelTop()
745
+ const subject = await read(() => {
746
+ const panel = screenCheck.shownPanel()
747
+ return Math.round(panel.querySelector('div[subject]').getBoundingClientRect().top - panel.getBoundingClientRect().top)
748
+ })
749
+ assert.equal(subject, 0, 'the part\'s head stays at the top of the panel, whatever the scroll')
750
+
751
+ await pressRow('forearm')
752
+ await until('forearm chosen, at its top', () => screenCheck.editor().selected === 'forearm' && screenCheck.shownPanel().scrollTop === 0)
753
+ await pressRow('upper-arm')
754
+ await until('upper-arm as it was left', top => screenCheck.editor().selected === 'upper-arm' && Math.abs(screenCheck.shownPanel().scrollTop - top) <= 2, top)
755
+ assert.deepEqual(await folds(), opened, 'its folds as they were')
756
+ })
757
+ })
758
+
759
+ describe('the fastening form says whether it changes the fastening the part has or fastens it anew', () => {
760
+ const preview = () => read(() => screenCheck.deep('[preview-words]').length)
761
+ const fastenButton = () => read(() => screenCheck.deep('button[act]').filter(b => b.textContent.trim().endsWith('figure.button.fasten')).length)
762
+ const target = () => element('the target select', () => screenCheck.deep('select').find(s => s.previousElementSibling?.textContent?.trim() === 'figure.label.target-part'))
763
+
764
+ test('a part fastened to nothing: 「새로 붙이기」 said, the sentence and what 붙이기 would do shown once a target is chosen, and nothing done until 붙이기', async () => {
765
+ await mount('door')
766
+ await pressRow('leaf')
767
+ await until('leaf chosen', () => screenCheck.editor().selected === 'leaf')
768
+ await press('start fastening', () => screenCheck.deep('button').find(b => b.textContent.trim().endsWith('figure.button.fasten-start')))
769
+ await until('the form', () => screenCheck.deep('p[fasten-mode]').length === 1)
770
+ const before = await revision()
771
+ await (await target()).select('frame')
772
+ await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').length === 1)
773
+ await until('what 붙이기 would do', () => screenCheck.deep('[preview-words]').length === 1)
774
+ assert.equal(await fastenButton(), 1, '붙이기 for a new one')
775
+ assert.equal(await revision(), before, 'nothing done until it is pressed')
776
+ })
777
+
778
+ test('a part already fastened: no 「새로 붙이기」, no 붙이기 -- another target is taken at once, in one step', async () => {
779
+ await mount('arm')
780
+ await pressRow('forearm')
781
+ await until('forearm chosen', () => screenCheck.editor().selected === 'forearm')
782
+ await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').length === 1)
783
+ assert.equal(await read(() => screenCheck.deep('p[fasten-mode]').length), 0, 'the one it has is not said to be new')
784
+ assert.equal(await fastenButton(), 0, 'no 붙이기')
785
+ await until('the fastening seen before it is done: its face lit on the canvas', () => !!screenCheck.deep('figure-canvas')[0].highlight)
786
+ assert.equal(await preview(), 0, 'no words for a command no button sends')
787
+ const before = await revision()
788
+ await (await target()).select('upper-arm')
789
+ await until('one step', was => screenCheck.editor().session.read().revision === was + 1, before)
790
+ assert.equal(await read(() => JSON.parse(screenCheck.editor().session.source()).joints.find(j => j.body1.part === 'forearm').body0.part), 'upper-arm')
791
+ })
792
+ })
793
+
794
+ describe('the modeller page: the editor with the page\'s figure panel, verdict and history in its tabs', () => {
795
+ /* A figure saved on the server in memory, opened on the modeller page as the shell opens it. */
796
+ async function open(id, figure, type) {
797
+ await boardsUp()
798
+ if (figure) await read((id, text, type) => product.seed({ id, source: text, type }), id, await read(name => figureText(name), figure), type ?? `probe-${id}`)
799
+ await read(id => product.open(id ?? undefined), id ?? null)
800
+ await until('the page\'s editor stands', id => {
801
+ const editor = screenCheck.editor()
802
+ const view = editor?.session?.read()
803
+ if (!view || view.loading || (id && view.id !== id)) return false
804
+ const canvas = screenCheck.deep('figure-canvas')[0]
805
+ return view.geometry.length === 0 ? !!canvas : !!canvas?.scene?.root?._threeCapability
806
+ }, id ?? null)
807
+ }
808
+ /* Saved, so another figure opens without asking about the edits in hand. */
809
+ async function saved() {
810
+ await press('save', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]'))
811
+ await until('saved', () => !screenCheck.editor().session.read().dirty)
812
+ }
813
+ const calls = field => read(field => product.calls().filter(c => c.field === field), field)
814
+
815
+ test('one side: the page puts its panels in the editor\'s tabs and has no side of its own', async () => {
816
+ await open('fig-a', 'two')
817
+ const asides = await read(() => screenCheck.deep('aside').map(a => a.getRootNode() === screenCheck.editor().shadowRoot))
818
+ assert.deepEqual(asides, [true], 'the editor\'s side, and no other')
819
+ })
820
+
821
+ test('the figure\'s type is the first field of the figure panel, under its heading', async () => {
822
+ await pressTab(TAB('properties'))
823
+ const first = await read(() => {
824
+ const properties = screenCheck.deep('figure-properties')[0]
825
+ const section = properties.shadowRoot.querySelector('section')
826
+ const heading = section.querySelector('h3')
827
+ const slot = heading.nextElementSibling
828
+ const label = slot?.assignedElements?.()[0]
829
+ const name = section.querySelector('div[field] input')
830
+ return {
831
+ heading: heading.textContent.trim(),
832
+ assigned: label?.matches('label[type-field]') ?? false,
833
+ order: label ? [heading.getBoundingClientRect().bottom <= label.getBoundingClientRect().top + 0.5, label.getBoundingClientRect().bottom <= name.getBoundingClientRect().top + 0.5] : null
834
+ }
835
+ })
836
+ assert.deepEqual(first, { heading: 'figure.label.group-figure', assigned: true, order: [true, true] })
837
+ })
838
+
839
+ test('the verdict and history tabs show the page\'s verdict and release history', async () => {
840
+ for (const [tab, host] of [['verdict', 'figure-verdict'], ['history', 'figure-history-panel']]) {
841
+ await pressTab(TAB(tab))
842
+ const shown = await read(() => screenCheck.shownPanel().querySelector('slot')?.assignedElements().map(e => ({ tag: e.localName, height: Math.round(e.getBoundingClientRect().height) })))
843
+ assert.equal(shown?.length, 1, tab)
844
+ assert.equal(shown[0].tag, host)
845
+ assert.ok(shown[0].height > 0, `${host} is shown`)
846
+ }
847
+ })
848
+
849
+ test('the verdict tab\'s count is the report\'s, measured by the verdict', async () => {
850
+ const expected = await read(() => screenCheck.capacityNow())
851
+ assert.ok(expected > 0)
852
+ await until('the count on the tab', expected => screenCheck.editor().shadowRoot.querySelector('button[tab][aria-label="figure.label.tab-short-verdict"] span[badge]')?.textContent.trim() === expected.toLocaleString(), expected)
853
+ })
854
+
855
+ test('a new type asks the verdict again', async () => {
856
+ await pressTab(TAB('properties'))
857
+ await pressRow('left')
858
+ await press('the model root', () => screenCheck.deep('button[model-root]')[0])
859
+ /* The figure as opened is judged first; what is counted after is asked by the new type. */
860
+ await until('judged', () => {
861
+ const said = screenCheck.deep('figure-verdict')[0]?.shadowRoot?.textContent ?? ''
862
+ return product.calls().some(c => c.field === 'inspectFigureDraft') && !said.includes('figure.text.verdict-waiting') && !said.includes('figure.text.verdict-asking')
863
+ })
864
+ const asked = (await calls('inspectFigureDraft')).length
865
+ const field = await element('the type field', () => screenCheck.deep('label[type-field] input')[0])
866
+ await field.click({ clickCount: 3 })
867
+ await page().keyboard.type('probe-renamed')
868
+ await page().keyboard.press('Tab')
869
+ await until('the type renamed on the server', () => product.calls().some(c => c.field === 'updateFigure' && c.variables.patch?.type === 'probe-renamed'))
870
+ await until('asked again', asked => product.calls().filter(c => c.field === 'inspectFigureDraft').length > asked, asked)
871
+ })
872
+
873
+ test('an edit is judged as it stands on screen: the source the server is sent is the one edited', async () => {
874
+ await pressRow('left')
875
+ const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'left.size.y'))
876
+ await height.click({ clickCount: 3 })
877
+ await page().keyboard.type('600')
878
+ await page().keyboard.press('Enter')
879
+ await until('the edit made', () => screenCheck.editor().session.read().fields.find(f => f.id === 'left.size.y')?.value === 600)
880
+ await until('the edited source judged', () => product.calls().filter(c => c.field === 'inspectFigureDraft').at(-1)?.variables.source === screenCheck.editor().session.revisionNow().text)
881
+ })
882
+
883
+ test('a save sends the card\'s picture with the figure', async () => {
884
+ /* The edit above is in hand, unsaved. */
885
+ assert.equal(await read(() => screenCheck.editor().session.read().dirty), true)
886
+ await read(() => product.forget())
887
+ await press('save', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]'))
888
+ await until('saved', () => product.calls().some(c => c.field === 'saveFigure'))
889
+ const thumbnail = await read(() => product.calls().find(c => c.field === 'saveFigure').variables.thumbnail)
890
+ assert.ok(thumbnail?.startsWith('data:image/webp'), `a picture: ${thumbnail?.slice(0, 30)}`)
891
+ await until('saved', () => !screenCheck.editor().session.read().dirty)
892
+ })
893
+
894
+ test('the verdict\'s 「부품에 맞추기」 declares the space from the parts, in one step', async () => {
895
+ await read(finding => product.answer('inspectFigureDraft', () => ({ __typename: 'FigureInspection', blocked: true, findings: [finding] })), await read(() => alone.finding('occupancy-undeclared')))
896
+ try {
897
+ await open('fig-b', 'two')
898
+ assert.equal(await read(() => JSON.parse(screenCheck.editor().session.source()).occupancy), undefined, 'no space declared yet')
899
+ await pressTab(TAB('verdict'))
900
+ await until('offered', () => !!screenCheck.deep('figure-verdict')[0]?.shadowRoot?.querySelector('button[fit]'))
901
+ const before = await revision()
902
+ await press('fit to the parts', () => screenCheck.deep('figure-verdict')[0].shadowRoot.querySelector('button[fit]'))
903
+ await until('one step', was => screenCheck.editor().session.read().revision === was + 1, before)
904
+ assert.ok(await read(() => !!JSON.parse(screenCheck.editor().session.source()).occupancy), 'the space is declared')
905
+ await saved()
906
+ } finally {
907
+ await read(() => product.answer('inspectFigureDraft'))
908
+ }
909
+ })
910
+
911
+ test('fitting a figure with no parts says so in the app\'s words and leaves the editor standing', async () => {
912
+ await read(finding => product.answer('inspectFigure', () => ({ __typename: 'FigureInspection', blocked: true, findings: [finding] })), await read(() => alone.finding('occupancy-undeclared')))
913
+ try {
914
+ await open('fig-empty', 'empty')
915
+ await pressTab(TAB('history'))
916
+ await until('offered', () => !!screenCheck.deep('figure-history-panel')[0]?.shadowRoot?.querySelector('button[fit-base]'))
917
+ await press('fit to the parts', () => screenCheck.deep('figure-history-panel')[0].shadowRoot.querySelector('button[fit-base]'))
918
+ await until('said', () => screenCheck.deep('p[fit-error]')[0]?.textContent.trim() === 'figure.text.fit-needs-parts')
919
+ assert.equal(await read(() => !!screenCheck.editor()?.isConnected), true, 'the editor stands')
920
+ } finally {
921
+ await read(() => product.answer('inspectFigure'))
922
+ }
923
+ })
924
+
925
+ test('the view set in the tab holds for the next figure opened: its board is stood up with it', async () => {
926
+ await pressTab(TAB('view'))
927
+ const colour = await element('the sky colour', () => screenCheck.setting('figure.label.sky-color').querySelector('input[type=color]'))
928
+ /* A colour picker is the browser's own window; its value is set as the picker sets it, and changed. */
929
+ await colour.evaluate(input => {
930
+ input.value = '#336699'
931
+ input.dispatchEvent(new Event('change', { bubbles: true }))
932
+ })
933
+ await open('fig-c', 'two')
934
+ try {
935
+ assert.equal(await read(() => screenCheck.deep('figure-canvas')[0].scene.root.state.skyColor), '#336699', 'the new board\'s sky')
936
+ } finally {
937
+ await pressTab(TAB('view'))
938
+ await press('reset the view', () => screenCheck.deep('figure-settings')[0].shadowRoot.querySelector('button[reset]'))
939
+ await until('the view as it was', () => screenCheck.deep('figure-canvas')[0].scene.root.state.skyColor === '#7c7c7c')
940
+ }
941
+ })
942
+
943
+ test('another figure opened starts its panels clean: a part of the same name opens at its top', async () => {
944
+ await open('fig-p', 'arm-clip')
945
+ await pressRow('upper-arm')
946
+ await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
947
+ const at = await read(() => {
948
+ const r = screenCheck.shownPanel().getBoundingClientRect()
949
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
950
+ })
951
+ await page().mouse.move(at.x, at.y)
952
+ await page().mouse.wheel({ deltaY: 400 })
953
+ await until('scrolled', () => screenCheck.shownPanel().scrollTop > 100)
954
+ /* Leaving the part keeps where its panel was. */
955
+ await press('the model root', () => screenCheck.deep('button[model-root]')[0])
956
+ await until('nothing chosen', () => screenCheck.editor().selected === '')
957
+ await open('fig-q', 'arm-clip')
958
+ await pressRow('upper-arm')
959
+ await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
960
+ for (let i = 0; i < 3; i++) await page().waitForFunction(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done(true)))))
961
+ assert.equal(await read(() => screenCheck.shownPanel().scrollTop), 0, 'not where the other figure\'s part was left')
962
+ })
963
+
964
+ test('a new figure\'s AI draft puts a request in the assistant\'s input, and sends nothing', async () => {
965
+ await read(() => assistant.forget())
966
+ await open(null)
967
+ await press('the AI draft', () => screenCheck.deep('button[start-ai]')[0])
968
+ await until('asked', () => assistant.said().some(s => s.call === 'ask'))
969
+ assert.deepEqual(await read(() => assistant.said().filter(s => s.call === 'ask')), [{ call: 'ask', draft: 'figure.text.ai-draft-request' }])
970
+ })
971
+
972
+ test('a new figure\'s placement is chosen in the figure panel before any part, in one step', async () => {
973
+ /* A new figure's own settings wait, folded: opened, the figure panel speaks of this figure. */
974
+ await press('the figure\'s settings', () => screenCheck.deep('summary').find(s => s.textContent.trim() === 'figure.label.figure-info-later'))
975
+ await until('the figure panel of the new figure', () => screenCheck.deep('figure-properties')[0]?.shadowRoot?.querySelector('div[field] input')?.value === screenCheck.editor().session.read().name)
976
+ const select = await element('the placement', () => screenCheck.deep('select', screenCheck.deep('figure-properties')[0].shadowRoot).find(s => s.previousElementSibling?.textContent.trim() === 'figure.label.figure-placement'))
977
+ assert.equal(await select.evaluate(s => s.disabled), false, 'not waiting for a part')
978
+ const before = await revision()
979
+ await select.select('ceiling')
980
+ await until('placed', () => JSON.parse(screenCheck.editor().session.source()).placement === 'ceiling')
981
+ assert.equal(await revision(), before + 1, 'one step')
982
+ })
983
+ })
984
+
985
+ describe('the card\'s picture: taken from a canvas of its own, through the scene\'s 3D snapshot', () => {
986
+ before(() => mount('box'))
987
+
988
+ test('a figure\'s picture is the card\'s size, as webp', async () => {
989
+ const url = await read(text => screenCheck.picture(text), await read(() => figureText('two')))
990
+ assert.ok(url?.startsWith('data:image/webp'), `a webp picture: ${url?.slice(0, 30)}`)
991
+ assert.deepEqual(await read(url => screenCheck.imageSize(url), url), [480, 360])
992
+ })
993
+
994
+ test('the edit canvas gives the card\'s size when asked for no other', async () => {
995
+ const url = await read(() => screenCheck.deep('figure-canvas')[0].snapshot())
996
+ assert.ok(url?.startsWith('data:image/webp'))
997
+ assert.deepEqual(await read(url => screenCheck.imageSize(url), url), [480, 360])
998
+ })
999
+ })
1000
+
1001
+ test('the page raised no error', () => {
1002
+ assert.deepEqual(opened.errors, [])
1003
+ })