@things-factory/figure-ui 10.1.81 → 10.1.82
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.
- package/client/figure-assistant.ts +2 -2
- package/client/graphql/index.ts +99 -10
- package/client/graphql/reached.ts +25 -0
- package/client/graphql/store.ts +27 -4
- package/client/modeller/ai-proposal.ts +74 -9
- package/client/modeller/align.ts +2 -3
- package/client/modeller/canvas.ts +18 -5
- package/client/modeller/command-preview.ts +113 -0
- package/client/modeller/editor.ts +826 -54
- package/client/modeller/fields.ts +12 -4
- package/client/modeller/figure-facts.ts +2 -2
- package/client/modeller/figure-first-save.ts +36 -14
- package/client/modeller/figure-history-panel.ts +133 -14
- package/client/modeller/figure-links.ts +385 -68
- package/client/modeller/figure-parts.ts +56 -2
- package/client/modeller/figure-preview.ts +19 -1
- package/client/modeller/figure-properties.ts +3 -3
- package/client/modeller/figure-revert-choice.ts +49 -0
- package/client/modeller/first-save.ts +16 -0
- package/client/modeller/gizmo.ts +0 -16
- package/client/modeller/links.ts +142 -15
- package/client/modeller/motion-ends.ts +83 -0
- package/client/modeller/occupancy.ts +28 -2
- package/client/modeller/product-session.ts +154 -18
- package/client/modeller/refusals.ts +15 -0
- package/client/modeller/revert-request.ts +26 -0
- package/client/modeller/side-panel-styles.ts +12 -0
- package/client/modeller/stage-model.ts +25 -3
- package/client/pages/figure-modeller-page.ts +68 -29
- package/dist-client/figure-assistant.js +2 -2
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/graphql/index.d.ts +39 -2
- package/dist-client/graphql/index.js +63 -9
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/graphql/reached.d.ts +10 -0
- package/dist-client/graphql/reached.js +28 -0
- package/dist-client/graphql/reached.js.map +1 -0
- package/dist-client/graphql/store.js +27 -4
- package/dist-client/graphql/store.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +31 -2
- package/dist-client/modeller/ai-proposal.js +65 -6
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/align.d.ts +1 -1
- package/dist-client/modeller/align.js +0 -2
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/canvas.d.ts +11 -0
- package/dist-client/modeller/canvas.js +33 -4
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/command-preview.d.ts +35 -0
- package/dist-client/modeller/command-preview.js +95 -0
- package/dist-client/modeller/command-preview.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +113 -1
- package/dist-client/modeller/editor.js +850 -50
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fields.d.ts +3 -2
- package/dist-client/modeller/fields.js +10 -3
- package/dist-client/modeller/fields.js.map +1 -1
- package/dist-client/modeller/figure-facts.js +2 -2
- package/dist-client/modeller/figure-facts.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +3 -4
- package/dist-client/modeller/figure-first-save.js +41 -14
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +31 -0
- package/dist-client/modeller/figure-history-panel.js +130 -14
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +51 -1
- package/dist-client/modeller/figure-links.js +392 -72
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +7 -0
- package/dist-client/modeller/figure-parts.js +55 -2
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +6 -0
- package/dist-client/modeller/figure-preview.js +22 -1
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.js +3 -3
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
- package/dist-client/modeller/figure-revert-choice.js +59 -0
- package/dist-client/modeller/figure-revert-choice.js.map +1 -0
- package/dist-client/modeller/first-save.d.ts +7 -0
- package/dist-client/modeller/first-save.js +17 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/gizmo.d.ts +0 -6
- package/dist-client/modeller/gizmo.js +0 -18
- package/dist-client/modeller/gizmo.js.map +1 -1
- package/dist-client/modeller/links.d.ts +49 -4
- package/dist-client/modeller/links.js +141 -15
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/motion-ends.d.ts +12 -0
- package/dist-client/modeller/motion-ends.js +65 -0
- package/dist-client/modeller/motion-ends.js.map +1 -0
- package/dist-client/modeller/occupancy.d.ts +14 -1
- package/dist-client/modeller/occupancy.js +14 -1
- package/dist-client/modeller/occupancy.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +43 -2
- package/dist-client/modeller/product-session.js +146 -12
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +13 -0
- package/dist-client/modeller/refusals.js +16 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/revert-request.d.ts +16 -0
- package/dist-client/modeller/revert-request.js +11 -0
- package/dist-client/modeller/revert-request.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.js +12 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +18 -2
- package/dist-client/modeller/stage-model.js +21 -3
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +8 -1
- package/dist-client/pages/figure-modeller-page.js +72 -34
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal.test.ts +2 -2
- package/test/align.test.ts +50 -5
- package/test/browser/acceptance-page.js +187 -0
- package/test/browser/build.mjs +5 -1
- package/test/browser/revert-page.js +47 -0
- package/test/browser/serve-acceptance.mjs +85 -0
- package/test/browser/serve-revert.mjs +119 -0
- package/test/clip-ai-proposal.test.ts +104 -0
- package/test/clip-consent.test.ts +88 -0
- package/test/command-preview.test.ts +288 -0
- package/test/fields.test.ts +4 -3
- package/test/figure-assistant-surface.test.ts +4 -1
- package/test/first-minute.test.ts +96 -0
- package/test/first-save-suggest.test.ts +42 -0
- package/test/gizmo.test.ts +20 -16
- package/test/joint-fan.test.ts +2 -1
- package/test/layout.test.ts +9 -2
- package/test/links.test.ts +7 -4
- package/test/proposal-record.test.ts +126 -0
- package/test/release-entry.test.ts +28 -0
- package/test/release-panel.test.ts +3 -2
- package/test/resize-with-placement.test.ts +225 -0
- package/test/revert-choice.test.ts +39 -0
- package/test/revert-keeping.test.ts +102 -0
- package/test/save-landed.test.ts +198 -0
- package/translations/en.json +222 -10
- package/translations/ja.json +223 -11
- package/translations/ko.json +223 -11
- package/translations/ms.json +222 -10
- package/translations/zh.json +223 -11
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { addFigureBox, createFigureAsset, applyFigureAuthoring, applyFigureProposal, compileFigureAsset, figureFollowOf, figureRoomOnlyInputsOf, proposeFigureOccupancy, parseFigureAsset, planFigureResize, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
5
|
+
import type { FigurePlacement } from '@hatiolab/figure-model'
|
|
6
|
+
import { createFigureProductSession } from '../client/modeller/product-session.js'
|
|
7
|
+
import { resizeRefusalOf } from '../client/modeller/refusals.js'
|
|
8
|
+
import { resizeAxesOf } from '../client/modeller/links.js'
|
|
9
|
+
import { groupFields } from '../client/modeller/fields.js'
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* ADR-0097: the modeller's "follow the size and keep where the figure is placed" is figure-model's plan, applied through
|
|
13
|
+
* the product session as one step: undone and redone whole, saved and opened again the same. The kernel's own tests
|
|
14
|
+
* check each placement at eight sizes; this checks the screen's path to it. The store is in memory: a kernel
|
|
15
|
+
* serialize-and-open, not the service's save and reopen, which is checked in the browser.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const START: Record<FigurePlacement, { x: number; y: number; z: number }> = {
|
|
19
|
+
floor: { x: 20, y: 50, z: 30 },
|
|
20
|
+
ceiling: { x: 20, y: -50, z: 30 },
|
|
21
|
+
wall: { x: 20, y: 50, z: 50 },
|
|
22
|
+
center: { x: 20, y: 50, z: 30 }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const mode of ['floor', 'ceiling', 'wall', 'center'] as const) {
|
|
26
|
+
test(`${mode}: one step through the session -- undo, redo, save and open again`, async () => {
|
|
27
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: START[mode] })
|
|
28
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: mode })
|
|
29
|
+
const before = serializeFigureAsset(a)
|
|
30
|
+
let record = { id: 'ux', source: before }
|
|
31
|
+
const session = createFigureProductSession({ load: async () => record, save: async q => (record = { id: 'ux', source: q.source }) })
|
|
32
|
+
await session.open('ux')
|
|
33
|
+
const plan = planFigureResize(parseFigureAsset(session.source()), { part: 'box', axes: resizeAxesOf(parseFigureAsset(session.source()), 'box').axes })
|
|
34
|
+
assert.equal(plan.refusal, undefined)
|
|
35
|
+
session.applyRecipe(plan.steps)
|
|
36
|
+
const after = session.source()
|
|
37
|
+
assert.notEqual(after, before)
|
|
38
|
+
session.undo()
|
|
39
|
+
assert.equal(session.source(), before, 'one undo takes all of it back')
|
|
40
|
+
session.redo()
|
|
41
|
+
assert.equal(session.source(), after)
|
|
42
|
+
await session.save()
|
|
43
|
+
await session.open('ux')
|
|
44
|
+
assert.equal(session.source(), after)
|
|
45
|
+
/* At twice the height the kept face is where it was. */
|
|
46
|
+
const asset = parseFigureAsset(session.source())
|
|
47
|
+
asset.designInputs['size.y'] = asset.designInputs['size.y']! * 2
|
|
48
|
+
const g = compileFigureAsset(asset).evaluate().geometry.find(g => g.id === 'box')! as unknown as { pose: { t: number[] }; shape: { dimensions: { height: number } } }
|
|
49
|
+
const y = g.pose.t[1]!, h = g.shape.dimensions.height
|
|
50
|
+
if (mode === 'floor') assert.equal(y - h / 2, 0)
|
|
51
|
+
if (mode === 'ceiling') assert.equal(y + h / 2, 0)
|
|
52
|
+
if (mode === 'center') assert.equal(y, 50)
|
|
53
|
+
/* Asked again, the part is held: the screen says so rather than fastening it twice. */
|
|
54
|
+
if (mode !== 'center') assert.equal(planFigureResize(asset, { part: 'box' }).refusal?.code, 'HELD')
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('a refusal is said in the author\'s words, never the kernel\'s English', () => {
|
|
59
|
+
const said: Array<[string, Record<string, string>]> = []
|
|
60
|
+
const say = (key: string, params: Record<string, string>) => (said.push([key, params]), key)
|
|
61
|
+
/* No room declared: the figure stands on the floor by default. */
|
|
62
|
+
const a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 }, rotation: { x: 0, y: 30, z: 0 } })
|
|
63
|
+
assert.equal(resizeRefusalOf(planFigureResize(a, { part: 'box' }), 'box', say), 'figure.text.resize-refused-turned')
|
|
64
|
+
assert.deepEqual(said.at(-1)![1].place, 'figure.label.resize-place-floor')
|
|
65
|
+
assert.equal(resizeRefusalOf({ mode: 'floor', refusal: { code: 'COMMAND_REFUSED' } }, 'box', say), 'figure.text.edit-refused')
|
|
66
|
+
assert.equal(resizeRefusalOf({ mode: 'floor' }, 'box', say), '')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* Designer 2026-09-27: the directions are passed as chosen; a first suggestion is not a choice, and a part set back to
|
|
71
|
+
* fixed in every direction is not "nothing chosen yet".
|
|
72
|
+
*/
|
|
73
|
+
test('fresh, width only, and all set back to fixed are three different answers', async () => {
|
|
74
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 20, y: 50, z: 30 } })
|
|
75
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
|
|
76
|
+
/* Fresh: all three are suggested -- and the button says "all three", never passing it off as the author's choice. */
|
|
77
|
+
assert.deepEqual(resizeAxesOf(a, 'box'), { axes: ['x', 'y', 'z'], basis: 'suggested' })
|
|
78
|
+
/* Width only ticked: width alone is planned, and height and depth stay fixed. */
|
|
79
|
+
const only = resizeAxesOf(a, 'box', ['x'])
|
|
80
|
+
assert.deepEqual(only, { axes: ['x'], basis: 'chosen' })
|
|
81
|
+
const resized = applyFigureProposal(a, planFigureResize(a, { part: 'box', axes: only.axes }).steps)
|
|
82
|
+
for (const axis of ['y', 'z'] as const) assert.equal(figureFollowOf(resized, 'box', axis).of, 'own', `${axis} stays fixed`)
|
|
83
|
+
const g = compileFigureAsset({ ...resized, designInputs: { ...resized.designInputs, 'size.x': 200, 'size.y': 400, 'size.z': 400 } }).evaluate().geometry.find(g => g.id === 'box') as unknown as { shape: { dimensions: Record<string, number> }; pose: { t: number[] } }
|
|
84
|
+
assert.deepEqual(g.shape.dimensions, { width: 200, height: 100, depth: 100 })
|
|
85
|
+
assert.equal(g.pose.t[1]! - 50, 0, 'and it stands on the floor')
|
|
86
|
+
/* All set back to fixed: nothing is taken for "all". */
|
|
87
|
+
let fixed = applyFigureAuthoring(a, { kind: 'follow-size', part: 'box', axes: ['x'] })
|
|
88
|
+
fixed = applyFigureAuthoring(fixed, { kind: 'unlink-dimension', part: 'box', axis: 'x', mm: 100 })
|
|
89
|
+
assert.deepEqual(resizeAxesOf(fixed, 'box', []), { axes: [], basis: 'none' })
|
|
90
|
+
/* A direction already following stays in, ticked or not. */
|
|
91
|
+
const following = applyFigureAuthoring(a, { kind: 'follow-size', part: 'box', axes: ['y'] })
|
|
92
|
+
assert.deepEqual(resizeAxesOf(following, 'box'), { axes: ['y'], basis: 'chosen' })
|
|
93
|
+
assert.deepEqual(resizeAxesOf(following, 'box', ['x']), { axes: ['x', 'y'], basis: 'chosen' })
|
|
94
|
+
/* The screen: the button passes the axes, the button names its scope, and "none" disables it. */
|
|
95
|
+
const links = readFileSync(new URL('../client/modeller/figure-links.ts', import.meta.url), 'utf-8')
|
|
96
|
+
assert.ok(links.includes("detail: { request: { part: this.partId, axes, ...(gap ? { gap } : {}) } }"))
|
|
97
|
+
assert.ok(links.includes("i18next.t('figure.button.resize-scoped-' + mode, { scope })"))
|
|
98
|
+
assert.ok(links.includes("?disabled=${choice.basis === 'none'"))
|
|
99
|
+
assert.ok(links.includes('this.pendingAxes = { ...this.pendingAxes, [this.partId]: axes.filter(one => one !== axis) }'), 'set back to fixed is remembered as a choice')
|
|
100
|
+
const ko = JSON.parse(readFileSync(new URL('../translations/ko.json', import.meta.url), 'utf-8'))
|
|
101
|
+
assert.equal(ko['figure.text.resize-scope-all-3'], '세 방향 모두')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a part off the floor is offered both, with the distance: keep the gap, or be brought onto the floor', () => {
|
|
105
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'shelf', size: { x: 400, y: 40, z: 300 }, position: { x: 0, y: 520, z: 0 } })
|
|
106
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
|
|
107
|
+
const asked = planFigureResize(a, { part: 'shelf', axes: resizeAxesOf(a, 'shelf').axes })
|
|
108
|
+
assert.deepEqual([asked.refusal?.code, asked.refusal?.mm], ['GAP_CHOICE', 500])
|
|
109
|
+
const links = readFileSync(new URL('../client/modeller/figure-links.ts', import.meta.url), 'utf-8')
|
|
110
|
+
assert.ok(links.includes("@click=${() => ask('keep')}") && links.includes("@click=${() => ask('plane')}"), 'two buttons, each a choice')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('a field only the room reads is taken out of the part\'s position row and shown apart', () => {
|
|
114
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 20, y: 50, z: 30 } })
|
|
115
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
|
|
116
|
+
const resized = applyFigureProposal(a, planFigureResize(a, { part: 'box' }).steps)
|
|
117
|
+
const roomOnly = figureRoomOnlyInputsOf(resized, 'box')
|
|
118
|
+
assert.ok(roomOnly.includes('box.position.y'))
|
|
119
|
+
const fields = Object.entries(resized.designInputs).filter(([id]) => id.startsWith('box.')).map(([id, value]) => ({ id, unit: 'mm', role: 'design', value }))
|
|
120
|
+
const { vectors } = groupFields(fields.filter(f => !roomOnly.includes(f.id)), 'box')
|
|
121
|
+
const position = vectors.find(v => v.kind === 'vector' && v.name === 'position')!
|
|
122
|
+
assert.ok(position.kind === 'vector' && position.cells.x && !position.cells.y && position.cells.z, 'X and Z still move it; Y is held by the floor')
|
|
123
|
+
const editor = readFileSync(new URL('../client/modeller/editor.ts', import.meta.url), 'utf-8')
|
|
124
|
+
assert.ok(editor.includes('${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}'))
|
|
125
|
+
assert.ok(editor.includes('if (agreed) this.linkCommand(refit.step)'), 'the room is declared again only when the author confirms')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* Designer 2026-09-27, 1: what the drag shows is what is written. The part component carries figure-model's anchor, and
|
|
130
|
+
* the scene's gizmo keeps that face while dragging (things-scene `scaleAnchor`); the commit keeps the same face.
|
|
131
|
+
*/
|
|
132
|
+
test('the part component carries the face a size drag keeps, for each placement', async () => {
|
|
133
|
+
const { componentOf } = await import('../client/modeller/stage-model.js')
|
|
134
|
+
const { figureScaleAnchorOf } = await import('@hatiolab/figure-model')
|
|
135
|
+
const expected = { floor: { x: 0, y: 1, z: 0 }, ceiling: { x: 0, y: -1, z: 0 }, wall: { x: 0, y: 0, z: 1 }, center: { x: 0, y: 0, z: 0 } } as const
|
|
136
|
+
for (const mode of ['floor', 'ceiling', 'wall', 'center'] as const) {
|
|
137
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: START[mode] })
|
|
138
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: mode })
|
|
139
|
+
const anchor = figureScaleAnchorOf(a, 'box')!
|
|
140
|
+
assert.deepEqual(anchor, expected[mode], mode)
|
|
141
|
+
const body = compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'box') as never
|
|
142
|
+
const state = componentOf(body, {}, { width: 1000, height: 1000 }, anchor)!
|
|
143
|
+
assert.deepEqual(state.scaleAnchor, expected[mode])
|
|
144
|
+
assert.equal(componentOf(body, {}, { width: 1000, height: 1000 })!.scaleAnchor, undefined, 'none given, none set')
|
|
145
|
+
}
|
|
146
|
+
const editor = readFileSync(new URL('../client/modeller/editor.ts', import.meta.url), 'utf-8')
|
|
147
|
+
assert.ok(editor.includes('.anchors=${this.anchorsOf(asset as FigureAsset | undefined)}'))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
/* Designer 2026-09-27 (acceptance round 2): what the screen shows for a part a rule places, and what it withholds. */
|
|
151
|
+
test('a fastened part keeps its position and turn rows, with the numbers the rule gives', async () => {
|
|
152
|
+
const { placementOf } = await import('../client/modeller/stage-model.js')
|
|
153
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 20, y: 50, z: 30 } })
|
|
154
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
|
|
155
|
+
const resized = applyFigureProposal(a, planFigureResize(a, { part: 'box' }).steps)
|
|
156
|
+
const fields = Object.entries(resized.designInputs).filter(([id]) => id.startsWith('box.') && !figureRoomOnlyInputsOf(resized, 'box').includes(id)).map(([id, value]) => ({ id, unit: 'mm', role: 'design', value }))
|
|
157
|
+
const { vectors } = groupFields(fields, 'box')
|
|
158
|
+
const rows = Object.fromEntries(vectors.map(v => [v.name, v]))
|
|
159
|
+
assert.ok(rows.rotation && rows.position, 'the turn row stands though every turn axis is the joint\'s')
|
|
160
|
+
const body = compileFigureAsset(resized).evaluate().geometry.find(g => g.id === 'box') as never
|
|
161
|
+
assert.deepEqual(placementOf(body), { position: [20, 50, 30], rotation: [0, 0, 0] }, 'the numbers a held cell shows')
|
|
162
|
+
const editor = readFileSync(new URL('../client/modeller/editor.ts', import.meta.url), 'utf-8')
|
|
163
|
+
assert.ok(editor.includes('<input type="text" readonly .value=${given(axis)}'), 'a held cell shows its number, read-only')
|
|
164
|
+
assert.ok(!editor.includes('value="—" disabled'), 'not a blank')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('after "follow size, attached" the part says what holds it, not a refusal', () => {
|
|
168
|
+
const links = readFileSync(new URL('../client/modeller/figure-links.ts', import.meta.url), 'utf-8')
|
|
169
|
+
assert.ok(links.includes('? this.renderHeld(asset)'))
|
|
170
|
+
assert.ok(!links.includes("<p hint resize-held>${i18next.t('figure.text.resize-refused-held'"))
|
|
171
|
+
const ko = JSON.parse(readFileSync(new URL('../translations/ko.json', import.meta.url), 'utf-8'))
|
|
172
|
+
assert.equal(ko['figure.text.held-on-plane'].replace('{face}', '아랫면').replace('{place}', '바닥').replace('{gap}', '0'), '아랫면을 바닥에 유지 중 (간격 0 mm)')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('a part fastened twisted has no face figure-model can keep for a drag, so the size gizmo is withheld', async () => {
|
|
176
|
+
const { figureScaleAnchorOf } = await import('@hatiolab/figure-model')
|
|
177
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'hub', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
|
|
178
|
+
a = addFigureBox(a, { id: 'blade', size: { x: 300, y: 20, z: 60 }, position: { x: 0, y: 110, z: 0 } })
|
|
179
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'blade', face: 'bottom', to: { part: 'hub', face: 'top' }, twist: 30 })
|
|
180
|
+
assert.equal(figureScaleAnchorOf(a, 'blade'), undefined)
|
|
181
|
+
const editor = readFileSync(new URL('../client/modeller/editor.ts', import.meta.url), 'utf-8')
|
|
182
|
+
assert.ok(editor.includes('if (reach.scale && this.sizeDragUnsure(asset, id)) return { ...reach, scale: false }'), 'the tool is off')
|
|
183
|
+
assert.ok(editor.includes("if (asset && this.sizeDragUnsure(asset, this.selected)) {"), 'the R key says why')
|
|
184
|
+
assert.ok(editor.includes("result.scale.some(k => Math.abs(k - 1) > 1e-4) && this.sizeDragUnsure(asset, id)"), 'a drag that still arrives is not written')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* Designer 2026-09-27 (reproduced by pressing it): "open attachment settings" opened the capability tab's holding places.
|
|
189
|
+
* It opens this part's fastening form -- loaded from the fastening the part has now -- in place.
|
|
190
|
+
*/
|
|
191
|
+
test('"open attachment settings" opens this part\'s fastening, as it is now, and not another tab', async () => {
|
|
192
|
+
const { fasteningOf } = await import('../client/modeller/links.js')
|
|
193
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 20, y: 150, z: 30 } })
|
|
194
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
|
|
195
|
+
const kept = applyFigureProposal(a, planFigureResize(a, { part: 'box', gap: 'keep' }).steps)
|
|
196
|
+
const form = fasteningOf(kept, 'box')!
|
|
197
|
+
assert.equal(form.toPlane, true, 'on the plane')
|
|
198
|
+
assert.equal(form.face, 'bottom')
|
|
199
|
+
assert.equal(form.gap, 100, 'the gap it keeps')
|
|
200
|
+
const links = readFileSync(new URL('../client/modeller/figure-links.ts', import.meta.url), 'utf-8')
|
|
201
|
+
const editor = readFileSync(new URL('../client/modeller/editor.ts', import.meta.url), 'utf-8')
|
|
202
|
+
assert.ok(links.includes('@click=${() => this.openFastening(asset)}'))
|
|
203
|
+
const open = links.slice(links.indexOf(' private async openFastening('), links.indexOf(' /**\n * What holds the part, said as its state'))
|
|
204
|
+
assert.ok(open.includes('const own = fasteningOf(asset, this.partId)'), 'the form is loaded from the fastening it has now')
|
|
205
|
+
assert.ok(open.includes("querySelector<HTMLElement>('section[fastening]')"), 'the fastening section is what comes into view')
|
|
206
|
+
assert.ok(!editor.includes("@open-fastening=${() => (this.tab = 'capability')}"), 'it does not switch to the capability tab')
|
|
207
|
+
assert.ok(links.includes('<section fastening ?spotlit=${this.spotlit}'))
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
/*
|
|
211
|
+
* The fastening form reads a place that follows a position field as that, and fastening again from it keeps the field:
|
|
212
|
+
* no relation is fixed into a number by opening the form and pressing "fasten" (a lost relation blocks release).
|
|
213
|
+
*/
|
|
214
|
+
test('the fastening form keeps a place that follows a position field, and fastening again keeps the field', async () => {
|
|
215
|
+
const { fasteningOf, alignOf } = await import('../client/modeller/links.js')
|
|
216
|
+
let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 200, y: 50, z: 150 } })
|
|
217
|
+
a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
|
|
218
|
+
const kept = applyFigureProposal(a, planFigureResize(a, { part: 'box' }).steps)
|
|
219
|
+
const form = fasteningOf(kept, 'box')!
|
|
220
|
+
assert.deepEqual([form.align.x, form.alignInput.x, form.align.z, form.alignInput.z], ['input', 'box.position.x', 'input', 'box.position.z'])
|
|
221
|
+
const align = alignOf(kept, 'box', 'plane', form.align, form.alignMm, false, form.alignInput)
|
|
222
|
+
const again = applyFigureAuthoring(kept, { kind: 'attach', part: 'box', face: 'bottom', to: { plane: 'mounting-plane' }, ...(align ? { align } : {}), replace: true } as never)
|
|
223
|
+
assert.ok('box.position.x' in again.designInputs && 'box.position.z' in again.designInputs, 'the fields stay')
|
|
224
|
+
assert.deepEqual(JSON.stringify(again.joints), JSON.stringify(kept.joints), 'the same fastening')
|
|
225
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Designer 2026-09-27, Q-D1: going back to a released version with edits not saved -- save first, let them go, or stay.
|
|
3
|
+
* A save that failed, or whose outcome is not known, holds the revert.
|
|
4
|
+
*/
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
11
|
+
|
|
12
|
+
test('with edits not saved, the three are asked; without, the plain confirmation', () => {
|
|
13
|
+
const panel = read('../client/modeller/figure-history-panel.ts')
|
|
14
|
+
const revert = panel.slice(panel.indexOf(' private async revert('))
|
|
15
|
+
assert.ok(revert.indexOf('if (this.dirty) {') < revert.indexOf('revertFigureVersion('), 'asked before anything is reverted')
|
|
16
|
+
assert.ok(revert.includes("if (!(await this.settleUnsaved('revert', version))) return"), 'cancelled or not saved: nothing')
|
|
17
|
+
const settle = panel.slice(panel.indexOf(' private async settleUnsaved('), panel.indexOf(' private async revert('))
|
|
18
|
+
assert.ok(settle.includes('html`<figure-revert-choice .version=${version} .purpose=${purpose}></figure-revert-choice>`'))
|
|
19
|
+
assert.ok(settle.includes('if (!choice) return false'), 'cancelled: nothing')
|
|
20
|
+
const choice = read('../client/modeller/figure-revert-choice.ts')
|
|
21
|
+
assert.ok(choice.includes("closePopup(this, 'discard' satisfies RevertChoice)") && choice.includes("closePopup(this, 'save' satisfies RevertChoice)"))
|
|
22
|
+
assert.ok(choice.includes('slot="start" destructive'), 'letting edits go is marked as what it is')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('saved first, and known to be -- or nothing is reverted', () => {
|
|
26
|
+
const panel = read('../client/modeller/figure-history-panel.ts')
|
|
27
|
+
const settle = panel.slice(panel.indexOf(' private async settleUnsaved('), panel.indexOf(' private async revert('))
|
|
28
|
+
assert.ok(settle.includes("if (!saved?.ok) {\n this.failure = i18next.t('figure.text.revert-held-unsaved'"), 'a save not done holds the revert')
|
|
29
|
+
assert.ok(settle.includes('return false\n }\n }\n return true'))
|
|
30
|
+
const page = read('../client/pages/figure-modeller-page.ts')
|
|
31
|
+
assert.ok(page.includes('.saveFirst=${this.saveBeforeRevert}'))
|
|
32
|
+
const save = page.slice(page.indexOf(' private saveBeforeRevert'), page.indexOf(' private reverted'))
|
|
33
|
+
assert.ok(save.includes("return this.session.read().dirty ? { ok: false } : { ok: true }"), 'ok only when the session says saved')
|
|
34
|
+
assert.ok(save.includes("code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable')"), 'not known is said as not known')
|
|
35
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
36
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
37
|
+
for (const key of ['figure.text.revert-with-unsaved', 'figure.button.revert-saving', 'figure.button.revert-discarding', 'figure.text.revert-held-unsaved']) assert.ok(t[key], `${lang} ${key}`)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Designer 2026-09-27 (ADR-0097 §5.6): the draft a revert writes over is kept on the server, in the same transaction.
|
|
3
|
+
* The screen sends what the server is confirmed to hold, so a revert never writes over something it did not see; a
|
|
4
|
+
* retry after a lost answer goes with the same request id, so the same revert is never run twice; the kept drafts are
|
|
5
|
+
* listed apart from the releases, and the newest can be taken back.
|
|
6
|
+
*
|
|
7
|
+
* The server's side -- one transaction, the same request once, an undo only while nothing changed -- is tested on sqlite
|
|
8
|
+
* and on postgres in figure-service (figure-revert-db, figure-revert-race.pg).
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
15
|
+
|
|
16
|
+
import { createFigureProductSession } from '../client/modeller/product-session.js'
|
|
17
|
+
import { afterRevertAnswer, revertOutcomeUnknown, revertRequestFor } from '../client/modeller/revert-request.js'
|
|
18
|
+
|
|
19
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
20
|
+
|
|
21
|
+
test('asked again for the same revert after no answer: the same request id; anything else, a new one', () => {
|
|
22
|
+
let n = 0
|
|
23
|
+
const fresh = () => `id-${++n}`
|
|
24
|
+
const first = revertRequestFor(undefined, 'revert:1:A', fresh)
|
|
25
|
+
assert.equal(first.requestId, 'id-1')
|
|
26
|
+
/* No answer came: kept, and the same press goes with the same id. */
|
|
27
|
+
const kept = afterRevertAnswer(first, 'REVERT_UNREACHABLE')
|
|
28
|
+
assert.equal(revertRequestFor(kept, 'revert:1:A', fresh).requestId, 'id-1')
|
|
29
|
+
/* Met itself in flight on the server: still not known -- kept too. */
|
|
30
|
+
assert.equal(afterRevertAnswer(first, 'FIGURE_REVERT_IN_FLIGHT'), first)
|
|
31
|
+
/* Another version, or the same version over another source (saved since): a new request. */
|
|
32
|
+
assert.equal(revertRequestFor(kept, 'revert:2:A', fresh).requestId, 'id-2')
|
|
33
|
+
assert.equal(revertRequestFor(kept, 'revert:1:B', fresh).requestId, 'id-3')
|
|
34
|
+
/* Done, or refused by name: let go -- the next press is a new request. */
|
|
35
|
+
assert.equal(afterRevertAnswer(first, undefined), undefined)
|
|
36
|
+
assert.equal(afterRevertAnswer(first, 'FIGURE_SOURCE_CONFLICT'), undefined)
|
|
37
|
+
assert.equal(afterRevertAnswer(first, 'FIGURE_KEPT_RESTORED'), undefined)
|
|
38
|
+
assert.equal(revertOutcomeUnknown('FIGURE_SOURCE_CONFLICT'), false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('the source a revert expects is what the server is confirmed to hold -- not the edits on screen', async () => {
|
|
42
|
+
let row = serializeFigureAsset(addFigureBox(createFigureAsset('A'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
|
|
43
|
+
let lose = false
|
|
44
|
+
const session = createFigureProductSession({
|
|
45
|
+
load: async (id: string) => ({ id, source: row }),
|
|
46
|
+
readBack: async (id: string) => ({ id, source: row }),
|
|
47
|
+
save: async (q: { id?: string; source: string; expectedSource?: string }) => {
|
|
48
|
+
if (lose) throw Object.assign(Error('서버에 연결하지 못했습니다.'), { code: 'SAVE_UNREACHABLE' })
|
|
49
|
+
row = q.source
|
|
50
|
+
return { id: q.id ?? 'a', source: row }
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
session.startNew()
|
|
54
|
+
assert.equal(session.confirmedSource(), undefined, 'nothing saved: nothing to revert over')
|
|
55
|
+
await session.open('a')
|
|
56
|
+
const loaded = row
|
|
57
|
+
assert.equal(session.confirmedSource(), loaded)
|
|
58
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
59
|
+
assert.equal(session.confirmedSource(), loaded, 'edits not saved are not what the server holds')
|
|
60
|
+
await session.save()
|
|
61
|
+
assert.equal(session.confirmedSource(), row)
|
|
62
|
+
assert.notEqual(row, loaded)
|
|
63
|
+
/* A save that never reached the server does not move it. */
|
|
64
|
+
const saved = row
|
|
65
|
+
session.command({ kind: 'rename', name: 'C' } as never)
|
|
66
|
+
lose = true
|
|
67
|
+
await session.save().catch(() => {})
|
|
68
|
+
assert.equal(session.confirmedSource(), saved)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('the screen: reverting is on, sends the confirmed source and a request id, and lists the kept drafts apart', () => {
|
|
72
|
+
const panel = read('../client/modeller/figure-history-panel.ts')
|
|
73
|
+
assert.ok(!panel.includes('REVERT_KEEPS_REPLACED_DRAFT'), 'the gate is gone')
|
|
74
|
+
const revert = panel.slice(panel.indexOf(' private async revert('), panel.indexOf(' private async undo('))
|
|
75
|
+
/* Read after the unsaved edits are settled -- a save first moves it. */
|
|
76
|
+
assert.ok(revert.indexOf('settleUnsaved(') < revert.indexOf('const expected = this.serverSource?.()'))
|
|
77
|
+
assert.ok(revert.includes('if (expected === undefined) {'), 'not known: not reverted')
|
|
78
|
+
assert.ok(revert.includes('revertFigureVersion(this.figureId!, version, requestId, expected)'))
|
|
79
|
+
const undo = panel.slice(panel.indexOf(' private async undo('), panel.indexOf(' private async send('))
|
|
80
|
+
assert.ok(undo.includes("this.dirty && !(await this.settleUnsaved('undo'))"), 'an undo asks about unsaved edits too')
|
|
81
|
+
assert.ok(panel.includes('<section kept>'), 'kept drafts in their own section, not among the releases')
|
|
82
|
+
assert.ok(panel.includes('${newest && !kept.restoredAt'), 'only the newest, not yet brought back, is offered')
|
|
83
|
+
assert.ok(read('../client/pages/figure-modeller-page.ts').includes('.serverSource=${() => this.session.confirmedSource()}'))
|
|
84
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
85
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
86
|
+
for (const key of [
|
|
87
|
+
'figure.label.kept-drafts',
|
|
88
|
+
'figure.text.kept-before-revert',
|
|
89
|
+
'figure.text.kept-before-undo',
|
|
90
|
+
'figure.text.kept-restored',
|
|
91
|
+
'figure.button.undo-revert',
|
|
92
|
+
'figure.button.redo-revert',
|
|
93
|
+
'figure.text.undo-revert-with-unsaved',
|
|
94
|
+
'figure.button.save-and-continue',
|
|
95
|
+
'figure.button.discard-and-continue',
|
|
96
|
+
'figure.text.revert-needs-server-source',
|
|
97
|
+
'figure.text.revert-unconfirmed'
|
|
98
|
+
])
|
|
99
|
+
assert.ok(t[key], `${lang} ${key}`)
|
|
100
|
+
assert.equal(t['figure.text.revert-waits-for-keeping'], undefined, `${lang}: the waiting notice is gone`)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Designer 2026-09-27 (D): a network failure alone does not say nothing was saved. A save whose answer was lost, or whose
|
|
3
|
+
* second try is refused because the first landed, is read back from the server: the very text sent is a save that
|
|
4
|
+
* happened. Anything else, the failure stands and the edits stay.
|
|
5
|
+
*/
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
11
|
+
|
|
12
|
+
import { createFigureProductSession } from '../client/modeller/product-session.js'
|
|
13
|
+
|
|
14
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
15
|
+
const unreachable = () => Object.assign(Error('서버에 연결하지 못했습니다.'), { code: 'SAVE_UNREACHABLE' })
|
|
16
|
+
|
|
17
|
+
/** A server that keeps one row; its save can be made to fail before or after it writes. */
|
|
18
|
+
function server() {
|
|
19
|
+
let row = serializeFigureAsset(addFigureBox(createFigureAsset('A'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
|
|
20
|
+
let picture: string | undefined
|
|
21
|
+
const state = { failAfterWrite: false, failBeforeWrite: false, loadFails: false, refuseOnGrounds: false, writes: 0, hold: undefined as undefined | Promise<void> }
|
|
22
|
+
const store = {
|
|
23
|
+
load: async (id: string) => ({ id, source: row }),
|
|
24
|
+
readBack: async (id: string) => {
|
|
25
|
+
if (state.loadFails) throw unreachable()
|
|
26
|
+
return { id, source: row, thumbnail: picture }
|
|
27
|
+
},
|
|
28
|
+
save: async (q: { id?: string; source: string; expectedSource?: string; thumbnail?: string }) => {
|
|
29
|
+
if (state.hold) await state.hold
|
|
30
|
+
if (state.failBeforeWrite) throw unreachable()
|
|
31
|
+
/* Refused on grounds -- permission: the row may even hold the same text; it is not a save. */
|
|
32
|
+
if (state.refuseOnGrounds) throw Object.assign(Error('권한이 없습니다.'), { code: 'FORBIDDEN' })
|
|
33
|
+
if (q.expectedSource !== row) throw Object.assign(Error('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 열어 주세요.'), { code: 'FIGURE_SOURCE_CONFLICT' })
|
|
34
|
+
row = q.source
|
|
35
|
+
if (q.thumbnail !== undefined) picture = q.thumbnail
|
|
36
|
+
state.writes++
|
|
37
|
+
if (state.failAfterWrite) throw unreachable()
|
|
38
|
+
return { id: q.id ?? 'a', source: row }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { store, state, row: () => row, setRow: (text: string) => (row = text), setPicture: (p: string | undefined) => (picture = p) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('the answer lost after it was written: read back, it was saved -- said as saved, and saving again is not refused', async () => {
|
|
45
|
+
const s = server()
|
|
46
|
+
const session = createFigureProductSession(s.store)
|
|
47
|
+
await session.open('a')
|
|
48
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
49
|
+
s.state.failAfterWrite = true
|
|
50
|
+
await session.save()
|
|
51
|
+
assert.equal(session.read().dirty, false, 'the server has it: saved')
|
|
52
|
+
assert.equal(s.state.writes, 1)
|
|
53
|
+
/* And the next save starts from what the server has. */
|
|
54
|
+
s.state.failAfterWrite = false
|
|
55
|
+
session.command({ kind: 'rename', name: 'C' } as never)
|
|
56
|
+
await session.save()
|
|
57
|
+
assert.equal(session.read().dirty, false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('a second try refused because the first landed: read back, it was saved', async () => {
|
|
61
|
+
const s = server()
|
|
62
|
+
const session = createFigureProductSession(s.store)
|
|
63
|
+
await session.open('a')
|
|
64
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
65
|
+
/* The first try wrote and its answer was lost, the page not told (it threw before the session read back). */
|
|
66
|
+
const sent = JSON.parse(session.source())
|
|
67
|
+
s.setRow(serializeFigureAsset(sent))
|
|
68
|
+
await session.save()
|
|
69
|
+
assert.equal(session.read().dirty, false, 'the conflict was its own earlier save')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('a real conflict, or no server at all: the failure stands and the edits stay', async () => {
|
|
73
|
+
const s = server()
|
|
74
|
+
const session = createFigureProductSession(s.store)
|
|
75
|
+
await session.open('a')
|
|
76
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
77
|
+
/* Someone else's edit on the server. */
|
|
78
|
+
s.setRow(serializeFigureAsset({ ...JSON.parse(s.row()), name: 'Other' }))
|
|
79
|
+
await assert.rejects(session.save(), /다른 편집에서/)
|
|
80
|
+
assert.equal(session.read().dirty, true)
|
|
81
|
+
/* Not reached, and not readable back: not known, and the edits stay. */
|
|
82
|
+
s.setRow(JSON.stringify(JSON.parse(s.row())))
|
|
83
|
+
const t = server()
|
|
84
|
+
const lost = createFigureProductSession(t.store)
|
|
85
|
+
await lost.open('a')
|
|
86
|
+
lost.command({ kind: 'rename', name: 'B' } as never)
|
|
87
|
+
t.state.failBeforeWrite = true
|
|
88
|
+
t.state.loadFails = true
|
|
89
|
+
await assert.rejects(lost.save(), (e: { code?: string }) => e.code === 'SAVE_UNREACHABLE')
|
|
90
|
+
assert.equal(lost.read().dirty, true)
|
|
91
|
+
assert.equal(t.state.writes, 0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('not reached is said as not known, not as not saved', () => {
|
|
95
|
+
const store = read('../client/graphql/store.ts')
|
|
96
|
+
assert.ok(read('../client/graphql/reached.ts').includes("if (lost) throw Object.assign(Error('서버에 연결하지 못했습니다.'), { code: unreachable, cause: error })"))
|
|
97
|
+
assert.ok(read('../client/graphql/reached.ts').includes("unreachable = 'SAVE_UNREACHABLE'"), 'a save not reached is SAVE_UNREACHABLE')
|
|
98
|
+
assert.ok(store.includes('await reached(client.mutate('), 'the save goes through it')
|
|
99
|
+
assert.ok(store.includes('async readBack(id)'), 'every field a save writes, read back')
|
|
100
|
+
const write = read('../../figure-service/server/service/figure/figure-write.ts')
|
|
101
|
+
assert.ok(write.includes("return new GraphQLError(message, { extensions: { code: 'FIGURE_SOURCE_CONFLICT' } })"), 'the server says a conflict is one')
|
|
102
|
+
const editor = read('../client/modeller/editor.ts')
|
|
103
|
+
assert.ok(editor.includes("=== 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable')"))
|
|
104
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) assert.ok(JSON.parse(read(`../translations/${lang}.json`))['figure.text.save-unreachable'], lang)
|
|
105
|
+
assert.match(JSON.parse(read('../translations/ko.json'))['figure.text.save-unreachable'], /저장됐는지 확인하지 못했습니다/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/* A released figure made a draft by a change is said when it happens -- saved source, or category and tags. */
|
|
109
|
+
test('a released figure changed back to a draft is told, whichever change did it', () => {
|
|
110
|
+
const page = read('../client/pages/figure-modeller-page.ts')
|
|
111
|
+
assert.ok(page.includes("if (before === 'released' && after === 'draft') notify({ level: 'info', message: i18next.t('figure.text.released-now-draft') })"))
|
|
112
|
+
assert.ok(page.includes('if (this.session.read().id === id) this.toldDemoted(this.row.state, figure.state)'), 'after a save, read back')
|
|
113
|
+
assert.ok(page.includes('this.toldDemoted(this.row.state, result.figure.state)'), 'after category or tags')
|
|
114
|
+
assert.ok(page.includes("...(result.figure.state ? { state: result.figure.state as 'draft' | 'released' } : {})"), 'and the badge follows')
|
|
115
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) assert.ok(JSON.parse(read(`../translations/${lang}.json`))['figure.text.released-now-draft'], lang)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
/* Designer 2026-09-27: read back, every field the save writes must match; a refusal on grounds is never a save. */
|
|
119
|
+
test('a refusal on grounds is not a save, even if the row holds the same text; a picture not written is not saved', async () => {
|
|
120
|
+
const s1 = server()
|
|
121
|
+
const session = createFigureProductSession(s1.store)
|
|
122
|
+
await session.open('a')
|
|
123
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
124
|
+
s1.setRow(serializeFigureAsset(JSON.parse(session.source())))
|
|
125
|
+
s1.state.refuseOnGrounds = true
|
|
126
|
+
await assert.rejects(session.save(), /권한이 없습니다/)
|
|
127
|
+
assert.equal(session.read().dirty, true, 'refused on grounds: not saved, whatever the row holds')
|
|
128
|
+
/* The answer lost, the source written but not the picture sent: not every field landed -- not a save. */
|
|
129
|
+
const s2 = server()
|
|
130
|
+
const other = createFigureProductSession(s2.store)
|
|
131
|
+
await other.open('a')
|
|
132
|
+
other.command({ kind: 'rename', name: 'B' } as never)
|
|
133
|
+
s2.setRow(serializeFigureAsset(JSON.parse(other.source())))
|
|
134
|
+
s2.setPicture('old-picture')
|
|
135
|
+
s2.state.failBeforeWrite = true
|
|
136
|
+
await assert.rejects(other.save({ thumbnail: 'new-picture' }), (e: { code?: string }) => e.code === 'SAVE_UNREACHABLE')
|
|
137
|
+
assert.equal(other.read().dirty, true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('no edit gets in while it saves; the next save starts from what the server has', async () => {
|
|
141
|
+
const s = server()
|
|
142
|
+
const session = createFigureProductSession(s.store)
|
|
143
|
+
await session.open('a')
|
|
144
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
145
|
+
let release!: () => void
|
|
146
|
+
s.state.hold = new Promise<void>(r => (release = r))
|
|
147
|
+
const saving = session.save()
|
|
148
|
+
await new Promise(r => setTimeout(r, 0))
|
|
149
|
+
/* While it saves, an edit is refused: nothing made after the request can be marked saved or wiped. */
|
|
150
|
+
assert.throws(() => session.command({ kind: 'rename', name: 'C' } as never), /저장이 끝난 뒤 편집해 주세요/)
|
|
151
|
+
release()
|
|
152
|
+
await saving
|
|
153
|
+
assert.equal(JSON.parse(s.row()).name, 'B')
|
|
154
|
+
assert.equal(session.read().dirty, false)
|
|
155
|
+
session.command({ kind: 'rename', name: 'D' } as never)
|
|
156
|
+
await session.save()
|
|
157
|
+
assert.equal(JSON.parse(s.row()).name, 'D')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/* The answer lost and not readable back; edited further; saved again: the row holds our earlier save, not another's. */
|
|
161
|
+
test('a save never answered, then more edits: the sent content confirmed on the server, the next save is conditional on it', async () => {
|
|
162
|
+
const s = server()
|
|
163
|
+
const session = createFigureProductSession(s.store)
|
|
164
|
+
await session.open('a')
|
|
165
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
166
|
+
s.state.failAfterWrite = true
|
|
167
|
+
s.state.loadFails = true
|
|
168
|
+
await assert.rejects(session.save(), (e: { code?: string }) => e.code === 'SAVE_UNREACHABLE')
|
|
169
|
+
assert.equal(session.read().dirty, true, 'not known: still unsaved')
|
|
170
|
+
s.state.failAfterWrite = false
|
|
171
|
+
s.state.loadFails = false
|
|
172
|
+
session.command({ kind: 'rename', name: 'C' } as never)
|
|
173
|
+
await session.save()
|
|
174
|
+
assert.equal(JSON.parse(s.row()).name, 'C', 'saved, conditional on the server text confirmed to be what was sent')
|
|
175
|
+
assert.equal(session.read().dirty, false)
|
|
176
|
+
/* Another's edit on the row is still a conflict. */
|
|
177
|
+
s.setRow(serializeFigureAsset({ ...JSON.parse(s.row()), name: 'Theirs' }))
|
|
178
|
+
session.command({ kind: 'rename', name: 'E' } as never)
|
|
179
|
+
await assert.rejects(session.save(), /다른 편집에서/)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/* ADR-0097 §5.6: sent again only on what was compared -- a row whose picture is not the one sent is not ours to write over. */
|
|
183
|
+
test('the sent content confirmed on the server means every field of it, the picture too', async () => {
|
|
184
|
+
const s = server()
|
|
185
|
+
const session = createFigureProductSession(s.store)
|
|
186
|
+
await session.open('a')
|
|
187
|
+
session.command({ kind: 'rename', name: 'B' } as never)
|
|
188
|
+
s.state.failAfterWrite = true
|
|
189
|
+
s.state.loadFails = true
|
|
190
|
+
await assert.rejects(session.save({ thumbnail: 'mine' }), (e: { code?: string }) => e.code === 'SAVE_UNREACHABLE')
|
|
191
|
+
s.state.failAfterWrite = false
|
|
192
|
+
s.state.loadFails = false
|
|
193
|
+
/* Someone else changed the picture since: the source matches what was sent, the picture does not. */
|
|
194
|
+
s.setPicture('theirs')
|
|
195
|
+
session.command({ kind: 'rename', name: 'C' } as never)
|
|
196
|
+
await assert.rejects(session.save({ thumbnail: 'mine-2' }), /다른 편집에서/)
|
|
197
|
+
assert.equal(session.read().dirty, true)
|
|
198
|
+
})
|