@things-factory/figure-ui 10.1.241 → 10.1.244

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 (63) hide show
  1. package/client/graphql/store.ts +3 -2
  2. package/client/modeller/editor.ts +63 -5
  3. package/client/modeller/figure-parts.ts +18 -3
  4. package/client/modeller/figure-preview.ts +1 -1
  5. package/client/modeller/figure-properties.ts +14 -7
  6. package/client/modeller/figure-save-conflict.ts +73 -0
  7. package/client/modeller/figure-section-editor.ts +3 -0
  8. package/client/modeller/keys.ts +8 -2
  9. package/client/modeller/modeller-keys.ts +4 -1
  10. package/client/modeller/product-session.ts +107 -5
  11. package/client/modeller/save-conflict.ts +91 -0
  12. package/client/pages/figure-modeller-page.ts +58 -43
  13. package/dist-client/graphql/store.js +3 -2
  14. package/dist-client/graphql/store.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +14 -0
  16. package/dist-client/modeller/editor.js +75 -5
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/figure-parts.js +18 -3
  19. package/dist-client/modeller/figure-parts.js.map +1 -1
  20. package/dist-client/modeller/figure-preview.d.ts +1 -1
  21. package/dist-client/modeller/figure-preview.js.map +1 -1
  22. package/dist-client/modeller/figure-properties.d.ts +4 -2
  23. package/dist-client/modeller/figure-properties.js +16 -7
  24. package/dist-client/modeller/figure-properties.js.map +1 -1
  25. package/dist-client/modeller/figure-save-conflict.d.ts +19 -0
  26. package/dist-client/modeller/figure-save-conflict.js +76 -0
  27. package/dist-client/modeller/figure-save-conflict.js.map +1 -0
  28. package/dist-client/modeller/figure-section-editor.d.ts +2 -0
  29. package/dist-client/modeller/figure-section-editor.js +8 -0
  30. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  31. package/dist-client/modeller/keys.d.ts +5 -0
  32. package/dist-client/modeller/keys.js +5 -3
  33. package/dist-client/modeller/keys.js.map +1 -1
  34. package/dist-client/modeller/modeller-keys.d.ts +2 -0
  35. package/dist-client/modeller/modeller-keys.js +2 -1
  36. package/dist-client/modeller/modeller-keys.js.map +1 -1
  37. package/dist-client/modeller/product-session.d.ts +368 -22
  38. package/dist-client/modeller/product-session.js +107 -7
  39. package/dist-client/modeller/product-session.js.map +1 -1
  40. package/dist-client/modeller/save-conflict.d.ts +12 -0
  41. package/dist-client/modeller/save-conflict.js +63 -0
  42. package/dist-client/modeller/save-conflict.js.map +1 -0
  43. package/dist-client/pages/figure-modeller-page.d.ts +11 -5
  44. package/dist-client/pages/figure-modeller-page.js +58 -49
  45. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +5 -5
  48. package/test/browser/category-page.js +3 -1
  49. package/test/browser/category.check.mjs +6 -0
  50. package/test/browser/product-page.check.mjs +268 -6
  51. package/test/browser/product-page.js +4 -1
  52. package/test/browser/screen.check.mjs +3 -0
  53. package/test/browser/section.check.mjs +21 -0
  54. package/test/keys.test.ts +8 -0
  55. package/test/product-session.test.ts +137 -1
  56. package/test/save-conflict.test.ts +41 -0
  57. package/test/save-landed.test.ts +75 -0
  58. package/translations/en.json +12 -1
  59. package/translations/es.json +12 -1
  60. package/translations/ja.json +12 -1
  61. package/translations/ko.json +12 -1
  62. package/translations/ms.json +12 -1
  63. package/translations/zh.json +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.241",
3
+ "version": "10.1.244",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -42,11 +42,11 @@
42
42
  "@operato/slot-layout-editor": "^10.29.58",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.241",
46
- "@things-factory/figure-service": "^10.1.241",
45
+ "@things-factory/ai-assistant": "^10.1.244",
46
+ "@things-factory/figure-service": "^10.1.244",
47
47
  "@things-factory/figure-viewer": "^10.1.227",
48
- "@things-factory/setting-base": "^10.1.241",
48
+ "@things-factory/setting-base": "^10.1.244",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "d7fe2e1a1eb7c02cb21b897da26d385df014f908"
51
+ "gitHead": "f80402ed4e682642b9dfb3ce68d8b00d591d44ee"
52
52
  }
@@ -17,12 +17,14 @@ document.body.append(panel)
17
17
  window.harness = {
18
18
  panel,
19
19
  asked,
20
- async show({ categories, category }) {
20
+ /** `saved`: the category the server holds -- the one shown, unless a choice is held for the save. */
21
+ async show({ categories, category, saved = category }) {
21
22
  await session.open('f1')
22
23
  panel.revision = session.revisionNow()
23
24
  panel.figureId = 'f1'
24
25
  panel.figureCategories = categories
25
26
  panel.figureCategory = category
27
+ panel.figureCategorySaved = saved
26
28
  await panel.updateComplete
27
29
  }
28
30
  }
@@ -51,4 +51,10 @@ describe('the category field', () => {
51
51
  await page().evaluate(() => harness.show({ categories: ['EQUIPMENT', 'CARRIER'], category: 'EQUIPMENT' }))
52
52
  assert.deepEqual(await field(), { kind: 'select', disabled: true, value: 'EQUIPMENT', options: ['EQUIPMENT', 'CARRIER'] })
53
53
  })
54
+
55
+ /* Chosen and held for the save (2026-11-01 audit item 5): set once is set on the server, so a wrong pick is picked again. */
56
+ test('with a vocabulary and one chosen, not saved yet: it may be chosen again', async () => {
57
+ await page().evaluate(() => harness.show({ categories: ['EQUIPMENT', 'CARRIER'], category: 'EQUIPMENT', saved: null }))
58
+ assert.deepEqual(await field(), { kind: 'select', disabled: false, value: 'EQUIPMENT', options: ['EQUIPMENT', 'CARRIER'] })
59
+ })
54
60
  })
