@things-factory/figure-ui 10.1.64 → 10.1.66

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 (56) hide show
  1. package/client/figure-assistant.ts +26 -10
  2. package/client/graphql/index.ts +0 -58
  3. package/client/modeller/v3-ai-proposal.ts +111 -0
  4. package/client/modeller/v3-editor.ts +3 -2
  5. package/client/modeller/v3-product-session.ts +10 -1
  6. package/client/modeller/v3-refusals.ts +17 -0
  7. package/client/pages/figure-v3-modeller-page.ts +35 -2
  8. package/client/types.ts +0 -39
  9. package/dist-client/figure-assistant.js +22 -10
  10. package/dist-client/figure-assistant.js.map +1 -1
  11. package/dist-client/graphql/index.d.ts +1 -19
  12. package/dist-client/graphql/index.js +0 -46
  13. package/dist-client/graphql/index.js.map +1 -1
  14. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  15. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  16. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  17. package/dist-client/modeller/figure-v3-links.d.ts +1 -1
  18. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  19. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  20. package/dist-client/modeller/v3-ai-proposal.d.ts +51 -0
  21. package/dist-client/modeller/v3-ai-proposal.js +64 -0
  22. package/dist-client/modeller/v3-ai-proposal.js.map +1 -0
  23. package/dist-client/modeller/v3-clips.d.ts +1 -1
  24. package/dist-client/modeller/v3-editor.js +3 -2
  25. package/dist-client/modeller/v3-editor.js.map +1 -1
  26. package/dist-client/modeller/v3-product-session.d.ts +35 -1
  27. package/dist-client/modeller/v3-product-session.js +8 -0
  28. package/dist-client/modeller/v3-product-session.js.map +1 -1
  29. package/dist-client/modeller/v3-refusals.d.ts +6 -0
  30. package/dist-client/modeller/v3-refusals.js +18 -0
  31. package/dist-client/modeller/v3-refusals.js.map +1 -1
  32. package/dist-client/pages/figure-v3-modeller-page.d.ts +7 -1
  33. package/dist-client/pages/figure-v3-modeller-page.js +41 -2
  34. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  35. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  36. package/dist-client/tsconfig.tsbuildinfo +1 -1
  37. package/dist-client/types.d.ts +0 -44
  38. package/dist-client/types.js.map +1 -1
  39. package/package.json +4 -4
  40. package/test/figure-assistant-surface.test.ts +9 -6
  41. package/test/v3-ai-proposal.test.ts +112 -0
  42. package/test/v3-refusals.test.ts +12 -0
  43. package/translations/en.json +8 -1
  44. package/translations/ja.json +8 -1
  45. package/translations/ko.json +8 -1
  46. package/translations/ms.json +8 -1
  47. package/translations/zh.json +8 -1
  48. package/client/modeller/proposal-session.ts +0 -98
  49. package/client/modeller/proposal.ts +0 -171
  50. package/dist-client/modeller/proposal-session.d.ts +0 -28
  51. package/dist-client/modeller/proposal-session.js +0 -66
  52. package/dist-client/modeller/proposal-session.js.map +0 -1
  53. package/dist-client/modeller/proposal.d.ts +0 -45
  54. package/dist-client/modeller/proposal.js +0 -114
  55. package/dist-client/modeller/proposal.js.map +0 -1
  56. package/test/ai-proposal-contract.test.ts +0 -563
@@ -101,50 +101,6 @@ export interface FigureFinding {
101
101
  message: string;
102
102
  at?: string;
103
103
  }
