@things-factory/figure-ui 10.1.167 → 10.1.168
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 +5 -0
- package/client/modeller/ai-proposal.ts +20 -2
- package/client/modeller/figure-properties.ts +1 -3
- package/client/modeller/product-session.ts +9 -4
- package/client/pages/figure-modeller-page.ts +7 -6
- package/dist-client/figure-assistant.js +5 -0
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +8 -0
- package/dist-client/modeller/ai-proposal.js +16 -3
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/figure-properties.d.ts +1 -2
- package/dist-client/modeller/figure-properties.js +1 -3
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +14 -3
- package/dist-client/modeller/product-session.js +9 -4
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.js +7 -6
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/test/ai-proposal.test.ts +73 -2
- package/test/browser/acceptance-page.js +3 -1
- package/test/browser/product-page.check.mjs +63 -3
- package/test/browser/product-page.js +7 -2
- package/test/figure-assistant-surface.test.ts +27 -1
- package/test/proposal-record.test.ts +18 -6
- package/translations/en.json +0 -12
- package/translations/es.json +0 -12
- package/translations/ja.json +0 -12
- package/translations/ko.json +0 -12
- package/translations/ms.json +0 -12
- package/translations/zh.json +0 -12
- package/client/modeller/carrier-declaration.ts +0 -63
- package/client/modeller/figure-carrier.ts +0 -117
- package/dist-client/modeller/carrier-declaration.d.ts +0 -31
- package/dist-client/modeller/carrier-declaration.js +0 -53
- package/dist-client/modeller/carrier-declaration.js.map +0 -1
- package/dist-client/modeller/figure-carrier.d.ts +0 -26
- package/dist-client/modeller/figure-carrier.js +0 -123
- package/dist-client/modeller/figure-carrier.js.map +0 -1
- package/test/carrier-declaration.test.ts +0 -69
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-ui",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.168",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"clean": "npm run clean:server && npm run clean:client"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@hatiolab/figure-model": "^0.
|
|
31
|
-
"@hatiolab/things-scene": "^10.1.
|
|
30
|
+
"@hatiolab/figure-model": "^0.7.0",
|
|
31
|
+
"@hatiolab/things-scene": "^10.1.90",
|
|
32
32
|
"@material/web": "^2.0.0",
|
|
33
33
|
"@operato/data-grist": "^10.0.0",
|
|
34
34
|
"@operato/graphql": "^10.0.0",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"@operato/slot-layout-editor": "^10.29.25",
|
|
42
42
|
"@operato/styles": "^10.0.0",
|
|
43
43
|
"@operato/utils": "^10.0.0",
|
|
44
|
-
"@things-factory/ai-assistant": "^10.1.
|
|
45
|
-
"@things-factory/figure-service": "^10.1.
|
|
46
|
-
"@things-factory/figure-viewer": "^10.1.
|
|
44
|
+
"@things-factory/ai-assistant": "^10.1.168",
|
|
45
|
+
"@things-factory/figure-service": "^10.1.168",
|
|
46
|
+
"@things-factory/figure-viewer": "^10.1.168",
|
|
47
47
|
"@things-factory/setting-base": "^10.1.167",
|
|
48
48
|
"three": "^0.185.1"
|
|
49
49
|
},
|
|
50
|
-
"gitHead": "
|
|
50
|
+
"gitHead": "fe067ff11d90effffc2523374453d9b8313b489f"
|
|
51
51
|
}
|
package/test/ai-proposal.test.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
9
|
import { createFigureAsset, addFigureBox, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
10
10
|
|
|
11
11
|
import { createFigureProductSession } from '../client/modeller/product-session.js'
|
|
12
|
-
import {
|
|
12
|
+
import type { FigureProposalStep } from '@hatiolab/figure-model'
|
|
13
|
+
import { pendNewFigure, runFigureProposal, takeFigureOffer, takePendingNewFigure, type FigureApplyDetail } from '../client/modeller/ai-proposal.js'
|
|
13
14
|
|
|
14
15
|
const ko = JSON.parse(readFileSync(fileURLToPath(new URL('../translations/ko.json', import.meta.url)), 'utf8'))
|
|
15
16
|
const say = (key: string, params: Record<string, string> = {}) => {
|
|
@@ -51,9 +52,18 @@ test('a person\'s edit after the proposal was asked makes it stale -- refused, n
|
|
|
51
52
|
assert.equal(s.read().name, 'A')
|
|
52
53
|
})
|
|
53
54
|
|
|
54
|
-
const taker = (result: Partial<FigureApplyDetail>) => ({
|
|
55
|
+
const taker = (result: Partial<FigureApplyDetail>, saved: Array<{ figureId: string; type?: string; name?: string }> = []) => ({
|
|
55
56
|
offered: [] as FigureApplyDetail[],
|
|
56
57
|
opened: [] as unknown[],
|
|
58
|
+
asked: [] as string[],
|
|
59
|
+
shown: [] as string[],
|
|
60
|
+
async savedInto(key: string) {
|
|
61
|
+
this.asked.push(key)
|
|
62
|
+
return saved
|
|
63
|
+
},
|
|
64
|
+
openSaved(figureId: string) {
|
|
65
|
+
this.shown.push(figureId)
|
|
66
|
+
},
|
|
57
67
|
offer(detail: FigureApplyDetail) {
|
|
58
68
|
this.offered.push(detail)
|
|
59
69
|
return Object.assign(detail, result)
|
|
@@ -94,6 +104,67 @@ test('a new figure with no new modeller on screen opens one', async () => {
|
|
|
94
104
|
assert.equal(target.opened.length, 1)
|
|
95
105
|
})
|
|
96
106
|
|
|
107
|
+
/*
|
|
108
|
+
The same proposal is one figure (ADR-0061 decision 3, ruled 2026-10-16): a new figure taken from it in the modeller keeps
|
|
109
|
+
the figure's own creation id, so what says the proposal was already made is the save's record of it, read before the
|
|
110
|
+
card runs again.
|
|
111
|
+
*/
|
|
112
|
+
test('a new figure\'s card run again after it was saved opens that figure, and makes none', async () => {
|
|
113
|
+
const K = '11111111-1111-4111-8111-111111111111'
|
|
114
|
+
const target = taker({ applied: true }, [{ figureId: 'R', type: 'box', name: 'Box with lid' }])
|
|
115
|
+
const out = await runFigureProposal({ key: K, steps, create: true }, target, say)
|
|
116
|
+
assert.deepEqual(target.asked, [K])
|
|
117
|
+
assert.deepEqual(target.shown, ['R'])
|
|
118
|
+
assert.equal(target.offered.length + target.opened.length, 0, 'not offered to a modeller, no new one opened')
|
|
119
|
+
assert.deepEqual(out, { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names: 'Box with lid' }) })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a new figure\'s card not saved anywhere yet runs as before; a revision is not looked up', async () => {
|
|
123
|
+
const K = '11111111-1111-4111-8111-111111111111'
|
|
124
|
+
const fresh = taker({})
|
|
125
|
+
assert.equal((await runFigureProposal({ key: K, steps, create: true }, fresh, say)).verdict, 'accepted')
|
|
126
|
+
assert.deepEqual({ asked: fresh.asked, opened: fresh.opened.length }, { asked: [K], opened: 1 })
|
|
127
|
+
const revision = taker({ applied: true }, [{ figureId: 'R' }])
|
|
128
|
+
await runFigureProposal({ key: K, steps, context: 'c', revision: 3 }, revision, say)
|
|
129
|
+
assert.deepEqual({ asked: revision.asked, shown: revision.shown }, { asked: [], shown: [] })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
Open (ADR-0061 decision 3, ruled 2026-10-16, not prevented): the same new figure's card run in another tab before the
|
|
134
|
+
first figure is saved finds no record yet, and opens a second figure under the proposal's key.
|
|
135
|
+
*/
|
|
136
|
+
test('the same new figure\'s card in another tab before the first save makes a second figure under its key', async () => {
|
|
137
|
+
const K = '11111111-1111-4111-8111-111111111111'
|
|
138
|
+
const ids: string[] = []
|
|
139
|
+
const tab = () =>
|
|
140
|
+
createFigureProductSession({
|
|
141
|
+
load: store.load,
|
|
142
|
+
save: async (q: { id?: string; source: string; creationId?: string }) => (ids.push(q.creationId!), { id: q.id ?? q.creationId!, source: q.source })
|
|
143
|
+
})
|
|
144
|
+
const made = [{ kind: 'add-part', part: { id: 'lid', kind: 'box', dimensions: { width: 100, height: 10, depth: 100 } } }] as any[]
|
|
145
|
+
const first = tab()
|
|
146
|
+
first.startNew()
|
|
147
|
+
const detail: FigureApplyDetail = { key: K, steps: made, create: true }
|
|
148
|
+
const here = { ...taker({}), offer: (d: FigureApplyDetail) => (takeFigureOffer(first, d, { onSavedFigure: false, say }), d) }
|
|
149
|
+
assert.equal((await runFigureProposal(detail, here, say)).verdict, 'accepted')
|
|
150
|
+
|
|
151
|
+
const second = tab()
|
|
152
|
+
const there = {
|
|
153
|
+
...taker({}),
|
|
154
|
+
async openNew(s: FigureProposalStep[], key?: string) {
|
|
155
|
+
second.startNew()
|
|
156
|
+
second.createUnder(key!)
|
|
157
|
+
second.applyProposal(s, second.read().revision!, second.read().context!, key)
|
|
158
|
+
return { applied: true }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
assert.equal((await runFigureProposal({ key: K, steps: made, create: true }, there, say)).verdict, 'accepted')
|
|
162
|
+
await first.save()
|
|
163
|
+
await second.save()
|
|
164
|
+
assert.deepEqual(ids, [first.read().creationId, K])
|
|
165
|
+
assert.notEqual(ids[0], ids[1], 'two figures')
|
|
166
|
+
})
|
|
167
|
+
|
|
97
168
|
test('an empty card is not applied', async () => {
|
|
98
169
|
assert.equal((await runFigureProposal({ steps: [] }, taker({ applied: true }), say)).verdict, 'unavailable')
|
|
99
170
|
})
|
|
@@ -202,7 +202,9 @@ async function start() {
|
|
|
202
202
|
})
|
|
203
203
|
const dock = {
|
|
204
204
|
offer: detail => (window.dispatchEvent(new CustomEvent('figure-apply', { detail })), detail),
|
|
205
|
-
openNew: async () => ({ applied: false, code: 'not-opened' })
|
|
205
|
+
openNew: async () => ({ applied: false, code: 'not-opened' }),
|
|
206
|
+
savedInto: async () => [],
|
|
207
|
+
openSaved: () => {}
|
|
206
208
|
}
|
|
207
209
|
window.acceptance = {
|
|
208
210
|
open: async id => (await session.open(id), editor.requestUpdate(), editor.dispatchEvent(new CustomEvent('changed'))),
|
|
@@ -541,14 +541,30 @@ describe('the page around the figure: the shell, the dock, the properties', () =
|
|
|
541
541
|
assert.equal(await beforeUnload(), true)
|
|
542
542
|
})
|
|
543
543
|
|
|
544
|
-
|
|
544
|
+
/*
|
|
545
|
+
A figure not saved yet is a subject (ADR-0061 decision 3): its conversation is anchored on the creation id its first
|
|
546
|
+
save inserts the row under, so the first save changes nothing the dock knows -- the same conversation goes on.
|
|
547
|
+
*/
|
|
548
|
+
test('a new figure: the dock talks about it by its creation id, and its first save keeps the conversation', async () => {
|
|
545
549
|
await press('button[save]', 'save')
|
|
546
550
|
await until(() => !product.view().dirty, 'saved')
|
|
547
551
|
await read(() => assistant.forget())
|
|
548
552
|
await open(null)
|
|
549
|
-
|
|
550
|
-
assert.
|
|
553
|
+
const creationId = await read(() => product.page().session.read().creationId)
|
|
554
|
+
assert.match(creationId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
555
|
+
assert.deepEqual((await said()).filter(s => s.call !== 'notify'), [{ call: 'provide', key: `figure:${creationId}` }])
|
|
556
|
+
assert.deepEqual(await read(() => assistant.surface.anchor()), { anchorType: 'figure', anchorId: creationId })
|
|
557
|
+
assert.deepEqual(await read(() => { const { figureId, editorMode } = assistant.surface.hostContext(); return { figureId, editorMode } }), { figureId: creationId, editorMode: 'new' })
|
|
551
558
|
assert.equal((await read(() => product.context())).menu, undefined)
|
|
559
|
+
await rename('Gate')
|
|
560
|
+
await read(() => { assistant.forget(); product.forget() })
|
|
561
|
+
await press('button[save]', 'save')
|
|
562
|
+
await press('figure-first-save >>> md-filled-button[slot=confirm]', 'the first save')
|
|
563
|
+
await until(() => product.view().id && !product.view().dirty, 'saved')
|
|
564
|
+
assert.equal((await callsTo('saveFigure'))[0].creationId, creationId)
|
|
565
|
+
assert.equal(await read(() => product.view().id), creationId)
|
|
566
|
+
assert.deepEqual((await said()).filter(s => s.call !== 'notify'), [], 'no new conversation, none taken back')
|
|
567
|
+
assert.deepEqual(await read(() => { const { figureId, editorMode } = assistant.surface.hostContext(); return { figureId, editorMode } }), { figureId: creationId, editorMode: 'existing' })
|
|
552
568
|
})
|
|
553
569
|
|
|
554
570
|
test('inactive: the surface is taken back', async () => {
|
|
@@ -643,6 +659,7 @@ describe('AI proposals reaching the page', () => {
|
|
|
643
659
|
const outcome = await read(key => assistant.surface.proposalOutcome(key), K1)
|
|
644
660
|
assert.deepEqual(outcome, { verdict: 'accepted', note: 'figure.text.proposal-saved-into {"names":"Door"}' })
|
|
645
661
|
assert.deepEqual(await callsTo('figureProposalOutcome'), [{ proposalKey: K1 }])
|
|
662
|
+
await read(() => product.answer('figureProposalOutcome'))
|
|
646
663
|
})
|
|
647
664
|
|
|
648
665
|
test("a new figure proposed: the modeller opens a new one, takes the steps under the proposal's key, and the dock is told", async () => {
|
|
@@ -661,6 +678,7 @@ describe('AI proposals reaching the page', () => {
|
|
|
661
678
|
outcome: { verdict: 'accepted', applied: 1, note: 'figure.text.proposal-applied {"n":"1"}' },
|
|
662
679
|
told: [{ level: 'info', note: 'figure.text.proposal-applied {"n":"1"}' }]
|
|
663
680
|
})
|
|
681
|
+
assert.deepEqual(await callsTo('figureProposalOutcome'), [{ proposalKey: K2 }], 'asked whether it was saved before')
|
|
664
682
|
assert.deepEqual((await read(() => product.navigations())).map(n => n.path), ['/figure-modeller'])
|
|
665
683
|
const made = await read(() => product.view())
|
|
666
684
|
assert.equal(made.id, undefined)
|
|
@@ -671,6 +689,48 @@ describe('AI proposals reaching the page', () => {
|
|
|
671
689
|
const [saved] = await callsTo('saveFigure')
|
|
672
690
|
assert.deepEqual({ creationId: saved.creationId, proposalKeys: saved.proposalKeys }, { creationId: K2, proposalKeys: [K2] })
|
|
673
691
|
})
|
|
692
|
+
|
|
693
|
+
/*
|
|
694
|
+
The draft the dock made on a new figure stays that figure's conversation (ADR-0061 decision 3, ruled 2026-10-16): the
|
|
695
|
+
figure keeps its creation id, and the save records the proposal. Run again once saved, the card opens that figure --
|
|
696
|
+
the record, not the row id, is what makes one proposal one figure.
|
|
697
|
+
*/
|
|
698
|
+
test('a new figure\'s proposal taken on a new figure keeps its conversation; run again after the save, it opens that figure', async () => {
|
|
699
|
+
/* After the one above: the figure on the page is saved, so a new one opens without a question. */
|
|
700
|
+
await until(() => product.view().id && !product.view().dirty, 'the one above saved')
|
|
701
|
+
await open(null)
|
|
702
|
+
const creationId = await read(() => product.page().session.read().creationId)
|
|
703
|
+
await read(() => { assistant.forget(); product.forget() })
|
|
704
|
+
const run = () =>
|
|
705
|
+
read(
|
|
706
|
+
async ({ key, steps }) => {
|
|
707
|
+
const told = []
|
|
708
|
+
const outcome = await assistant.handlers.proposeFigureCandidate({ key, steps, create: true }, { notify: (level, note) => told.push({ level, note }) })
|
|
709
|
+
return { outcome, told }
|
|
710
|
+
},
|
|
711
|
+
{ key: K1, steps: LID }
|
|
712
|
+
)
|
|
713
|
+
assert.deepEqual((await run()).outcome, { verdict: 'accepted', applied: 1, note: 'figure.text.proposal-applied {"n":"1"}' })
|
|
714
|
+
assert.equal(await read(() => product.page().session.read().creationId), creationId, 'the proposal\'s key is not the figure\'s id')
|
|
715
|
+
assert.deepEqual((await said()).filter(s => s.call !== 'notify'), [], 'the same conversation: no surface given or taken back')
|
|
716
|
+
assert.deepEqual(await read(() => product.navigations()), [], 'taken where it was asked')
|
|
717
|
+
await press('button[save]', 'save')
|
|
718
|
+
await press('figure-first-save >>> md-filled-button[slot=confirm]', 'the first save')
|
|
719
|
+
await until(() => product.view().id && !product.view().dirty, 'saved')
|
|
720
|
+
const [saved] = await callsTo('saveFigure')
|
|
721
|
+
assert.deepEqual({ creationId: saved.creationId, proposalKeys: saved.proposalKeys }, { creationId, proposalKeys: [K1] })
|
|
722
|
+
|
|
723
|
+
await open(null)
|
|
724
|
+
await read(() => product.forget())
|
|
725
|
+
const before = await read(() => product.rows().length)
|
|
726
|
+
const names = await read(id => product.rows().find(r => r.id === id).name, creationId)
|
|
727
|
+
const note = `figure.text.proposal-saved-into ${JSON.stringify({ names })}`
|
|
728
|
+
assert.deepEqual(await run(), { outcome: { verdict: 'accepted', note }, told: [{ level: 'info', note }] })
|
|
729
|
+
assert.deepEqual(await callsTo('figureProposalOutcome'), [{ proposalKey: K1 }])
|
|
730
|
+
assert.deepEqual((await read(() => product.navigations())).map(n => n.path), [`/figure-modeller/${creationId}`])
|
|
731
|
+
assert.equal(await read(() => product.rows().length), before, 'no second figure')
|
|
732
|
+
assert.deepEqual(await callsTo('saveFigure'), [])
|
|
733
|
+
})
|
|
674
734
|
})
|
|
675
735
|
|
|
676
736
|
describe('the catalogue preview', () => {
|
|
@@ -61,6 +61,8 @@ const calls = []
|
|
|
61
61
|
/* Answers a check wants instead of the server's: field -> function(variables) returning data or throwing. */
|
|
62
62
|
const overrides = new Map()
|
|
63
63
|
let made = 0
|
|
64
|
+
/* What the saves recorded of AI proposals: { proposalKey, figureId }. */
|
|
65
|
+
const records = []
|
|
64
66
|
|
|
65
67
|
function row(id) {
|
|
66
68
|
const found = rows.get(id)
|
|
@@ -71,8 +73,10 @@ function row(id) {
|
|
|
71
73
|
const answers = {
|
|
72
74
|
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) }),
|
|
73
75
|
figureTypeNames: () => [...rows.values()].map(r => r.type),
|
|
74
|
-
saveFigure: async ({ id, source, type, creationId, thumbnail }) => {
|
|
76
|
+
saveFigure: async ({ id, source, type, creationId, thumbnail, proposalKeys }) => {
|
|
75
77
|
const saved = id ? row(id) : { id: creationId ?? `made-${++made}`, namespace: null, type: type ?? `figure-${made}`, state: 'draft', version: 0 }
|
|
78
|
+
/* The save's record of the proposals its source holds, as the server keeps it (figure-proposal.ts). */
|
|
79
|
+
for (const key of proposalKeys ?? []) if (!records.some(r => r.proposalKey === key && r.figureId === saved.id)) records.push({ proposalKey: key, figureId: saved.id })
|
|
76
80
|
/* The picture is kept by the hash of its bytes, as the server keeps it (ADR-0104 decision 6). */
|
|
77
81
|
Object.assign(saved, { source, name: JSON.parse(source).name, state: 'draft' }, thumbnail === undefined ? {} : { thumbnailHash: await thumbnailHashOf(thumbnail) })
|
|
78
82
|
rows.set(saved.id, saved)
|
|
@@ -87,7 +91,8 @@ const answers = {
|
|
|
87
91
|
return { __typename: 'Figure', id, version: released.version, state: 'released', updatedAt: null }
|
|
88
92
|
},
|
|
89
93
|
figureRecipeTemplates: () => [],
|
|
90
|
-
figureProposalOutcome: () =>
|
|
94
|
+
figureProposalOutcome: ({ proposalKey }) =>
|
|
95
|
+
records.filter(r => r.proposalKey === proposalKey).map(r => ({ figureId: r.figureId, type: rows.get(r.figureId)?.type, name: rows.get(r.figureId)?.name, recordedAt: null })),
|
|
91
96
|
figureReplacedDrafts: () => [],
|
|
92
97
|
figureVersions: () => [],
|
|
93
98
|
figureReleased: () => null,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The modeller's AI - the framework dock talks about the figure on screen (design/ai/shared-dock.md §3).
|
|
3
3
|
*
|
|
4
4
|
* What the page does with the dock -- ① a saved figure provides a surface anchored on its row ② the draft is read at the
|
|
5
|
-
* moment of sending ③ an unsaved figure provides
|
|
5
|
+
* moment of sending ③ an unsaved figure provides one anchored on its creation id ④ the surface is taken back when the page goes inactive or
|
|
6
6
|
* leaves ⑤ a proposal is taken through the page, and a new figure's opens a new figure -- is checked on the page itself
|
|
7
7
|
* in test/browser/product-page.check.mjs. Here: the offer the page takes it by, and the words every outcome is said in.
|
|
8
8
|
*/
|
|
@@ -43,6 +43,32 @@ test('the dock applies a proposal through the page, on the version the model saw
|
|
|
43
43
|
assert.equal(create.applied, undefined)
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
+
/*
|
|
47
|
+
A figure not saved yet is a subject too (ADR-0061 decision 3): its conversation is anchored on the creation id the
|
|
48
|
+
first save inserts the row under, so the anchor is the same before and after that save. Taking a new figure's
|
|
49
|
+
proposal on it must not change that id -- the conversation that asked for the draft would be left behind.
|
|
50
|
+
*/
|
|
51
|
+
test('a new figure keeps its creation id through a new figure\'s proposal and the first save', async () => {
|
|
52
|
+
const sent: Array<{ id?: string; creationId?: string; proposalKeys?: string[] }> = []
|
|
53
|
+
const session = createFigureProductSession({
|
|
54
|
+
load: async (id: string) => ({ id, source: '' }),
|
|
55
|
+
save: async (q: { id?: string; source: string; creationId?: string; proposalKeys?: string[] }) => {
|
|
56
|
+
sent.push({ id: q.id, creationId: q.creationId, proposalKeys: q.proposalKeys })
|
|
57
|
+
return { id: q.id ?? q.creationId!, source: q.source }
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
session.startNew()
|
|
61
|
+
const creationId = session.read().creationId
|
|
62
|
+
assert.match(creationId ?? '', /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
63
|
+
const create: FigureApplyDetail = { key: K1, steps: lid, create: true }
|
|
64
|
+
assert.equal(takeFigureOffer(session, create, { onSavedFigure: false, say }), true)
|
|
65
|
+
assert.equal(session.read().creationId, creationId, 'the proposal\'s key is not the figure\'s id')
|
|
66
|
+
assert.deepEqual(session.proposalsInEffect(), [K1], 'the save still records the proposal')
|
|
67
|
+
await session.save()
|
|
68
|
+
assert.deepEqual(sent, [{ id: undefined, creationId, proposalKeys: [K1] }])
|
|
69
|
+
assert.deepEqual({ id: session.read().id, creationId: session.read().creationId }, { id: creationId, creationId })
|
|
70
|
+
})
|
|
71
|
+
|
|
46
72
|
test('a figure proposal is answered by its handler, in words, never left without one', () => {
|
|
47
73
|
for (const lang of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
|
|
48
74
|
const words = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* What became of an AI proposal (ADR-0097 §5.6 item 5; AI lane, 2026-09-27). A save names the proposals whose steps its
|
|
3
|
-
* text holds -- undone, a proposal is not in it; a new figure
|
|
3
|
+
* text holds -- undone, a proposal is not in it; a new figure opened for a proposal is created under the proposal's key.
|
|
4
4
|
* The server side (recorded in the save's transaction, one figure per key) is tested on sqlite in figure-service
|
|
5
5
|
* (figure-proposal-db).
|
|
6
6
|
*/
|
|
@@ -59,16 +59,28 @@ test('a save names the proposals its text holds: undone, not; redone, again; und
|
|
|
59
59
|
assert.deepEqual(s.proposalsInEffect(), [K1])
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/*
|
|
63
|
+
Two ways a new figure comes from a proposal (ADR-0061 decision 3, ruled 2026-10-16). Opened for a proposal from another
|
|
64
|
+
screen, it is created under the proposal's key. Taken on the new figure the modeller has open, its conversation is
|
|
65
|
+
anchored on that figure's creation id, which stays; the save records the proposal either way.
|
|
66
|
+
*/
|
|
67
|
+
test('a new figure opened for a proposal is created under its key; one taken on the open new figure keeps its id', async () => {
|
|
63
68
|
const { store, saves } = server()
|
|
64
69
|
const s = createFigureProductSession(store)
|
|
65
70
|
s.startNew()
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
s.createUnder(K1)
|
|
72
|
+
s.applyProposal(lid, s.read().revision!, s.read().context!, K1)
|
|
68
73
|
await s.save({ type: 'LIDDED' })
|
|
69
74
|
assert.equal(saves.at(-1)!.creationId, K1, 'created under the proposal key: taken twice, it meets the first figure')
|
|
70
75
|
assert.deepEqual(saves.at(-1)!.proposalKeys, [K1])
|
|
71
76
|
|
|
77
|
+
s.startNew({ discard: true })
|
|
78
|
+
const own = s.read().creationId
|
|
79
|
+
const detail: FigureApplyDetail = { key: K2, steps: lid, create: true }
|
|
80
|
+
assert.equal(takeFigureOffer(s, detail, { onSavedFigure: false, say }), true)
|
|
81
|
+
await s.save({ type: 'LIDDED_2' })
|
|
82
|
+
assert.deepEqual({ creationId: saves.at(-1)!.creationId, proposalKeys: saves.at(-1)!.proposalKeys }, { creationId: own, proposalKeys: [K2] })
|
|
83
|
+
|
|
72
84
|
/* A new figure the person has touched is theirs: the proposal is not put on it (NEW_FIGURE_UNSAVED), its id stays. */
|
|
73
85
|
s.startNew({ discard: true })
|
|
74
86
|
s.command({ kind: 'rename', name: 'Mine' } as never)
|
|
@@ -84,13 +96,13 @@ test('the key travels from the card to the modeller, on a revision and on a new
|
|
|
84
96
|
const opened: Array<string | undefined> = []
|
|
85
97
|
await runFigureProposal(
|
|
86
98
|
{ key: K1, steps: lid, create: false, context: 'c', revision: 1 },
|
|
87
|
-
{ offer: d => (offered.push(d), { ...d, applied: true }), openNew: async () => ({ applied: false }) },
|
|
99
|
+
{ offer: d => (offered.push(d), { ...d, applied: true }), openNew: async () => ({ applied: false }), savedInto: async () => [], openSaved: () => {} },
|
|
88
100
|
say
|
|
89
101
|
)
|
|
90
102
|
assert.equal(offered[0]!.key, K1)
|
|
91
103
|
await runFigureProposal(
|
|
92
104
|
{ key: K2, steps: lid, create: true },
|
|
93
|
-
{ offer: d => d, openNew: async (_steps, key) => (opened.push(key), { applied: true }) },
|
|
105
|
+
{ offer: d => d, openNew: async (_steps, key) => (opened.push(key), { applied: true }), savedInto: async () => [], openSaved: () => {} },
|
|
94
106
|
say
|
|
95
107
|
)
|
|
96
108
|
assert.deepEqual(opened, [K2])
|
package/translations/en.json
CHANGED
|
@@ -973,18 +973,6 @@
|
|
|
973
973
|
"figure.text.detail-unreleased": "Not released yet. Showing the saved draft.",
|
|
974
974
|
"figure.button.detail-edit": "Open editor",
|
|
975
975
|
"figure.button.download-package": "download package",
|
|
976
|
-
"figure.label.group-carrier": "Carrier",
|
|
977
|
-
"figure.label.carrier-type": "Carrier type",
|
|
978
|
-
"figure.text.carrier-type-placeholder": "CARRIERTYPE",
|
|
979
|
-
"figure.label.carrier-size": "Physical size",
|
|
980
|
-
"figure.label.carrier-width": "Width",
|
|
981
|
-
"figure.label.carrier-length": "Length",
|
|
982
|
-
"figure.label.carrier-height": "Height",
|
|
983
|
-
"figure.button.declare-carrier": "Declare carrier",
|
|
984
|
-
"figure.button.clear-carrier": "Clear",
|
|
985
|
-
"figure.text.carrier-type-required": "Enter the carrier type code.",
|
|
986
|
-
"figure.text.carrier-type-too-long": "A carrier type code is at most {max} characters.",
|
|
987
|
-
"figure.text.carrier-length-positive": "{axis} must be greater than 0.",
|
|
988
976
|
"figure.button.import-mesh": "import GLB",
|
|
989
977
|
"figure.text.import-mesh-hint": "brings a GLB file in as a part: the whole file, or one part of it.",
|
|
990
978
|
"figure.text.mesh-whole-file": "whole file",
|
package/translations/es.json
CHANGED
|
@@ -973,18 +973,6 @@
|
|
|
973
973
|
"figure.text.detail-unreleased": "Aún no publicada. Se muestra el borrador guardado.",
|
|
974
974
|
"figure.button.detail-edit": "Abrir editor",
|
|
975
975
|
"figure.button.download-package": "descargar paquete",
|
|
976
|
-
"figure.label.group-carrier": "Portador",
|
|
977
|
-
"figure.label.carrier-type": "Tipo de portador",
|
|
978
|
-
"figure.text.carrier-type-placeholder": "CARRIERTYPE",
|
|
979
|
-
"figure.label.carrier-size": "Tamaño físico",
|
|
980
|
-
"figure.label.carrier-width": "Ancho",
|
|
981
|
-
"figure.label.carrier-length": "Largo",
|
|
982
|
-
"figure.label.carrier-height": "Alto",
|
|
983
|
-
"figure.button.declare-carrier": "Declarar portador",
|
|
984
|
-
"figure.button.clear-carrier": "Quitar",
|
|
985
|
-
"figure.text.carrier-type-required": "Introduzca el código del tipo de portador.",
|
|
986
|
-
"figure.text.carrier-type-too-long": "El código del tipo de portador tiene como máximo {max} caracteres.",
|
|
987
|
-
"figure.text.carrier-length-positive": "{axis} debe ser mayor que 0.",
|
|
988
976
|
"figure.button.import-mesh": "importar GLB",
|
|
989
977
|
"figure.text.import-mesh-hint": "trae un archivo GLB como pieza: el archivo entero o una parte de él.",
|
|
990
978
|
"figure.text.mesh-whole-file": "archivo entero",
|
package/translations/ja.json
CHANGED
|
@@ -973,18 +973,6 @@
|
|
|
973
973
|
"figure.text.detail-unreleased": "まだ公開されていません。保存された下書きを表示します。",
|
|
974
974
|
"figure.button.detail-edit": "編集画面を開く",
|
|
975
975
|
"figure.button.download-package": "パッケージをダウンロード",
|
|
976
|
-
"figure.label.group-carrier": "キャリア",
|
|
977
|
-
"figure.label.carrier-type": "キャリアタイプ",
|
|
978
|
-
"figure.text.carrier-type-placeholder": "CARRIERTYPE",
|
|
979
|
-
"figure.label.carrier-size": "実寸",
|
|
980
|
-
"figure.label.carrier-width": "幅",
|
|
981
|
-
"figure.label.carrier-length": "長さ",
|
|
982
|
-
"figure.label.carrier-height": "高さ",
|
|
983
|
-
"figure.button.declare-carrier": "キャリアとして宣言",
|
|
984
|
-
"figure.button.clear-carrier": "宣言を解除",
|
|
985
|
-
"figure.text.carrier-type-required": "キャリアタイプのコードを入力してください。",
|
|
986
|
-
"figure.text.carrier-type-too-long": "キャリアタイプのコードは {max} 文字までです。",
|
|
987
|
-
"figure.text.carrier-length-positive": "{axis} は 0 より大きい値にしてください。",
|
|
988
976
|
"figure.button.import-mesh": "GLB を取り込む",
|
|
989
977
|
"figure.text.import-mesh-hint": "GLB ファイルを部品として取り込みます。ファイル全体か、その中の 1 つを選びます。",
|
|
990
978
|
"figure.text.mesh-whole-file": "ファイル全体",
|
package/translations/ko.json
CHANGED
|
@@ -973,18 +973,6 @@
|
|
|
973
973
|
"figure.text.detail-unreleased": "아직 발행되지 않았습니다. 저장된 초안을 보여줍니다.",
|
|
974
974
|
"figure.button.detail-edit": "편집 화면 열기",
|
|
975
975
|
"figure.button.download-package": "꾸러미 내려받기",
|
|
976
|
-
"figure.label.group-carrier": "캐리어",
|
|
977
|
-
"figure.label.carrier-type": "캐리어 유형",
|
|
978
|
-
"figure.text.carrier-type-placeholder": "CARRIERTYPE",
|
|
979
|
-
"figure.label.carrier-size": "실물 크기",
|
|
980
|
-
"figure.label.carrier-width": "폭",
|
|
981
|
-
"figure.label.carrier-length": "길이",
|
|
982
|
-
"figure.label.carrier-height": "높이",
|
|
983
|
-
"figure.button.declare-carrier": "캐리어로 선언",
|
|
984
|
-
"figure.button.clear-carrier": "선언 해제",
|
|
985
|
-
"figure.text.carrier-type-required": "캐리어 유형 코드를 입력해 주세요.",
|
|
986
|
-
"figure.text.carrier-type-too-long": "캐리어 유형 코드는 {max}자까지입니다.",
|
|
987
|
-
"figure.text.carrier-length-positive": "{axis} 값은 0보다 커야 합니다.",
|
|
988
976
|
"figure.button.import-mesh": "GLB 가져오기",
|
|
989
977
|
"figure.text.import-mesh-hint": "GLB 파일을 부품으로 가져옵니다. 파일 전체나 그 안의 한 부분을 고릅니다.",
|
|
990
978
|
"figure.text.mesh-whole-file": "파일 전체",
|
package/translations/ms.json
CHANGED
|
@@ -973,18 +973,6 @@
|
|
|
973
973
|
"figure.text.detail-unreleased": "Belum diterbitkan. Draf yang disimpan dipaparkan.",
|
|
974
974
|
"figure.button.detail-edit": "Buka editor",
|
|
975
975
|
"figure.button.download-package": "muat turun pakej",
|
|
976
|
-
"figure.label.group-carrier": "Pembawa",
|
|
977
|
-
"figure.label.carrier-type": "Jenis pembawa",
|
|
978
|
-
"figure.text.carrier-type-placeholder": "CARRIERTYPE",
|
|
979
|
-
"figure.label.carrier-size": "Saiz fizikal",
|
|
980
|
-
"figure.label.carrier-width": "Lebar",
|
|
981
|
-
"figure.label.carrier-length": "Panjang",
|
|
982
|
-
"figure.label.carrier-height": "Tinggi",
|
|
983
|
-
"figure.button.declare-carrier": "Isytihar pembawa",
|
|
984
|
-
"figure.button.clear-carrier": "Kosongkan",
|
|
985
|
-
"figure.text.carrier-type-required": "Masukkan kod jenis pembawa.",
|
|
986
|
-
"figure.text.carrier-type-too-long": "Kod jenis pembawa paling banyak {max} aksara.",
|
|
987
|
-
"figure.text.carrier-length-positive": "{axis} mesti lebih besar daripada 0.",
|
|
988
976
|
"figure.button.import-mesh": "import GLB",
|
|
989
977
|
"figure.text.import-mesh-hint": "membawa masuk fail GLB sebagai bahagian: seluruh fail, atau satu bahagian daripadanya.",
|
|
990
978
|
"figure.text.mesh-whole-file": "seluruh fail",
|
package/translations/zh.json
CHANGED
|
@@ -973,18 +973,6 @@
|
|
|
973
973
|
"figure.text.detail-unreleased": "尚未发布。显示已保存的草稿。",
|
|
974
974
|
"figure.button.detail-edit": "打开编辑页面",
|
|
975
975
|
"figure.button.download-package": "下载包",
|
|
976
|
-
"figure.label.group-carrier": "载具",
|
|
977
|
-
"figure.label.carrier-type": "载具类型",
|
|
978
|
-
"figure.text.carrier-type-placeholder": "CARRIERTYPE",
|
|
979
|
-
"figure.label.carrier-size": "实物尺寸",
|
|
980
|
-
"figure.label.carrier-width": "宽",
|
|
981
|
-
"figure.label.carrier-length": "长",
|
|
982
|
-
"figure.label.carrier-height": "高",
|
|
983
|
-
"figure.button.declare-carrier": "声明为载具",
|
|
984
|
-
"figure.button.clear-carrier": "取消声明",
|
|
985
|
-
"figure.text.carrier-type-required": "请输入载具类型代码。",
|
|
986
|
-
"figure.text.carrier-type-too-long": "载具类型代码最多 {max} 个字符。",
|
|
987
|
-
"figure.text.carrier-length-positive": "{axis}必须大于 0。",
|
|
988
976
|
"figure.button.import-mesh": "导入 GLB",
|
|
989
977
|
"figure.text.import-mesh-hint": "将 GLB 文件导入为部件:整个文件,或其中的一部分。",
|
|
990
978
|
"figure.text.mesh-whole-file": "整个文件",
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* A figure that is a carrier type declares so (ADR-0123): the CARRIERTYPE code a material handling system reports, and
|
|
3
|
-
* the carrier's physical size in mm. Kept apart from the element so it is tested without a browser.
|
|
4
|
-
*
|
|
5
|
-
* The rule is figure-model's (`carrierDeclarationRefusal`); the field asks it before sending `set-carrier`, so a value
|
|
6
|
-
* the kernel refuses is said beside the field and never becomes a step of the history. The size is what the author
|
|
7
|
-
* types, never read off the occupied room.
|
|
8
|
-
*/
|
|
9
|
-
import { carrierDeclarationRefusal, FIGURE_CARRIER_TYPE_MAX, normalizeCarrierType, type FigureAsset, type FigureAuthoringAction } from '@hatiolab/figure-model'
|
|
10
|
-
|
|
11
|
-
type Say = (key: string, params: Record<string, string>) => string
|
|
12
|
-
type Declaration = NonNullable<FigureAsset['carrier']>
|
|
13
|
-
|
|
14
|
-
export const CARRIER_AXES = ['width', 'length', 'height'] as const
|
|
15
|
-
export type CarrierAxis = (typeof CARRIER_AXES)[number]
|
|
16
|
-
|
|
17
|
-
/** What the field holds while the author types: the code and the three lengths as typed. */
|
|
18
|
-
export type CarrierDraft = { type: string } & Record<CarrierAxis, string>
|
|
19
|
-
|
|
20
|
-
/** The field's values for a declaration, or blank for a figure that declares none. */
|
|
21
|
-
export function carrierDraftOf(carrier?: Declaration): CarrierDraft {
|
|
22
|
-
return {
|
|
23
|
-
type: carrier?.type ?? '',
|
|
24
|
-
width: carrier ? String(carrier.dimensions.width) : '',
|
|
25
|
-
length: carrier ? String(carrier.dimensions.length) : '',
|
|
26
|
-
height: carrier ? String(carrier.dimensions.height) : ''
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Whether the field says what is declared already: nothing to send. */
|
|
31
|
-
export function draftIsDeclared(draft: CarrierDraft, carrier?: Declaration): boolean {
|
|
32
|
-
const declared = carrierDraftOf(carrier)
|
|
33
|
-
return normalizeCarrierType(draft.type) === declared.type && CARRIER_AXES.every(axis => Object.is(lengthOf(draft[axis]), lengthOf(declared[axis])))
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** A typed length as a number; blank is no length. */
|
|
37
|
-
const lengthOf = (typed: string) => (typed.trim() === '' ? Number.NaN : Number(typed))
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The `set-carrier` command for what the field holds, or why the kernel would refuse it in the author's words. The
|
|
41
|
-
* code is sent as the kernel stores it (trimmed, upper case).
|
|
42
|
-
*/
|
|
43
|
-
export function carrierCommandOf(draft: CarrierDraft, say: Say): { action: FigureAuthoringAction } | { refusal: string } {
|
|
44
|
-
const carrier = {
|
|
45
|
-
type: normalizeCarrierType(draft.type),
|
|
46
|
-
dimensions: { width: lengthOf(draft.width), length: lengthOf(draft.length), height: lengthOf(draft.height) }
|
|
47
|
-
}
|
|
48
|
-
const refused = carrierDeclarationRefusal(carrier)
|
|
49
|
-
if (refused) return { refusal: carrierRefusalOf(refused, carrier.type, say) }
|
|
50
|
-
return { action: { kind: 'set-carrier', carrier } }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* A carrier declaration refused, said from its code and path (ADR-0127): the code missing or too long, or the length
|
|
55
|
-
* the path names. The kernel's English is never shown; a code the field has no words for is said with the code.
|
|
56
|
-
*/
|
|
57
|
-
export function carrierRefusalOf(refused: { code: string; path: string }, type: string, say: Say): string {
|
|
58
|
-
if (refused.code === 'CARRIER_TYPE')
|
|
59
|
-
return type ? say('figure.text.carrier-type-too-long', { max: String(FIGURE_CARRIER_TYPE_MAX) }) : say('figure.text.carrier-type-required', {})
|
|
60
|
-
const axis = CARRIER_AXES.find(one => refused.path.endsWith(`.${one}`))
|
|
61
|
-
if (refused.code === 'CARRIER_DIMENSIONS' && axis) return say('figure.text.carrier-length-positive', { axis: say(`figure.label.carrier-${axis}`, {}) })
|
|
62
|
-
return say('figure.text.edit-refused', { code: refused.code })
|
|
63
|
-
}
|