@@ -104,6 +104,70 @@ describe('a save whose answer never came back', () => {
104
104
  })
105
105
  })
106
106
 
107
+ /*
108
+ Another editor saved the figure first (2026-11-01 audit item 6): the edits stay, what differs is said by part, and the
109
+ ways on are asked -- a copy, the latest in a new tab, or writing over it.
110
+ */
111
+ describe('a save another editor got in before', () => {
112
+ before(async () => {
113
+ await fresh()
114
+ })
115
+ const conflictOn = async (id, theirs) => {
116
+ await read(id => product.seed({ id, type: id, source: product.boxSource('Mine') }), id)
117
+ await open(id)
118
+ await press('figure-editor >>> figure-parts >>> button[shape]', 'a part added')
119
+ await until(() => product.view().dirty, 'edited')
120
+ await read(({ id, theirs }) => product.seed({ id, type: id, source: product.boxSource(theirs) }), { id, theirs })
121
+ await read(() => product.forget())
122
+ await press('button[save]', 'save')
123
+ await until(() => !!product.deep('figure-save-conflict')[0], 'the conflict asked')
124
+ }
125
+ const differs = () => read(() => [...product.deep('figure-save-conflict >>> ul[differs] li')].map(li => li.textContent.replace(/\s+/g, ' ').trim()))
126
+
127
+ test('the latest in a new tab: the edits stay, unsaved, and the next save asks again', async () => {
128
+ await conflictOn('k1', 'Theirs')
129
+ const added = await read(() => JSON.parse(product.view().source).document.model.nodes.find(n => n.op === 'place@1' && n.id !== 'box').id)
130
+ assert.deepEqual(await differs(), [`figure.label.conflict-mine-only ${added}`, 'figure.text.conflict-other-changed'])
131
+ await read(() => { window.opened = []; window.open = (...a) => (window.opened.push(a[0]), null) })
132
+ await press('figure-save-conflict >>> md-outlined-button[latest]', 'the latest')
133
+ await until(() => !product.deep('figure-save-conflict')[0], 'closed')
134
+ assert.deepEqual(await read(() => window.opened), [await read(() => location.href)])
135
+ const view = await read(() => product.view())
136
+ assert.deepEqual({ id: view.id, dirty: view.dirty }, { id: 'k1', dirty: true })
137
+ assert.equal(JSON.parse((await read(() => product.rows())).find(r => r.id === 'k1').source).name, 'Theirs')
138
+ })
139
+
140
+ test('kept as a copy: a new figure saved by the first save\'s question, the other left as the server holds it', async () => {
141
+ await read(() => product.deep('figure-save-conflict')[0] && product.deep('figure-save-conflict >>> md-text-button[slot=cancel]')[0].click())
142
+ /* Tags changed and not saved go with the copy (2026-11-01 re-check). */
143
+ await change('figure-properties >>> input[placeholder="figure.text.tags-placeholder"]', 'new-tag')
144
+ await read(() => product.forget())
145
+ await press('button[save]', 'save')
146
+ await until(() => !!product.deep('figure-save-conflict')[0], 'the conflict asked again')
147
+ await press('figure-save-conflict >>> md-filled-button[copy]', 'save as a copy')
148
+ await until(() => !!product.deep('figure-first-save')[0], 'the first save asked')
149
+ assert.notEqual(await read(() => product.deep('figure-first-save >>> #type')[0].value), 'k1', 'the identifier of the figure left is not offered')
150
+ await press('figure-first-save >>> md-filled-button[slot=confirm]', 'the first save')
151
+ await until(() => product.view().id && product.view().id !== 'k1' && !product.view().dirty, 'saved as a figure of its own')
152
+ const copy = await read(() => product.view().id)
153
+ await until(c => product.rows().find(r => r.id === c)?.tags?.length, 'the tags written to the copy', copy)
154
+ assert.deepEqual(await callsTo('updateFigure'), [{ id: copy, patch: { tags: ['new-tag'] } }])
155
+ const rows = await read(() => product.rows())
156
+ assert.equal(JSON.parse(rows.find(r => r.id === 'k1').source).name, 'Theirs')
157
+ assert.equal(JSON.parse(rows.find(r => r.id === copy).source).document.model.nodes.filter(n => n.op === 'place@1').length, 2, 'the copy has the edits')
158
+ assert.equal(await read(() => location.pathname.endsWith(`/figure-modeller/${product.view().id}`)), true, 'the address is the copy\'s')
159
+ })
160
+
161
+ test('written over, once more agreed: saved over the latest, and nothing else asked', async () => {
162
+ await conflictOn('k2', 'Theirs')
163
+ await press('figure-save-conflict >>> md-text-button[overwrite]', 'overwrite')
164
+ await press('ox-prompt >>> #confirm', 'confirm the overwrite')
165
+ await until(() => !product.view().dirty, 'saved')
166
+ assert.equal(JSON.parse((await read(() => product.rows())).find(r => r.id === 'k2').source).name, 'Mine')
167
+ assert.equal(await read(() => product.view().id), 'k2')
168
+ })
169
+ })
170
+
107
171
  describe('a released figure the server makes a draft again', () => {
108
172
  before(async () => {
109
173
  await fresh()
@@ -136,25 +200,153 @@ describe('a released figure the server makes a draft again', () => {
136
200
  assert.equal(await releaseState(), 'figure.text.release-state-draft-over {"version":"2"}')
137
201
  })
138
202
 
139
- test('its category changed: written to the row at once, told, and the state follows', async () => {
203
+ /*
204
+ The row's fields are saved by 「저장」 with the figure (2026-11-01 audit item 5): written at once, they stayed after
205
+ leaving without saving, and the save button never lit for them.
206
+ */
207
+ test('its category changed: held, the save lit; 「저장」 writes the row alone, told, and the state follows', async () => {
140
208
  await open('c')
141
209
  await until(() => product.deep('[release-state]')[0]?.textContent.includes('released'), 'released')
142
210
  await read(() => {
143
211
  product.forget()
144
- product.answer('updateFigure', v => {
145
- const answer = product.server('updateFigure', v)
146
- answer.figure.state = 'draft'
147
- return answer
148
- })
212
+ product.answer('updateFigure', v => product.server('updateFigure', { ...v, patch: { ...v.patch, state: 'draft' } }))
149
213
  })
150
214
  const before = await read(() => product.view())
151
215
  await change('figure-properties >>> input[placeholder="figure.text.category-placeholder"]', 'conveyors')
216
+ assert.deepEqual(await callsTo('updateFigure'), [], 'nothing written yet')
217
+ assert.equal((await read(() => product.view())).dirty, true)
218
+ await press('button[save]', 'save')
152
219
  await until(() => product.deep('[release-state]')[0]?.textContent.includes('draft-over'), 'the state follows')
153
220
  assert.deepEqual(await callsTo('updateFigure'), [{ id: 'c', patch: { category: 'conveyors' } }])
221
+ assert.deepEqual(await callsTo('saveFigure'), [], 'the source is not written again')
154
222
  assert.deepEqual(await notices(), [{ level: 'info', message: 'figure.text.released-now-draft' }])
155
223
  const now = await read(() => product.view())
156
224
  assert.equal(now.source, before.source, 'the row, not the figure')
157
225
  assert.equal(now.dirty, false)
226
+ assert.equal(await read(() => product.deep('figure-properties >>> input[placeholder="figure.text.category-placeholder"]')[0].value), 'conveyors')
227
+ })
228
+
229
+ /* 2026-11-01 re-check: typed while the save was on its way, the field showed it, the server had the first, and nothing was unsaved. */
230
+ test('tags typed while their save is on its way: shown, still to save, and saved by the next 「저장」', async () => {
231
+ await read(() => product.seed({ id: 't', type: 'tag', source: product.boxSource('T') }))
232
+ await open('t')
233
+ await read(() => {
234
+ window.gate = new Promise(go => (window.letGo = go))
235
+ product.answer('updateFigure', async v => (await window.gate, product.server('updateFigure', v)))
236
+ })
237
+ const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
238
+ await change(tags, 'first')
239
+ await press('button[save]', 'save')
240
+ await until(() => product.page().session.read().saving, 'saving')
241
+ await change(tags, 'second')
242
+ await read(() => { window.letGo(); product.answer('updateFigure') })
243
+ await until(() => !product.page().session.read().saving, 'saved')
244
+ assert.equal((await read(() => product.rows())).find(r => r.id === 't').tags.join(), 'first')
245
+ assert.equal((await read(() => product.view())).dirty, true, 'the second is still to save')
246
+ assert.equal(await read(s => product.deep(s)[0].value, tags), 'second')
247
+ await press('button[save]', 'save')
248
+ await until(() => !product.view().dirty, 'saved again')
249
+ assert.equal((await read(() => product.rows())).find(r => r.id === 't').tags.join(), 'second')
250
+ })
251
+
252
+ /* 2026-11-01 second re-check: put back to the old tags while the save wrote others, the screen said old, the server the others, nothing unsaved. */
253
+ test('tags put back to the old ones while their save is on its way: still to save, and the next 「저장」 writes them', async () => {
254
+ await read(() => product.seed({ id: 'u', type: 'untag', tags: ['old'], source: product.boxSource('U') }))
255
+ await open('u')
256
+ await read(() => {
257
+ window.gate = new Promise(go => (window.letGo = go))
258
+ product.answer('updateFigure', async v => (await window.gate, product.server('updateFigure', v)))
259
+ })
260
+ const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
261
+ await until(s => product.deep(s)[0]?.value === 'old', 'the tags read', tags)
262
+ await change(tags, 'first')
263
+ await press('button[save]', 'save')
264
+ await until(() => product.page().session.read().saving, 'saving')
265
+ await change(tags, 'old')
266
+ await read(() => { window.letGo(); product.answer('updateFigure') })
267
+ await until(() => !product.page().session.read().saving, 'saved')
268
+ assert.equal((await read(() => product.rows())).find(r => r.id === 'u').tags.join(), 'first')
269
+ assert.equal((await read(() => product.view())).dirty, true, 'the old tags are still to save')
270
+ assert.equal(await read(s => product.deep(s)[0].value, tags), 'old')
271
+ /* Compared with what the row holds now: the ones written are no change, the old ones are. */
272
+ await change(tags, 'first')
273
+ await until(() => !product.view().dirty, 'the row read again: first is what it holds')
274
+ await change(tags, 'old')
275
+ assert.equal((await read(() => product.view())).dirty, true)
276
+ await press('button[save]', 'save')
277
+ await until(() => !product.view().dirty, 'saved again')
278
+ assert.equal((await read(() => product.rows())).find(r => r.id === 'u').tags.join(), 'old')
279
+ })
280
+
281
+ /* 2026-11-01 third re-check: typed between the save's answer and the row read again, a value was let go, then written over. */
282
+ test('tags typed after the save answered and before the row is read again: compared with what was written, and kept', async () => {
283
+ await read(() => product.seed({ id: 'v', type: 'vtag', tags: ['old'], source: product.boxSource('V') }))
284
+ await open('v')
285
+ const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
286
+ await until(s => product.deep(s)[0]?.value === 'old', 'the tags read', tags)
287
+ await read(() => {
288
+ window.gate = new Promise(go => (window.letGo = go))
289
+ window.rowGate = new Promise(go => (window.letRowGo = go))
290
+ product.answer('updateFigure', async v => (await window.gate, product.server('updateFigure', v)))
291
+ window.rowAnswered = 0
292
+ product.answer('figure', async v => (await window.rowGate, window.rowAnswered++, product.server('figure', v)))
293
+ })
294
+ await change(tags, 'first')
295
+ await press('button[save]', 'save')
296
+ await until(() => product.page().session.read().saving, 'saving')
297
+ await change(tags, 'second')
298
+ await read(() => window.letGo())
299
+ await until(() => !product.page().session.read().saving, 'the save answered')
300
+ assert.equal((await read(() => product.view())).dirty, true, 'second is still to save')
301
+ /* The row not read again yet. */
302
+ await change(tags, 'old')
303
+ assert.equal((await read(() => product.view())).dirty, true, 'old is a change from first, which the row holds now')
304
+ await read(() => { window.letRowGo(); product.answer('figure'); product.answer('updateFigure') })
305
+ await until(() => window.rowAnswered > 0, 'the row read again')
306
+ await read(() => new Promise(go => requestAnimationFrame(() => setTimeout(go, 50))))
307
+ assert.equal(await read(s => product.deep(s)[0].value, tags), 'old', 'not written over by the reading')
308
+ await press('button[save]', 'save')
309
+ await until(() => !product.view().dirty, 'saved again')
310
+ assert.equal((await read(() => product.rows())).find(r => r.id === 'v').tags.join(), 'old')
311
+ })
312
+
313
+ test('a reading of the row that comes back after a later one is not the row now', async () => {
314
+ await read(() => product.seed({ id: 'w', type: 'wtag', tags: ['old'], source: product.boxSource('W') }))
315
+ await open('w')
316
+ const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
317
+ await until(s => product.deep(s)[0]?.value === 'old', 'the tags read', tags)
318
+ await read(() => {
319
+ window.firstReading = new Promise(go => (window.letFirstGo = go))
320
+ window.readings = 0
321
+ /* The first reading after a save is taken as the server holds the row then, and answers late. */
322
+ product.answer('figure', async v => {
323
+ const answer = product.server('figure', v)
324
+ if (window.readings++ === 0) await window.firstReading
325
+ return answer
326
+ })
327
+ })
328
+ await change(tags, 'a')
329
+ await press('button[save]', 'save')
330
+ await until(() => !product.view().dirty && window.readings === 1, 'saved, its reading on its way')
331
+ await change(tags, 'b')
332
+ await press('button[save]', 'save')
333
+ await until(() => !product.view().dirty && window.readings === 2, 'saved again, read')
334
+ await read(() => { window.letFirstGo(); product.answer('figure') })
335
+ await read(() => new Promise(go => requestAnimationFrame(() => setTimeout(go, 50))))
336
+ assert.equal(await read(s => product.deep(s)[0].value, tags), 'b')
337
+ await change(tags, 'b')
338
+ assert.equal((await read(() => product.view())).dirty, false, 'b is what the row holds')
339
+ })
340
+
341
+ test('tags changed and the figure left without saving: the row keeps what it had, and undo is the figure\'s', async () => {
342
+ await open('n')
343
+ await read(() => product.forget())
344
+ await change('figure-properties >>> input[placeholder="figure.text.tags-placeholder"]', 'belt, roller')
345
+ assert.equal((await read(() => product.view())).dirty, true)
346
+ assert.equal(await beforeUnload(), true)
347
+ await read(() => product.page().session.open('o', { discard: true }))
348
+ assert.deepEqual(await callsTo('updateFigure'), [])
349
+ assert.deepEqual((await read(() => product.rows())).find(r => r.id === 'n').tags ?? [], [])
158
350
  })
159
351
  })
160
352
 
@@ -628,6 +820,76 @@ describe('the page around the figure: the shell, the dock, the properties', () =
628
820
  assert.deepEqual(await said(), [{ call: 'clear' }])
629
821
  })
630
822
 
823
+ /*
824
+ A page left is kept by the shell with its editor on the window (2026-11-01 audit): a key pressed elsewhere is not
825
+ its, and its unsaved edits are still lost by closing the tab.
826
+ */
827
+ test('left behind: keys pressed elsewhere leave the figure alone, and closing the tab still asks', async () => {
828
+ await open('d')
829
+ await pick('leaf')
830
+ await until(() => assistant.surface.hostContext().selection?.part === 'leaf', 'leaf picked')
831
+ const before = await read(() => product.view())
832
+ await read(() => product.leave())
833
+ await read(() => document.activeElement?.blur())
834
+ for (const key of ['Backspace', 'Delete']) await page().keyboard.press(key)
835
+ await page().keyboard.down('Control')
836
+ for (const key of ['d', 'v', 'z']) await page().keyboard.press(key)
837
+ await page().keyboard.up('Control')
838
+ const after = await read(() => product.view())
839
+ assert.deepEqual({ revision: after.revision, dirty: after.dirty, source: after.source }, { revision: before.revision, dirty: before.dirty, source: before.source })
840
+ await open('d')
841
+ await rename('Door 4')
842
+ await until(() => product.view().dirty, 'edited')
843
+ await read(() => product.leave())
844
+ assert.equal(await beforeUnload(), true, 'a page left with unsaved edits holds the tab too')
845
+ await read(() => product.page().session.undo())
846
+ await until(() => !product.view().dirty, 'undone')
847
+ assert.equal(await beforeUnload(), false)
848
+ })
849
+
850
+ test('the preview: Backspace removes nothing, and 「fit」 fits the preview in front', async () => {
851
+ await open('d')
852
+ await pick('leaf')
853
+ await press('figure-editor >>> button[mode]:not([aria-pressed=true])', 'the preview')
854
+ await until(() => !!product.deep('figure-editor >>> figure-preview')[0]?.checkVisibility(), 'the preview shown')
855
+ const before = await read(() => product.view())
856
+ await read(() => document.activeElement?.blur())
857
+ await page().keyboard.press('Backspace')
858
+ await page().keyboard.press('Delete')
859
+ const after = await read(() => product.view())
860
+ assert.deepEqual({ revision: after.revision, dirty: after.dirty }, { revision: before.revision, dirty: before.dirty })
861
+ const fitted = await read(() => {
862
+ const fitted = []
863
+ const preview = product.deep('figure-editor >>> figure-preview')[0]
864
+ const canvas = product.deep('figure-editor >>> figure-canvas')[0]
865
+ preview.fitView = () => fitted.push('preview')
866
+ canvas.fit = () => fitted.push('edit')
867
+ product.deep('figure-editor >>> ox-canvas-bar')[0].dispatchEvent(new CustomEvent('canvas-fit', { bubbles: true, composed: true }))
868
+ delete preview.fitView
869
+ delete canvas.fit
870
+ return fitted
871
+ })
872
+ assert.deepEqual(fitted, ['preview'])
873
+ await press('figure-editor >>> button[mode]:not([aria-pressed=true])', 'the edit canvas')
874
+ })
875
+
876
+ test('by the keyboard: a part is picked by its row\'s button; the tab in front and each field say what they are', async () => {
877
+ await open('d')
878
+ await page().keyboard.press('Escape')
879
+ await read(() => [...product.deep('figure-editor >>> figure-parts >>> button[pick]')].find(b => b.textContent.includes('frame')).focus())
880
+ await page().keyboard.press('Enter')
881
+ await until(() => assistant.surface.hostContext().selection?.part === 'frame', 'frame picked by Enter')
882
+ const rows = await read(() => [...product.deep('figure-editor >>> figure-parts >>> button[pick]')].map(b => [b.querySelector('span[label]').title, b.getAttribute('aria-pressed')]))
883
+ assert.deepEqual(rows.filter(([, pressed]) => pressed === 'true'), [['frame', 'true']])
884
+ await page().keyboard.press('Escape')
885
+ await press(TAB('properties'), 'the properties tab')
886
+ const tabs = await read(() => [...product.deep('figure-editor >>> button[tab]')].filter(b => b.getAttribute('aria-pressed') === 'true').map(b => b.getAttribute('aria-label')))
887
+ assert.deepEqual(tabs, ['figure.label.tab-short-properties'])
888
+ await until(() => product.deep('figure-modeller-page >>> figure-properties')[0]?.checkVisibility(), 'the figure properties shown')
889
+ const labelled = await read(() => [...product.deep('figure-modeller-page >>> figure-properties')[0].shadowRoot.querySelectorAll('div[field] > label')].map(l => [l.textContent.trim(), l.control?.localName ?? null]))
890
+ assert.deepEqual(labelled, [['figure.label.name', 'input'], ['figure.label.figure-placement', 'select'], ['figure.label.category', 'input'], ['figure.label.tags', 'input']])
891
+ })
892
+
631
893
  test('gone: the surface is taken back, and the tab is no longer held', async () => {
632
894
  await open('d')
633
895
  await rename('Door 3')
@@ -81,7 +81,10 @@ function row(id) {
81
81
  const answers = {
82
82
  figure: ({ id }) => ({ __typename: 'Figure', properties: null, tags: null, category: null, description: null, score: null, triangles: null, groups: null, thumbnailUpdatedAt: null, updatedAt: null, updater: null, thumbnailHash: null, ...row(id) }),
83
83
  figureTypeNames: () => [...rows.values()].map(r => r.type),
84
- saveFigure: async ({ id, source, type, creationId, thumbnail, proposalKeys }) => {
84
+ saveFigure: async ({ id, source, expectedSource, type, creationId, thumbnail, proposalKeys }) => {
85
+ /* Written only over the source the editor opened, as figure-write.ts writes. */
86
+ if (id && expectedSource !== undefined && expectedSource !== row(id).source)
87
+ throw Object.assign(Error('changed elsewhere'), { graphQLErrors: [{ message: 'changed elsewhere', extensions: { code: 'FIGURE_SOURCE_CONFLICT' } }] })
85
88
  const saved = id ? row(id) : { id: creationId ?? `made-${++made}`, namespace: null, type: type ?? `figure-${made}`, state: 'draft', version: 0 }
86
89
  /* The save's record of the proposals its source holds, as the server keeps it (figure-proposal.ts). */
87
90
  for (const key of proposalKeys ?? []) if (!records.some(r => r.proposalKey === key && r.figureId === saved.id)) records.push({ proposalKey: key, figureId: saved.id })
@@ -1102,6 +1102,9 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1102
1102
  await field.click({ clickCount: 3 })
1103
1103
  await page().keyboard.type('probe-renamed')
1104
1104
  await page().keyboard.press('Tab')
1105
+ /* Held until 「저장」 (2026-11-01 audit item 5): the server judges the row, so the new type is asked once saved. */
1106
+ assert.equal((await calls('updateFigure')).length, 0, 'not written before the save')
1107
+ await press('the save', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]'))
1105
1108
  await until('the type renamed on the server', () => product.calls().some(c => c.field === 'updateFigure' && c.variables.patch?.type === 'probe-renamed'))
1106
1109
  await until('asked again', asked => product.calls().filter(c => c.field === 'inspectFigureDraft').length > asked, asked)
1107
1110
  })
@@ -248,6 +248,27 @@ describe('the section editor on a drawn section: typed, previewed, applied or le
248
248
  await until(() => probe.drawnShape('wedge').section.points.length === 3, 'the solid drawn with three points')
249
249
  })
250
250
 
251
+ /* A page left keeps the modeller, and this editor, on the window (2026-11-01 audit item 1, found again in the re-check). */
252
+ test('on a page left, Delete and Ctrl+Z are not the editor\'s: no point deleted, none brought back', async () => {
253
+ await press('g[point="p3"] circle', { within: 'figure-section-editor' })
254
+ await read(() => (findDeep('figure-editor').active = false))
255
+ await page().keyboard.press('Delete')
256
+ await page().keyboard.down('Control')
257
+ await page().keyboard.press('KeyZ')
258
+ await page().keyboard.up('Control')
259
+ await sleep(100)
260
+ assert.equal((await editorNow()).points, 3)
261
+ await read(() => (findDeep('figure-editor').active = true))
262
+ await page().keyboard.down('Control')
263
+ await page().keyboard.press('KeyZ')
264
+ await page().keyboard.up('Control')
265
+ await until(() => probe.sectionEditor().points === 4, 'in front again, Ctrl+Z is the editor\'s')
266
+ /* Left as the test before left it, with its change: the next asks about it. */
267
+ await press('g[point="p4"] circle', { within: 'figure-section-editor' })
268
+ await page().keyboard.press('Delete')
269
+ await until(() => probe.sectionEditor().points === 3, 'p4 deleted again')
270
+ })
271
+
251
272
  test('picking another part with changes keeps the part until the editor is closed: asked, let go, then the pick goes on', async () => {
252
273
  const start = await state()
253
274
  const at = await read(() => probe.row('block'))
package/test/keys.test.ts CHANGED
@@ -52,3 +52,11 @@ test('복사 · 붙여넣기는 모델러의 것 — 고른 부품의 Ctrl+C 는
52
52
  assert.equal(keyActionOf({ key: 'x', ctrlKey: true }, 'arm'), 'hold')
53
53
  assert.equal(keyActionOf({ key: 'v', ctrlKey: true, target: { tagName: 'INPUT' } }, ''), null, '칸 안의 붙여넣기는 칸의 것')
54
54
  })
55
+
56
+ test('in the preview no key edits a part: removing, duplicating, pasting and the gizmo stay in the edit canvas', () => {
57
+ for (const key of ['Delete', 'Backspace', 'w', 'e', 'r']) assert.equal(keyActionOf({ key, previewing: true }, 'arm'), null, key)
58
+ assert.equal(keyActionOf({ key: 'd', ctrlKey: true, previewing: true }, 'arm'), null)
59
+ assert.equal(keyActionOf({ key: 'v', ctrlKey: true, previewing: true }, 'arm'), 'hold', 'kept from the scene, and not pasted')
60
+ assert.equal(keyActionOf({ key: 'z', ctrlKey: true, previewing: true }, ''), 'undo', 'undo stays, as the bar has it')
61
+ assert.equal(keyActionOf({ key: 'Escape', previewing: true }, 'arm'), 'deselect')
62
+ })
@@ -21,7 +21,8 @@ function deferred<T>() {
21
21
  }
22
22
  const store = {
23
23
  load: async (id: string) => ({ id, source: source(id) }),
24
- save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'created', source: q.source })
24
+ save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'created', source: q.source }),
25
+ updateCatalog: async () => ({})
25
26
  }
26
27
 
27
28
  test('existing model -> new clears geometry, ID, history and derived state', async () => {
@@ -131,3 +132,138 @@ test('retry after a lost create response reuses the same creation identity', asy
131
132
  assert.equal(keys[0], keys[1])
132
133
  assert.ok(keys[0])
133
134
  })
135
+
136
+ /*
137
+ The catalogue fields (type, category, tags) are the row's: held by the session and written by 「저장」 with the
138
+ figure, so leaving without saving leaves them as the server had them (2026-11-01 audit item 5).
139
+ */
140
+ test('catalogue fields are held until 「저장」: unsaved, they hold the figure; changed back, they are no change', async () => {
141
+ const s = createFigureProductSession(store)
142
+ assert.throws(() => s.editCatalog({ tags: ['a'] }, {}), { code: 'SESSION_NOT_SAVED' })
143
+ await s.open('A')
144
+ s.editCatalog({ tags: ['conveyor'] }, { tags: [] })
145
+ assert.deepEqual(s.read().catalog, { tags: ['conveyor'] })
146
+ assert.equal(s.read().dirty, true)
147
+ assert.equal(s.read().canUndo, false, 'not a step of the figure')
148
+ await assert.rejects(s.open('B'), /저장하지 않은/)
149
+ s.editCatalog({ tags: [] }, { tags: [] })
150
+ assert.equal(s.read().dirty, false)
151
+ s.editCatalog({ category: 'C' }, { category: undefined })
152
+ await s.open('B', { discard: true })
153
+ assert.deepEqual(s.read().catalog, {})
154
+ })
155
+ test('only the catalogue changed: 「저장」 writes the row and not the source', async () => {
156
+ const sent: string[] = []
157
+ const s = createFigureProductSession({
158
+ ...store,
159
+ save: async q => (sent.push('source'), store.save(q)),
160
+ updateCatalog: async (id, patch) => (sent.push(`row ${id} ${JSON.stringify(patch)}`), {})
161
+ })
162
+ await s.open('A')
163
+ s.editCatalog({ category: 'conveyors', tags: ['belt'] }, {})
164
+ await s.save()
165
+ assert.deepEqual(sent, ['row A {"category":"conveyors","tags":["belt"]}'])
166
+ assert.equal(s.read().dirty, false)
167
+ })
168
+ test('the source and the catalogue: the source first; a row refused keeps the catalogue held, the source saved', async () => {
169
+ const sent: string[] = []
170
+ const s = createFigureProductSession({
171
+ ...store,
172
+ save: async q => (sent.push('source'), store.save(q)),
173
+ updateCatalog: async () => {
174
+ sent.push('row')
175
+ throw Object.assign(Error('the type is taken'), { code: 'figure-type-taken' })
176
+ }
177
+ })
178
+ await s.open('A')
179
+ s.apply([{ kind: 'set-design', input: 'body.size.y', unit: 'mm', value: 200 }])
180
+ s.editCatalog({ type: 'gate' }, { type: 'door' })
181
+ await assert.rejects(s.save(), /taken/)
182
+ assert.deepEqual(sent, ['source', 'row'])
183
+ assert.equal(s.read().saving, false)
184
+ assert.deepEqual(s.read().catalog, { type: 'gate' })
185
+ assert.equal(s.read().dirty, true, 'the catalogue is still to save')
186
+ assert.equal(s.read().canUndo, true)
187
+ s.editCatalog({ type: 'door' }, { type: 'door' })
188
+ assert.equal(s.read().dirty, false, 'the source was saved')
189
+ })
190
+ test('a catalogue field changed while its save is on the way: shown, and held for the next save', async () => {
191
+ const writing = deferred<unknown>()
192
+ const sent: object[] = []
193
+ const s = createFigureProductSession({ ...store, updateCatalog: (_id: string, patch: object) => (sent.push(patch), writing.promise) })
194
+ await s.open('A')
195
+ s.editCatalog({ tags: ['first'], category: 'C' }, {})
196
+ const saving = s.save()
197
+ s.editCatalog({ tags: ['second'] }, {})
198
+ writing.resolve({})
199
+ await saving
200
+ assert.deepEqual(sent, [{ tags: ['first'], category: 'C' }])
201
+ assert.deepEqual(s.read().catalog, { tags: ['second'] }, 'the category saved; the tags typed since still to save')
202
+ assert.equal(s.read().dirty, true)
203
+ /* Changed back to what was sent while it was on its way: nothing left to save. */
204
+ const again = deferred<unknown>()
205
+ const t = createFigureProductSession({ ...store, updateCatalog: () => again.promise })
206
+ await t.open('A')
207
+ t.editCatalog({ tags: ['first'] }, {})
208
+ const second = t.save()
209
+ t.editCatalog({ tags: ['other'] }, {})
210
+ t.editCatalog({ tags: ['first'] }, {})
211
+ again.resolve({})
212
+ await second
213
+ assert.equal(t.read().dirty, false)
214
+ })
215
+
216
+ /* 2026-11-01 second re-check: put back to the old value while the save writes another, it was let go -- the row kept the other. */
217
+ test('a field put back to its old value while a save writes another is held: the old value is a change once that save lands', async () => {
218
+ const writing = deferred<unknown>()
219
+ const s = createFigureProductSession({ ...store, updateCatalog: () => writing.promise })
220
+ await s.open('A')
221
+ s.editCatalog({ tags: ['first'] }, { tags: ['old'] })
222
+ const saving = s.save()
223
+ s.editCatalog({ tags: ['old'] }, { tags: ['old'] })
224
+ writing.resolve({})
225
+ await saving
226
+ assert.deepEqual(s.read().catalog, { tags: ['old'] })
227
+ assert.equal(s.read().dirty, true)
228
+ /* While the source is being written, before the row: the same. */
229
+ const source = deferred<FigureProductRecord>()
230
+ const t = createFigureProductSession({ ...store, save: () => source.promise })
231
+ await t.open('A')
232
+ t.command({ kind: 'rename', name: 'B' } as never)
233
+ t.editCatalog({ tags: ['first'] }, { tags: ['old'] })
234
+ const both = t.save()
235
+ t.editCatalog({ tags: ['old'] }, { tags: ['old'] })
236
+ source.resolve({ id: 'A', source: t.source() })
237
+ await both
238
+ assert.deepEqual(t.read().catalog, { tags: ['old'] })
239
+ /* Typed again as sent, and the save refused: still held, nothing lost. */
240
+ const refused = deferred<unknown>()
241
+ const u = createFigureProductSession({ ...store, updateCatalog: () => refused.promise })
242
+ await u.open('A')
243
+ u.editCatalog({ tags: ['first'] }, { tags: ['old'] })
244
+ const failing = u.save()
245
+ u.editCatalog({ tags: ['first'] }, { tags: ['old'] })
246
+ refused.reject(Error('refused'))
247
+ await assert.rejects(failing)
248
+ assert.deepEqual(u.read().catalog, { tags: ['first'] })
249
+ /* After a save lands, back to the old value is no change. */
250
+ s.editCatalog({ tags: ['old'] }, { tags: ['first'] })
251
+ assert.deepEqual(s.read().catalog, { tags: ['old'] }, 'compared with what the row holds now')
252
+ })
253
+
254
+ /* 2026-11-01 third re-check: what a save wrote is told the moment it answers, for a screen to compare with before it reads the row. */
255
+ test('what the last save wrote of the catalogue is told; a save that writes none tells none', async () => {
256
+ const s = createFigureProductSession(store)
257
+ await s.open('A')
258
+ s.editCatalog({ tags: ['first'], category: 'C' }, { tags: ['old'] })
259
+ await s.save()
260
+ assert.deepEqual(s.read().catalogWritten, { tags: ['first'], category: 'C' })
261
+ s.command({ kind: 'rename', name: 'B' } as never)
262
+ await s.save()
263
+ assert.deepEqual(s.read().catalogWritten, {}, 'a later save of the source alone')
264
+ const refused = createFigureProductSession({ ...store, updateCatalog: async () => Promise.reject(Error('refused')) })
265
+ await refused.open('A')
266
+ refused.editCatalog({ tags: ['x'] }, {})
267
+ await assert.rejects(refused.save())
268
+ assert.deepEqual(refused.read().catalogWritten, {})
269
+ })
@@ -0,0 +1,41 @@
1
+ /*
2
+ * What differs between the edits on screen and the server's latest when a save is refused for a conflict.
3
+ */
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
7
+
8
+ import { saveConflictChangesOf } from '../client/modeller/save-conflict.ts'
9
+
10
+ const base = (lid = 10) =>
11
+ addFigureBox(addFigureBox(createFigureAsset('Gate'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }), {
12
+ id: 'lid',
13
+ size: { x: 100, y: lid, z: 100 },
14
+ position: { x: 0, y: 105, z: 0 }
15
+ })
16
+
17
+ test('the same figure: nothing differs', () => {
18
+ assert.deepEqual(saveConflictChangesOf(base(), base()), { changed: [], mineOnly: [], theirsOnly: [], otherChanged: false })
19
+ })
20
+
21
+ test('a part made differently is named, and only it', () => {
22
+ const theirs = base(30)
23
+ assert.deepEqual(saveConflictChangesOf(base(), theirs), { changed: ['lid'], mineOnly: [], theirsOnly: [], otherChanged: false })
24
+ })
25
+
26
+ test('a part on one side only is named on its side', () => {
27
+ const mine = addFigureBox(base(), { id: 'arm', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 0, z: 0 } })
28
+ const theirs = addFigureBox(base(), { id: 'leg', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 0, z: 0 } })
29
+ assert.deepEqual(saveConflictChangesOf(mine, theirs), { changed: [], mineOnly: ['arm'], theirsOnly: ['leg'], otherChanged: false })
30
+ })
31
+
32
+ test('the name differs: not a part, said as something else', () => {
33
+ const theirs = applyFigureAuthoring(base(), { kind: 'rename', name: 'Gate 2' } as never)
34
+ assert.deepEqual(saveConflictChangesOf(base(), theirs), { changed: [], mineOnly: [], theirsOnly: [], otherChanged: true })
35
+ })
36
+
37
+ test('a part\'s look differs: the part is named, not something else', () => {
38
+ const theirs = base() as unknown as { appearance: { target: string; material: unknown }[] }
39
+ theirs.appearance = theirs.appearance.map(look => (look.target === 'lid' ? { ...look, material: { token: 'palette.secondary' } } : look))
40
+ assert.deepEqual(saveConflictChangesOf(base(), theirs as never), { changed: ['lid'], mineOnly: [], theirsOnly: [], otherChanged: false })
41
+ })