104
- /**
105
- * 저작 보조가 낸 후보.
106
- *
107
- * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고
108
- * 돌려주기만 한다.
109
- */
110
- export interface FigureProposal {
111
- /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */
112
- source: string;
113
- score: number;
114
- grade: string;
115
- triangles: number;
116
- /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */
117
- groups: number;
118
- /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */
119
- quality: {
120
- status: 'ready' | 'review';
121
- summary: string;
122
- findings: Array<{
123
- dimension: string;
124
- code: string;
125
- message: string;
126
- }>;
127
- visualRegions: number;
128
- visualCoverage: number;
129
- visualReadability: number;
130
- };
131
- /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */
132
- violations: FigureFinding[];
133
- /** 몇 번 만에 됐나. 진단용이다. */
134
- attempts: number;
135
- }
136
- /** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */
137
- export interface ProposalFeedback {
138
- outcome: 'accepted' | 'discarded';
139
- selectedChanges: number;
140
- totalChanges: number;
141
- note?: string;
142
- grade?: string;
143
- quality?: {
144
- status?: 'ready' | 'review';
145
- findingCodes?: string[];
146
- };
147
- }
148
104
  /**
149
105
  * 발행 판정 한 줄.
150
106
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** Which format the stored source is in — `'v2'`, `'v3'`, or null when the server cannot tell. */\n format?: 'v2' | 'v3' | null\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 저작 보조가 낸 후보.\n *\n * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\n/** A server-remeasured set of draft-only changes that clears every blocking sizing split. */\nexport interface FigureGateFixPlan {\n fixes: FigureGateFix[]\n resolved: number\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n fixPlan?: FigureGateFixPlan\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** Which format the stored source is in — `'v2'`, `'v3'`, or null when the server cannot tell. */\n format?: 'v2' | 'v3' | null\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\n/** A server-remeasured set of draft-only changes that clears every blocking sizing split. */\nexport interface FigureGateFixPlan {\n fixes: FigureGateFix[]\n resolved: number\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n fixPlan?: FigureGateFixPlan\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.64",
3
+ "version": "10.1.66",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,7 +27,7 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.1.75",
30
+ "@hatiolab/figure-model": "^0.1.76",
31
31
  "@hatiolab/things-scene": "^10.1.51",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
@@ -40,8 +40,8 @@
40
40
  "@operato/styles": "^10.0.0",
41
41
  "@operato/utils": "^10.0.0",
42
42
  "@things-factory/ai-assistant": "^10.1.58",
43
- "@things-factory/figure-service": "^10.1.64",
43
+ "@things-factory/figure-service": "^10.1.66",
44
44
  "three": "^0.185.1"
45
45
  },
46
- "gitHead": "9e10bdcadcc265c8e48b37a772d861221c4680ba"
46
+ "gitHead": "f92ae2a32e389f17eba0ce5770a98866ac3b1209"
47
47
  }
@@ -20,7 +20,7 @@ test('a saved figure provides a surface anchored on its row', () => {
20
20
  })
21
21
 
22
22
  test('the draft is read when the message is sent, as host context the figure tools read', () => {
23
- assert.match(source, /hostContext: \(\) => \(\{[\s\S]*?figureId: id,[\s\S]*?draftSource: JSON\.parse\(this\.session\.source\(\)\),[\s\S]*?palette: Object\.keys\(activePalette\(\)\)/)
23
+ assert.match(source, /hostContext: \(\) => \(\{[\s\S]*?figureId: id,[\s\S]*?draftSource: JSON\.parse\(this\.session\.source\(\)\),[\s\S]*?context: this\.session\.read\(\)\.context,\s*revision: this\.session\.read\(\)\.revision,/)
24
24
  })
25
25
 
26
26
  test('an unsaved figure provides nothing', () => {
@@ -33,19 +33,22 @@ test('the surface is taken back when the page goes inactive or leaves', () => {
33
33
  assert.doesNotMatch(source, /figure-ai-context/, 'the old window-event channel is gone')
34
34
  })
35
35
 
36
- test('the V3 apply entry stays for the proposer that writes V3 changes', () => {
36
+ test('the dock applies a proposal through the page, on the version the model saw', () => {
37
37
  assert.match(source, /window\.addEventListener\('figure-v3-apply', this\.applyAI\)/)
38
+ assert.match(source, /this\.session\.applyProposal\(detail\.steps, detail\.revision, detail\.context\)/)
39
+ assert.match(source, /this\.takeNewFigureProposal\(\)/, 'a new figure opened for a proposal takes its steps')
38
40
  })
39
41
 
40
- test('a figure candidate is answered - unavailable, in words - never left without a handler', () => {
42
+ test('a figure proposal is answered by its handler, in words, never left without one', () => {
41
43
  const handler = readFileSync(fileURLToPath(new URL('../client/figure-assistant.ts', import.meta.url)), 'utf8')
42
44
  assert.match(handler, /registerProposalHandler\(FIGURE_CANDIDATE_TOOL,/)
43
45
  assert.match(handler, /export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'/)
44
- assert.match(handler, /verdict: 'unavailable'/)
45
- assert.match(handler, /figure\.text\.candidate-cannot-open/)
46
+ assert.match(handler, /runFigureProposal\(/)
47
+ assert.match(handler, /context\.notify\(/)
46
48
  for (const lang of ['en', 'ko', 'ja', 'zh', 'ms']) {
47
49
  const words = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
48
- assert.ok(words['figure.text.candidate-cannot-open'], `${lang} has no words for it`)
50
+ for (const key of ['applied', 'stale', 'refused', 'needs-modeller', 'not-opened', 'new-figure-unsaved'])
51
+ assert.ok(words[`figure.text.proposal-${key}`], `${lang} has no words for proposal-${key}`)
49
52
  }
50
53
  const index = readFileSync(fileURLToPath(new URL('../client/index.ts', import.meta.url)), 'utf8')
51
54
  assert.match(index, /figure-assistant\.js/)
@@ -0,0 +1,112 @@
1
+ /*
2
+ * An AI proposal reaching the modeller (ADR-0094): the modeller's commands, applied as one step of its history on the
3
+ * version the model saw, undone whole; every outcome said in words.
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
+ import { createV3Asset, addV3Box, serializeV3Asset } from '@hatiolab/figure-model'
10
+
11
+ import { createV3ProductSession } from '../client/modeller/v3-product-session.js'
12
+ import { pendNewFigure, runFigureProposal, takePendingNewFigure, type FigureApplyDetail } from '../client/modeller/v3-ai-proposal.js'
13
+
14
+ const ko = JSON.parse(readFileSync(fileURLToPath(new URL('../translations/ko.json', import.meta.url)), 'utf8'))
15
+ const say = (key: string, params: Record<string, string> = {}) => {
16
+ assert.ok(ko[key], `ko has no words for ${key}`)
17
+ return ko[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
18
+ }
19
+
20
+ const source = () =>
21
+ serializeV3Asset(addV3Box(createV3Asset('A'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
22
+ const store = {
23
+ load: async (id: string) => ({ id, source: source() }),
24
+ save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'created', source: q.source })
25
+ }
26
+ const steps = [
27
+ { kind: 'add-part', part: { id: 'lid', kind: 'box', dimensions: { width: 100, height: 10, depth: 100 } } },
28
+ { kind: 'attach', part: 'lid', face: 'bottom', to: { part: 'body', face: 'top' } },
29
+ { kind: 'rename', name: 'Box with lid' }
30
+ ] as any[]
31
+
32
+ test('a proposal is one step of the history on the version the model saw, undone whole', async () => {
33
+ const s = createV3ProductSession(store)
34
+ await s.open('A')
35
+ const seen = s.read()
36
+ s.applyProposal(steps, seen.revision!, seen.context!)
37
+ assert.equal(s.read().name, 'Box with lid')
38
+ assert.ok(s.read().geometry.length > seen.geometry.length)
39
+ s.undo()
40
+ assert.equal(s.read().name, 'A', 'one undo takes the whole proposal back')
41
+ assert.equal(s.read().geometry.length, seen.geometry.length)
42
+ assert.equal(s.read().canUndo, false)
43
+ })
44
+
45
+ test('a person\'s edit after the proposal was asked makes it stale -- refused, nothing applied', async () => {
46
+ const s = createV3ProductSession(store)
47
+ await s.open('A')
48
+ const seen = s.read()
49
+ s.apply([{ kind: 'set-design', input: 'body.size.y', unit: 'mm', value: 150 }])
50
+ assert.throws(() => s.applyProposal(steps, seen.revision!, seen.context!), (e: any) => e.code === 'REVISION_CONFLICT')
51
+ assert.equal(s.read().name, 'A')
52
+ })
53
+
54
+ const taker = (result: Partial<FigureApplyDetail>) => ({
55
+ offered: [] as FigureApplyDetail[],
56
+ opened: [] as unknown[],
57
+ offer(detail: FigureApplyDetail) {
58
+ this.offered.push(detail)
59
+ return Object.assign(detail, result)
60
+ },
61
+ async openNew(s: unknown[]) {
62
+ this.opened.push(s)
63
+ return { applied: true }
64
+ }
65
+ })
66
+
67
+ test('applied: said with how many commands, and the card carries the version to the page', async () => {
68
+ const target = taker({ applied: true })
69
+ const out = await runFigureProposal({ steps, context: 'c', revision: 3 }, target, say)
70
+ assert.deepEqual(out, { verdict: 'accepted', applied: 3, note: '명령 3개를 한 단계로 적용했습니다.' })
71
+ assert.equal(target.offered[0]!.context, 'c')
72
+ assert.equal(target.offered[0]!.revision, 3)
73
+ })
74
+
75
+ test('refused: a stale version is said as what happened; any other refusal carries its reason', async () => {
76
+ const stale = await runFigureProposal({ steps }, taker({ applied: false, code: 'REVISION_CONFLICT', error: 'editor changed' }), say)
77
+ assert.equal(stale.verdict, 'refused')
78
+ assert.match(stale.note!, /모델이 바뀌어/)
79
+ const other = await runFigureProposal({ steps }, taker({ applied: false, code: 'ATTACH_FACE', error: 'faces along x' }), say)
80
+ assert.match(other.note!, /적용하지 않았습니다: faces along x/)
81
+ })
82
+
83
+ test('a revision with no modeller to take it is not applied anywhere else', async () => {
84
+ const target = taker({})
85
+ const out = await runFigureProposal({ steps }, target, say)
86
+ assert.equal(out.verdict, 'unavailable')
87
+ assert.equal(target.opened.length, 0, 'a revision never opens a new figure')
88
+ })
89
+
90
+ test('a new figure with no new modeller on screen opens one', async () => {
91
+ const target = taker({})
92
+ const out = await runFigureProposal({ steps, create: true }, target, say)
93
+ assert.equal(out.verdict, 'accepted')
94
+ assert.equal(target.opened.length, 1)
95
+ })
96
+
97
+ test('an empty card is not applied', async () => {
98
+ assert.equal((await runFigureProposal({ steps: [] }, taker({ applied: true }), say)).verdict, 'unavailable')
99
+ })
100
+
101
+ test('a new figure\'s steps wait for the page once, and lapse if no page takes them', async () => {
102
+ const waiting = pendNewFigure(steps, 50)
103
+ const taken = takePendingNewFigure()!
104
+ assert.equal(taken.steps, steps)
105
+ assert.equal(takePendingNewFigure(), undefined, 'taken once')
106
+ taken.settle({ applied: true })
107
+ assert.deepEqual(await waiting, { applied: true })
108
+
109
+ const lapsing = pendNewFigure(steps, 10)
110
+ assert.deepEqual(await lapsing, { applied: false, code: 'not-opened' })
111
+ assert.equal(takePendingNewFigure(), undefined, 'a later new figure does not receive them')
112
+ })
@@ -122,3 +122,15 @@ test('배치를 바꿀 수 없는 까닭 — 평면에 붙은 부품과 새 평
122
122
  const props = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-properties.ts', import.meta.url)), 'utf8')
123
123
  assert.ok(props.includes('select.value = current'), '거절되면 목록이 원래 배치로 돌아온다')
124
124
  })
125
+
126
+ test('범위 밖 값은 받을 수 있는 범위를 숫자로 말한다 — 커널의 영어가 아니다 (Figure 레인 :3300, 2026-09-26)', async () => {
127
+ const { inputRangeRefusalOf } = await import('../client/modeller/v3-refusals.ts')
128
+ const range = (message: string) => Object.assign(new Error(message), { code: 'INPUT_RANGE' })
129
+ const size = inputRangeRefusalOf(range('INPUT_RANGE: size.x: 6021100 outside [300,1200]'), say)!
130
+ assert.ok(size.includes('X') && size.includes('300') && size.includes('1,200') && size.includes('6,021,100'), size)
131
+ assert.ok(!/outside|INPUT_RANGE/.test(size))
132
+ const other = inputRangeRefusalOf(range('INPUT_RANGE: lift.stroke: -5 outside [0,1]'), say)!
133
+ assert.ok(other.includes('0') && other.includes('1') && other.includes('-5'), other)
134
+ assert.equal(inputRangeRefusalOf(range('INPUT_RANGE: steps: at the length it is authored (100 mm) a pitch under 1 mm makes more than 50 copies'), say), null)
135
+ assert.equal(inputRangeRefusalOf(Object.assign(new Error('x'), { code: 'EDIT_TARGET' }), say), null)
136
+ })
@@ -79,6 +79,8 @@
79
79
  "figure.text.instance-range-can-widen": "measured again with the parts as they are: {ranges} mm.",
80
80
  "figure.text.size-range-too-small": "below {mm} mm on {axis} the {part} part has no length, yet the figure is declared to be placed smaller, so this change cannot be made. use \"measure again\" under the sizes it can be placed at.",
81
81
  "figure.text.size-range-too-large": "above {mm} mm on {axis} the {part} part cannot be drawn, yet the figure is declared to be placed larger, so this change cannot be made. use \"measure again\" under the sizes it can be placed at.",
82
+ "figure.text.input-range-size": "figure size {axis} can only be set between {min} and {max} mm. value given: {value} mm",
83
+ "figure.text.input-range": "this value can only be set between {min} and {max}. value given: {value}",
82
84
  "figure.text.mounting-face-needs-space": "declare the space first to choose one.",
83
85
  "figure.text.part-is-referenced": "'{part}' cannot be deleted: another part is fastened to it or follows its size. Undo that link first.",
84
86
  "figure.text.part-name-invalid": "'{name}' cannot be a part name. Start with a letter and use letters, digits, - and _.",
@@ -711,7 +713,12 @@
711
713
  "figure.label.twist": "twist (°)",
712
714
  "figure.text.instance-size-undeclared": "the figure has no size, so a board cannot resize it.",
713
715
  "figure.button.declare-instance-size": "declare the figure size",
714
- "figure.text.candidate-cannot-open": "this candidate is a V2 figure and the modeller opens V3 figures only, so it cannot be opened for review yet.",
716
+ "figure.text.proposal-applied": "applied {n} commands as one step.",
717
+ "figure.text.proposal-needs-modeller": "this proposal can be applied only while its figure is open in the modeller.",
718
+ "figure.text.proposal-new-figure-unsaved": "a new figure is being made, so the proposal was not applied. save or discard it, then run it again.",
719
+ "figure.text.proposal-not-opened": "a new figure could not be opened in the modeller, so the proposal was not applied.",
720
+ "figure.text.proposal-refused": "the modeller did not apply the proposal: {reason}",
721
+ "figure.text.proposal-stale": "the model changed after the proposal was made, so it was not applied. ask again.",
715
722
  "figure.label.follows-plus": "plus (mm)",
716
723
  "figure.label.motion-travel": "travel",
717
724
  "figure.label.motion-travel-mm": "its own range (mm)",
@@ -77,6 +77,8 @@
77
77
  "figure.text.instance-range-can-widen": "今の部品で測り直すと {ranges} mm です。",
78
78
  "figure.text.size-range-too-small": "この図形は {axis} を {mm} mm より小さくすると {part} 部品の長さが 0 になりますが、それより小さく置けると宣言されているため、この変更はできません。「置けるサイズ」で測り直すと範囲が合います。",
79
79
  "figure.text.size-range-too-large": "この図形は {axis} を {mm} mm より大きくすると {part} 部品を描けませんが、それより大きく置けると宣言されているため、この変更はできません。「置けるサイズ」で測り直すと範囲が合います。",
80
+ "figure.text.input-range-size": "図形サイズ {axis} は {min} ~ {max} mm の間でのみ設定できます。入力値: {value} mm",
81
+ "figure.text.input-range": "この値は {min} ~ {max} の間でのみ設定できます。入力値: {value}",
80
82
  "figure.text.mounting-face-needs-space": "先に空間を宣言すると選べます。",
81
83
  "figure.text.part-is-referenced": "'{part}' には他の部品が取り付けられているか、そのサイズに従っているため削除できません。先にその連結を外してください。",
82
84
  "figure.text.part-name-invalid": "'{name}' は部品名に使えません。英字で始め、英字・数字・-・_ のみ使えます。",
@@ -711,7 +713,12 @@
711
713
  "figure.label.twist": "ひねり (°)",
712
714
  "figure.text.instance-size-undeclared": "図形サイズがないため、ボードでサイズを変えられません。",
713
715
  "figure.button.declare-instance-size": "図形サイズを宣言",
714
- "figure.text.candidate-cannot-open": "この候補は V2 図形で、モデラーは V3 図形しか開けないため、まだレビュー用に開けません。",
716
+ "figure.text.proposal-applied": "{n} 個のコマンドを 1 ステップとして適用しました。",
717
+ "figure.text.proposal-needs-modeller": "この提案は、その図形がモデラーで開いているときだけ適用できます。",
718
+ "figure.text.proposal-new-figure-unsaved": "作成中の新しい図形があるため、提案を適用しませんでした。保存または破棄してから、もう一度実行してください。",
719
+ "figure.text.proposal-not-opened": "新しい図形をモデラーで開けなかったため、提案を適用しませんでした。",
720
+ "figure.text.proposal-refused": "モデラーは提案を適用しませんでした: {reason}",
721
+ "figure.text.proposal-stale": "提案の後にモデルが変わったため、適用しませんでした。もう一度依頼してください。",
715
722
  "figure.label.follows-plus": "加算 (mm)",
716
723
  "figure.label.motion-travel": "移動距離",
717
724
  "figure.label.motion-travel-mm": "自分の範囲 (mm)",
@@ -79,6 +79,8 @@
79
79
  "figure.text.instance-range-can-widen": "지금 부품으로 다시 재면 {ranges} mm 입니다.",
80
80
  "figure.text.size-range-too-small": "이 도형은 {axis} 축을 {mm} mm 보다 작게 하면 {part} 부품의 길이가 0 이 되는데, 그보다 작게 놓일 수 있다고 선언되어 있어 이 변경을 할 수 없습니다. 「놓일 수 있는 크기」에서 다시 재기를 누르면 범위가 맞춰집니다.",
81
81
  "figure.text.size-range-too-large": "이 도형은 {axis} 축을 {mm} mm 보다 크게 하면 {part} 부품을 그릴 수 없는데, 그보다 크게 놓일 수 있다고 선언되어 있어 이 변경을 할 수 없습니다. 「놓일 수 있는 크기」에서 다시 재기를 누르면 범위가 맞춰집니다.",
82
+ "figure.text.input-range-size": "도형 크기 {axis} 는 {min} ~ {max} mm 사이에서만 정할 수 있습니다. 넣은 값: {value} mm",
83
+ "figure.text.input-range": "이 값은 {min} ~ {max} 사이에서만 정할 수 있습니다. 넣은 값: {value}",
82
84
  "figure.text.mounting-face-needs-space": "공간을 먼저 선언해야 고를 수 있습니다.",
83
85
  "figure.text.part-is-referenced": "'{part}' 에 다른 부품이 붙어 있거나 그 크기를 따르고 있어 지울 수 없습니다. 그 연결을 먼저 푸세요.",
84
86
  "figure.text.part-name-invalid": "'{name}' 은 부품 이름으로 쓸 수 없습니다. 영문자로 시작하고 영문자·숫자·-·_ 만 씁니다.",
@@ -711,7 +713,12 @@
711
713
  "figure.label.twist": "비틀기 (°)",
712
714
  "figure.text.instance-size-undeclared": "도형 크기가 없어 보드에서 크기를 바꿀 수 없습니다.",
713
715
  "figure.button.declare-instance-size": "도형 크기 선언",
714
- "figure.text.candidate-cannot-open": "이 후보는 V2 도형이고 모델러는 V3 도형만 열 수 있어, 아직 검토하도록 열 수 없습니다.",
716
+ "figure.text.proposal-applied": "명령 {n}개를 한 단계로 적용했습니다.",
717
+ "figure.text.proposal-needs-modeller": "이 제안은 모델러에 그 도형이 열려 있을 때만 적용할 수 있습니다.",
718
+ "figure.text.proposal-new-figure-unsaved": "만들고 있는 새 Figure가 있어 제안을 적용하지 않았습니다. 저장하거나 버린 뒤 다시 실행해 주세요.",
719
+ "figure.text.proposal-not-opened": "새 Figure를 모델러에 열지 못해 제안을 적용하지 않았습니다.",
720
+ "figure.text.proposal-refused": "모델러가 제안을 적용하지 않았습니다: {reason}",
721
+ "figure.text.proposal-stale": "제안을 받은 뒤 모델이 바뀌어 적용하지 않았습니다. 다시 요청해 주세요.",
715
722
  "figure.label.follows-plus": "더하기 (mm)",
716
723
  "figure.label.motion-travel": "이동 거리",
717
724
  "figure.label.motion-travel-mm": "제 범위 (mm)",
@@ -77,6 +77,8 @@
77
77
  "figure.text.instance-range-can-widen": "diukur semula dengan bahagian semasa: {ranges} mm.",
78
78
  "figure.text.size-range-too-small": "di bawah {mm} mm pada {axis} bahagian {part} tiada panjang, namun figura diisytiharkan boleh diletakkan lebih kecil, jadi perubahan ini tidak boleh dibuat. gunakan \"ukur semula\" di bawah saiz yang boleh diletakkan.",
79
79
  "figure.text.size-range-too-large": "di atas {mm} mm pada {axis} bahagian {part} tidak boleh dilukis, namun figura diisytiharkan boleh diletakkan lebih besar, jadi perubahan ini tidak boleh dibuat. gunakan \"ukur semula\" di bawah saiz yang boleh diletakkan.",
80
+ "figure.text.input-range-size": "saiz rajah {axis} hanya boleh ditetapkan antara {min} dan {max} mm. nilai diberi: {value} mm",
81
+ "figure.text.input-range": "nilai ini hanya boleh ditetapkan antara {min} dan {max}. nilai diberi: {value}",
80
82
  "figure.text.mounting-face-needs-space": "isytiharkan ruang dahulu untuk memilih.",
81
83
  "figure.text.part-is-referenced": "'{part}' tidak boleh dipadam: bahagian lain dipasang padanya atau mengikut saiznya. Leraikan pautan itu dahulu.",
82
84
  "figure.text.part-name-invalid": "'{name}' tidak boleh menjadi nama bahagian. Mulakan dengan huruf dan gunakan huruf, digit, - dan _.",
@@ -711,7 +713,12 @@
711
713
  "figure.label.twist": "pusingan (°)",
712
714
  "figure.text.instance-size-undeclared": "rajah tiada saiz, jadi papan tidak boleh mengubah saiznya.",
713
715
  "figure.button.declare-instance-size": "isytihar saiz rajah",
714
- "figure.text.candidate-cannot-open": "calon ini ialah rajah V2 dan pemodel hanya membuka rajah V3, jadi ia belum boleh dibuka untuk semakan.",
716
+ "figure.text.proposal-applied": "{n} arahan digunakan sebagai satu langkah.",
717
+ "figure.text.proposal-needs-modeller": "cadangan ini hanya boleh digunakan semasa rajahnya dibuka dalam pemodel.",
718
+ "figure.text.proposal-new-figure-unsaved": "rajah baharu sedang dibuat, jadi cadangan tidak digunakan. simpan atau buang dahulu, kemudian jalankan semula.",
719
+ "figure.text.proposal-not-opened": "rajah baharu tidak dapat dibuka dalam pemodel, jadi cadangan tidak digunakan.",
720
+ "figure.text.proposal-refused": "pemodel tidak menggunakan cadangan: {reason}",
721
+ "figure.text.proposal-stale": "model berubah selepas cadangan dibuat, jadi ia tidak digunakan. minta sekali lagi.",
715
722
  "figure.label.follows-plus": "tambah (mm)",
716
723
  "figure.label.motion-travel": "jarak gerak",
717
724
  "figure.label.motion-travel-mm": "julat sendiri (mm)",
@@ -77,6 +77,8 @@
77
77
  "figure.text.instance-range-can-widen": "按当前部件重新测量为 {ranges} mm。",
78
78
  "figure.text.size-range-too-small": "此图形的 {axis} 小于 {mm} mm 时 {part} 部件长度为 0,但声明可以放得更小,因此无法进行此更改。请在「可放置的尺寸」中重新测量。",
79
79
  "figure.text.size-range-too-large": "此图形的 {axis} 大于 {mm} mm 时无法绘制 {part} 部件,但声明可以放得更大,因此无法进行此更改。请在「可放置的尺寸」中重新测量。",
80
+ "figure.text.input-range-size": "图形尺寸 {axis} 只能设置在 {min} 到 {max} mm 之间。输入值:{value} mm",
81
+ "figure.text.input-range": "此值只能设置在 {min} 到 {max} 之间。输入值:{value}",
80
82
  "figure.text.mounting-face-needs-space": "请先声明空间,然后才能选择。",
81
83
  "figure.text.part-is-referenced": "无法删除 '{part}':有其他部件固定在它上面或跟随它的尺寸。请先解除该连接。",
82
84
  "figure.text.part-name-invalid": "'{name}' 不能作为部件名称。请以字母开头,只使用字母、数字、- 和 _。",
@@ -711,7 +713,12 @@
711
713
  "figure.label.twist": "扭转 (°)",
712
714
  "figure.text.instance-size-undeclared": "图形没有尺寸,看板上无法调整大小。",
713
715
  "figure.button.declare-instance-size": "声明图形尺寸",
714
- "figure.text.candidate-cannot-open": "此候选为 V2 图形,而建模器只能打开 V3 图形,因此暂时无法打开以供审阅。",
716
+ "figure.text.proposal-applied": "已将 {n} 条命令作为一步应用。",
717
+ "figure.text.proposal-needs-modeller": "只有在建模器中打开该图形时才能应用此提案。",
718
+ "figure.text.proposal-new-figure-unsaved": "正在制作一个新图形,因此未应用该提案。请保存或放弃后再运行。",
719
+ "figure.text.proposal-not-opened": "无法在建模器中打开新图形,因此未应用该提案。",
720
+ "figure.text.proposal-refused": "建模器未应用该提案:{reason}",
721
+ "figure.text.proposal-stale": "提案生成后模型已更改,因此未应用。请重新请求。",
715
722
  "figure.label.follows-plus": "加 (mm)",
716
723
  "figure.label.motion-travel": "行程",
717
724
  "figure.label.motion-travel-mm": "自身范围 (mm)",
@@ -1,98 +0,0 @@
1
- import type { FigureSource } from '@hatiolab/figure-model'
2
-
3
- import type { FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'
4
- import { diffProposal } from './proposal.js'
5
-
6
- /**
7
- * 한 번의 AI 후보 검토에만 존재하는 상태다.
8
- *
9
- * 후보는 정본이 아니다. 후보 원본·선택·메모·측정값을 한 값으로 묶어 두면 화면이
10
- * 수락/거절 중 일부만 이전 후보에서 남기는 일이 없다.
11
- */
12
- export interface ProposalSession {
13
- source: FigureSource
14
- title: string
15
- picked: Set<string>
16
- note: string
17
- metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>
18
- /**
19
- * The release-check fix this session was opened from, if any. The page translates the title from it;
20
- * this module stays free of i18n because node tests import it directly.
21
- */
22
- fix?: FigureGateFix
23
- }
24
-
25
- function selectedKeys(current: FigureSource | undefined, source: FigureSource): Set<string> {
26
- return new Set(diffProposal(current, source).map(change => change.key))
27
- }
28
-
29
- /** 서버 후보를 검토 세션으로 연다. 저장 형식이 아니면 화면에 후보를 열지 않는다. */
30
- export function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession {
31
- const source = JSON.parse(proposal.source) as FigureSource
32
- if (!source || !Array.isArray(source.parts)) {
33
- throw new Error('the AI candidate is not a figure source: parts is missing or not an array')
34
- }
35
-
36
- return {
37
- source,
38
- title: '',
39
- picked: selectedKeys(current, source),
40
- note: '',
41
- metrics: {
42
- grade: proposal.grade,
43
- triangles: proposal.triangles,
44
- groups: proposal.groups,
45
- attempts: proposal.attempts,
46
- quality: proposal.quality
47
- }
48
- }
49
- }
50
-
51
- /** 발행 게이트가 측정한 한-앵커 수정도 같은 후보 세션으로 연다. */
52
- export function openSizingFix(current: FigureSource, fix: FigureGateFix): ProposalSession | undefined {
53
- if (!current.parts.some(part => part.name === fix.part)) return undefined
54
-
55
- const source: FigureSource = {
56
- ...current,
57
- parts: current.parts.map(part =>
58
- part.name === fix.part ? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } } : part
59
- )
60
- }
61
-
62
- return {
63
- source,
64
- title: '',
65
- fix,
66
- picked: selectedKeys(current, source),
67
- note: ''
68
- }
69
- }
70
-
71
- /** 후보 검토 중 사람의 선택을 뒤집는다. 기존 Set을 바꾸지 않아 Lit 상태 경계도 보존한다. */
72
- export function toggleProposalChange(session: ProposalSession, key: string): ProposalSession {
73
- const picked = new Set(session.picked)
74
- if (picked.has(key)) picked.delete(key)
75
- else picked.add(key)
76
- return { ...session, picked }
77
- }
78
-
79
- /** 사람의 결정을, 다음 AI 요청에만 쓰는 제한된 피드백으로 만든다. */
80
- export function feedbackFromSession(
81
- current: FigureSource | undefined,
82
- session: ProposalSession,
83
- outcome: ProposalFeedback['outcome']
84
- ): ProposalFeedback | undefined {
85
- if (!session.metrics) return undefined
86
-
87
- return {
88
- outcome,
89
- selectedChanges: outcome === 'accepted' ? session.picked.size : 0,
90
- totalChanges: diffProposal(current, session.source).length,
91
- note: session.note.trim().slice(0, 500) || undefined,
92
- grade: session.metrics.grade,
93
- quality: {
94
- status: session.metrics.quality.status,
95
- findingCodes: session.metrics.quality.findings.map(finding => finding.code)
96
- }
97
- }
98
- }