@things-factory/figure-service 10.1.9 → 10.1.15

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.
@@ -0,0 +1,15 @@
1
+ {
2
+ "keep": {
3
+ "days": true,
4
+ "amount": 2
5
+ },
6
+ "auditLog": "logs/.08636eb59927f12972f6774f5947c8507b3564c2-audit.json",
7
+ "files": [
8
+ {
9
+ "date": 1789397538290,
10
+ "name": "logs/application-2026-09-14-23.log",
11
+ "hash": "69c3188e63ed8031b901bd7314adfe3a91152378397ce14edd2f4fafb5a8770e"
12
+ }
13
+ ],
14
+ "hashType": "sha256"
15
+ }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.9",
3
+ "version": "10.1.15",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -23,14 +23,15 @@
23
23
  "migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
24
24
  },
25
25
  "dependencies": {
26
- "@hatiolab/figure-model": "^0.1.16",
26
+ "@hatiolab/figure-model": "^0.1.17",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
28
  "@things-factory/ai-client-base": "^10.1.8",
29
29
  "@things-factory/auth-base": "^10.1.7",
30
30
  "@things-factory/env": "^10.1.3",
31
31
  "@things-factory/shell": "^10.1.7",
32
+ "graphql-scalars": "^1.22.4",
32
33
  "graphql-upload": "^15.0.2",
33
34
  "koa-router": "^7.4.0"
34
35
  },
35
- "gitHead": "ccbb6cfee4f73360a863d17f58a6c4134221dae1"
36
+ "gitHead": "0186eb7ec008781fd9f80b6f59379a91e0908820"
36
37
  }
@@ -69,8 +69,7 @@ test('초안·세션 피드백을 반영한 후보가 검증·품질 근거와
69
69
  const result = await propose.builder(
70
70
  { prompt: '포트가 더 잘 읽히도록 고쳐줘' },
71
71
  {
72
- state: {
73
- domain: DOMAIN,
72
+ state: { domain: DOMAIN },
74
73
  host: {
75
74
  figureId: 'fig-1',
76
75
  palette: ['palette.primary'],
@@ -81,7 +80,6 @@ test('초안·세션 피드백을 반영한 후보가 검증·품질 근거와
81
80
  quality: { status: 'review', findingCodes: ['top-view-readability'] }
82
81
  }]
83
82
  }
84
- }
85
83
  }
86
84
  )
87
85
 
@@ -0,0 +1,178 @@
1
+ import type { DataSource } from 'typeorm'
2
+
3
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
4
+ const shell = require('@things-factory/shell')
5
+ const authBase = require('@things-factory/auth-base')
6
+
7
+ let dataSource: DataSource | null = null
8
+ let Figure: any
9
+ let FigureHistory: any
10
+ let FigureQuery: any
11
+ let testDomain: any
12
+
13
+ function loadCompiled(modulePath: string): any {
14
+ const { existsSync } = require('fs')
15
+ const { join } = require('path')
16
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
17
+
18
+ if (!existsSync(resolved)) {
19
+ throw new Error(
20
+ `figure-service 산출물이 없다: ${resolved}\n` +
21
+ `이 시험은 tsc 산출물을 든다. 먼저 빌드할 것:\n` +
22
+ ` yarn workspace @things-factory/figure-service build:server`
23
+ )
24
+ }
25
+
26
+ return require(resolved)
27
+ }
28
+
29
+ const sampleSource = JSON.stringify({
30
+ version: 1,
31
+ type: 'TEST_BOX',
32
+ base: { x: 100, y: 100, z: 100 },
33
+ parts: [
34
+ {
35
+ name: 'box-1',
36
+ primitive: 'cube',
37
+ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } },
38
+ material: { token: 'palette.primary' }
39
+ }
40
+ ]
41
+ })
42
+
43
+ beforeAll(async () => {
44
+ Figure = loadCompiled('service/figure/figure').Figure
45
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
46
+ FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
47
+
48
+ dataSource = new TypeOrmDataSource({
49
+ type: 'sqlite',
50
+ database: ':memory:',
51
+ synchronize: true,
52
+ dropSchema: true,
53
+ namingStrategy: new shell.NamingStrategy(),
54
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
55
+ logging: false
56
+ })
57
+
58
+ await dataSource.initialize()
59
+ shell.addDataSource('default', dataSource)
60
+ shell.addDataSource('tx', dataSource)
61
+ })
62
+
63
+ beforeEach(async () => {
64
+ await dataSource!.synchronize(true)
65
+ testDomain = await dataSource!.getRepository(shell.Domain).save({
66
+ name: 'test-domain',
67
+ subdomain: 'test-domain'
68
+ })
69
+ })
70
+
71
+ afterAll(async () => {
72
+ if (!dataSource) return
73
+ shell.removeDataSource('default')
74
+ shell.removeDataSource('tx')
75
+ await dataSource.destroy()
76
+ dataSource = null
77
+ })
78
+
79
+ function makeContext() {
80
+ return {
81
+ state: {
82
+ domain: testDomain,
83
+ user: { id: 'user-1' }
84
+ }
85
+ } as any
86
+ }
87
+
88
+ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위한 청사진 전달', () => {
89
+ it('존재하지 않는 타입은 null을 반환한다', async () => {
90
+ const query = new FigureQuery()
91
+ const result = await query.figureBlueprint('NON_EXISTENT', makeContext())
92
+ expect(result).toBeNull()
93
+ })
94
+
95
+ it('초안(draft) 상태이고 발행 이력이 없는 Figure는 null을 반환한다 (초안 유출 차단)', async () => {
96
+ const repo = dataSource!.getRepository(Figure)
97
+ await repo.save({
98
+ domain: testDomain,
99
+ type: 'DRAFT_FIGURE',
100
+ name: '초안 도형',
101
+ state: 'draft',
102
+ source: sampleSource,
103
+ version: 0
104
+ })
105
+
106
+ const query = new FigureQuery()
107
+ const result = await query.figureBlueprint('DRAFT_FIGURE', makeContext())
108
+ expect(result).toBeNull()
109
+ })
110
+
111
+ it('발행된(released) Figure는 올바르게 컴파일된 Blueprint를 반환한다', async () => {
112
+ const repo = dataSource!.getRepository(Figure)
113
+ await repo.save({
114
+ domain: testDomain,
115
+ type: 'RELEASED_FIGURE',
116
+ name: '발행 도형',
117
+ state: 'released',
118
+ source: sampleSource,
119
+ version: 1
120
+ })
121
+
122
+ const query = new FigureQuery()
123
+ const result = await query.figureBlueprint('RELEASED_FIGURE', makeContext())
124
+ expect(result).not.toBeNull()
125
+ expect(result.type).toBe('TEST_BOX')
126
+ expect(result.groups).toBeDefined()
127
+ expect(result.groups.length).toBe(1)
128
+ expect(result.groups[0].members[0].name).toBe('box-1')
129
+ })
130
+
131
+ it('현재 저작 중(draft)이더라도 과거 발행판(FigureHistory)이 있으면 최신 발행판을 컴파일해 반환한다', async () => {
132
+ const repo = dataSource!.getRepository(Figure)
133
+ const historyRepo = dataSource!.getRepository(FigureHistory)
134
+
135
+ const v1Source = sampleSource
136
+ const v2DraftSource = JSON.stringify({
137
+ version: 1,
138
+ type: 'UPDATING_FIGURE',
139
+ base: { x: 200, y: 200, z: 200 },
140
+ parts: [
141
+ {
142
+ name: 'box-modified',
143
+ primitive: 'cube',
144
+ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 200, y: 200, z: 200 } },
145
+ material: { token: 'palette.primary' }
146
+ }
147
+ ]
148
+ })
149
+
150
+ const figure = await repo.save({
151
+ domain: testDomain,
152
+ type: 'UPDATING_FIGURE',
153
+ name: '수정 중인 도형',
154
+ state: 'draft',
155
+ source: v2DraftSource,
156
+ version: 1
157
+ })
158
+
159
+ // 과거 발행된 v1 이력
160
+ await historyRepo.save({
161
+ domain: testDomain,
162
+ originalId: figure.id,
163
+ type: 'UPDATING_FIGURE',
164
+ name: 'v1 발행판',
165
+ state: 'released',
166
+ version: 1,
167
+ source: v1Source
168
+ })
169
+
170
+ const query = new FigureQuery()
171
+ const result = await query.figureBlueprint('UPDATING_FIGURE', makeContext())
172
+
173
+ expect(result).not.toBeNull()
174
+ // 소비처는 수정 중인 초안(box-modified, size 200)이 아니라 기발행된 판(box-1, size 100)을 본다!
175
+ expect(result.groups[0].members[0].name).toBe('box-1')
176
+ expect(result.groups[0].members[0].transform.size.x).toBe(100)
177
+ })
178
+ })
@@ -7,6 +7,7 @@ import {
7
7
  PRIMITIVE_KINDS,
8
8
  SEGMENT_PRESETS,
9
9
  SIZING_RULES,
10
+ FIGURE_PLACEMENTS,
10
11
  compile,
11
12
  costOf,
12
13
  scoreOf,
@@ -131,13 +132,14 @@ export function rulesPrompt(palette: string[]): string {
131
132
  'Answer with a single JSON object and nothing else — no prose, no code fence.',
132
133
  '',
133
134
  'The JSON is a FigureSource:',
134
- ' { type, base: {x,y,z}, detailLevel, parts: [...] }',
135
+ ' { type, base: {x,y,z}, placement?, detailLevel, parts: [...] }',
135
136
  '',
136
137
  'A part is:',
137
138
  ' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',
138
139
  ' material: {token, preset?}, segments?, shape?, sizing? }',
139
140
  '',
140
141
  'Hard vocabulary — anything outside these is rejected:',
142
+ ` placement ${FIGURE_PLACEMENTS.join(' | ')} (floor: default, ground equipment; ceiling: overhead hoist/OHT; center: airborne/drone/sensor)`,
141
143
  ` primitive ${PRIMITIVE_KINDS.join(' | ')}`,
142
144
  ` segments ${SEGMENT_PRESETS.join(' | ')} (only for cylinder and sphere)`,
143
145
  ` material.token ${palette.join(' | ')}`,
@@ -1,5 +1,7 @@
1
1
  import { In } from 'typeorm'
2
2
  import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
3
+ import { GraphQLJSON } from 'graphql-scalars'
4
+ import { compile } from '@hatiolab/figure-model'
3
5
 
4
6
  import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
5
7
 
@@ -126,4 +128,70 @@ export class FigureQuery {
126
128
 
127
129
  return rows.map(r => r.type).filter(Boolean)
128
130
  }
131
+
132
+ /**
133
+ * 소비처(operato-board, operato-twin, things-scene)를 위한 컴파일된 청사진(FigureBlueprint) 질의.
134
+ *
135
+ * ## 규칙
136
+ * 1. 소비처는 초안(draft)을 볼 수 없다 — **반드시 발행된(released) 판만 전달**한다.
137
+ * 2. 현재 Figure가 released 상태이면 현재 source를 컴파일한다.
138
+ * 3. 현재 Figure가 저작 중(draft)이더라도 이전에 발행된 이력(FigureHistory)이 있으면
139
+ * 가장 최근의 released 판본을 컴파일하여 하위 호환성을 유지한다.
140
+ * 4. 한 번도 발행된 적 없는 초안 자산이거나 존재하지 않는 타입인 경우 null을 반환하여
141
+ * 미완성 자산이 씬 화면에 유출되는 것을 엄격히 차단한다.
142
+ * 5. 도메인 상속 시 자식 도메인의 타입이 부모 도메인의 동일 타입을 항상 오버라이드한다.
143
+ * 6. 소비처(board, twin, things-scene) 사용자에게 저작 권한(figure:query)이 없어도
144
+ * 발행된 청사진을 열람할 수 있도록 도메인 인증 상태에서 열람을 허용한다.
145
+ */
146
+ @Query(returns => GraphQLJSON, {
147
+ nullable: true,
148
+ description: 'Compiled FigureBlueprint for external consumers (board, twin, things-scene). Only returns released figures.'
149
+ })
150
+ async figureBlueprint(
151
+ @Arg('type') type: string,
152
+ @Ctx() context: ResolverContext
153
+ ): Promise<any> {
154
+ const { domain } = context.state
155
+
156
+ // 자식 도메인 자산 우선 탐색 후, 부모 도메인 표준 자산으로 폴백 (오버라이드 보장)
157
+ let figure = await getRepository(Figure).findOne({
158
+ where: { domain: { id: domain.id }, type }
159
+ })
160
+ if (!figure && domain.parentId) {
161
+ figure = await getRepository(Figure).findOne({
162
+ where: { domain: { id: domain.parentId }, type }
163
+ })
164
+ }
165
+
166
+ if (!figure) {
167
+ return null
168
+ }
169
+
170
+ let sourceToCompile: string | null = null
171
+
172
+ if (figure.state === 'released' && figure.source) {
173
+ sourceToCompile = figure.source
174
+ } else if (figure.id) {
175
+ // 저작 중(draft)인 경우 최근 발행 이력(FigureHistory)을 확인한다
176
+ const latestReleased = await getRepository(FigureHistory).findOne({
177
+ where: { domain: { id: figure.domain?.id ?? domain.id }, originalId: figure.id, state: 'released' },
178
+ order: { version: 'DESC' }
179
+ })
180
+ if (latestReleased && latestReleased.source) {
181
+ sourceToCompile = latestReleased.source
182
+ }
183
+ }
184
+
185
+ if (!sourceToCompile) {
186
+ return null
187
+ }
188
+
189
+ try {
190
+ const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
191
+ return compile(parsed)
192
+ } catch (err) {
193
+ console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
194
+ return null
195
+ }
196
+ }
129
197
  }
@@ -24,6 +24,7 @@ function part(over: Record<string, unknown> = {}) {
24
24
  /** 온전한 정본 하나. */
25
25
  function source(over: Record<string, unknown> = {}) {
26
26
  return {
27
+ version: 1,
27
28
  type: 'MIXER',
28
29
  base: { x: 300, y: 800, z: 300 },
29
30
  detailLevel: 'M',
@@ -59,22 +60,21 @@ describe('figure-source — 들어오는 문', () => {
59
60
  it('형식을 어기면 던지고, figure-model 의 코드를 그대로 전한다', () => {
60
61
  let thrown: FigureSourceError | undefined
61
62
  try {
62
- // 부품이 없는 정본
63
63
  parseSource(json(source({ parts: [] })))
64
64
  } catch (e) {
65
65
  thrown = e as FigureSourceError
66
66
  }
67
67
 
68
68
  expect(thrown).toBeInstanceOf(FigureSourceError)
69
- expect(thrown!.findings.length).toBeGreaterThan(0)
70
- // 코드를 서버가 다시 짓지 않는다 — figure-model 의 것을 그대로 쓴다
71
- for (const f of thrown!.findings) {
72
- expect(typeof f.code).toBe('string')
73
- expect(f.code.length).toBeGreaterThan(0)
74
- }
69
+ expect(thrown!.findings[0].code).toBe('missing-parts')
70
+ })
71
+
72
+ it('필수 필드가 빠지면 던진다', () => {
73
+ // type 없음
74
+ expect(() => parseSource(json({ base: { x: 1, y: 1, z: 1 }, parts: [part()] }))).toThrow(FigureSourceError)
75
75
  })
76
76
 
77
- it('어디서 났는지(path)를 함께 전한다 — 화면이 그 자리를 가리킬 수 있게', () => {
77
+ it('오류의 위치(at)를 알린다 — 어느 부품인지 저작 화면이 짚을 수 있다', () => {
78
78
  let thrown: FigureSourceError | undefined
79
79
  try {
80
80
  parseSource(json(source({ parts: [part({ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: -1, y: 1, z: 1 } } })] })))
@@ -94,10 +94,16 @@ describe('figure-source — 들어오는 문', () => {
94
94
  })
95
95
 
96
96
  it('정본을 다시 직렬화하지 않는다 — 들어온 문자열을 그대로 저장한다', () => {
97
- // 다시 만들면 키 순서·공백이 바뀌어 「고치지 않았는데 바뀌었다」가 된다
97
+ // 이미 version이 있는 정본은 키 순서·공백을 그대로 보존한다
98
98
  const raw = json(source())
99
99
  expect(parseSource(raw).source).toBe(raw)
100
100
  })
101
+
102
+ it('version이 누락된 구형/외부 정본은 FIGURE_SOURCE_VERSION으로 보정 직렬화한다', () => {
103
+ const withoutVersion = json({ type: 'MIXER', base: { x: 300, y: 800, z: 300 }, detailLevel: 'M', parts: [part()] })
104
+ const parsed = JSON.parse(parseSource(withoutVersion).source)
105
+ expect(parsed.version).toBe(1)
106
+ })
101
107
  })
102
108
 
103
109
  describe('파생값은 서버가 계산한다', () => {
@@ -1,4 +1,4 @@
1
- import { compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
1
+ import { FIGURE_SOURCE_VERSION, compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
2
2
 
3
3
  import { FigureFinding } from './figure-type.js'
4
4
 
@@ -24,7 +24,7 @@ import { FigureFinding } from './figure-type.js'
24
24
  * 목록 화면이 실물과 다른 것을 보여 준다. 정본에서 계산한다.
25
25
  */
26
26
  export interface ParsedSource {
27
- /** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 다시 직렬화하지 않는다. */
27
+ /** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 단, version 누락 시에만 정규화 직렬화한다. */
28
28
  source: string
29
29
  /** 목록 화면이 정렬·필터에 쓰는 파생값. */
30
30
  derived: { score: number; triangles: number; groups: number }
@@ -65,6 +65,14 @@ export function parseSource(sourceJson: string): ParsedSource {
65
65
  )
66
66
  }
67
67
 
68
+ // version이 누락된 구형/외부 입력의 경우 FIGURE_SOURCE_VERSION을 부여하여 스키마를 정규화한다.
69
+ // 이미 version이 있는 정본은 포맷 훼손을 막기 위해 sourceJson 원본을 그대로 보존한다.
70
+ let normalizedSource = sourceJson
71
+ if (parsed && typeof parsed === 'object' && (parsed as any).version === undefined) {
72
+ (parsed as any).version = FIGURE_SOURCE_VERSION
73
+ normalizedSource = JSON.stringify(parsed, null, 2)
74
+ }
75
+
68
76
  // 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.
69
77
  // 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
70
78
  const blueprint = compile(parsed as any)
@@ -72,7 +80,7 @@ export function parseSource(sourceJson: string): ParsedSource {
72
80
  const score = scoreOf(blueprint)
73
81
 
74
82
  return {
75
- source: sourceJson,
83
+ source: normalizedSource,
76
84
  derived: {
77
85
  score: score.score,
78
86
  triangles: cost.triangles,
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * 실제 모델은 부르지 않는다. 여기서 볼 것은 모델의 솜씨가 아니라 **우리 배선**이다.
14
14
  */
15
- import { clearToolRegistry, getToolCategories } from '@things-factory/ai-client-base'
15
+ import { clearToolRegistry, getToolCategories, registryAssistantChat } from '@things-factory/ai-client-base'
16
16
 
17
17
  import { __setFigureToolDepsForTest, registerFigureToolCategories } from './figure-tools'
18
18
 
@@ -91,7 +91,7 @@ function toolNamed(name: string) {
91
91
  }
92
92
 
93
93
  const ctx = (host: any = { figureId: 'fig-1', palette: ['palette.primary'] }) => ({
94
- state: { domain: MINE, host }
94
+ state: { domain: MINE }, host
95
95
  })
96
96
 
97
97
  beforeEach(() => {
@@ -103,6 +103,25 @@ beforeEach(() => {
103
103
 
104
104
  afterEach(() => clearToolRegistry())
105
105
 
106
+ test('실제 registryAssistantChat 경계가 열린 모델 문맥을 Figure 도구에 전달한다', async () => {
107
+ const seen: any[] = []
108
+ let turn = 0
109
+ await registryAssistantChat({
110
+ client: { chatWithTools: async (messages: any[]) => {
111
+ seen.push(messages)
112
+ return turn++ === 0
113
+ ? { toolCalls: [{ id: 'read-1', name: 'getFigureSource', arguments: {}, type: 'tool_use' }], stopReason: 'tool_use' }
114
+ : { text: '조회했습니다.', toolCalls: [], stopReason: 'end_turn' }
115
+ } } as any,
116
+ messages: [{ role: 'user', content: '현재 모델을 조회해줘' }],
117
+ systemPrompt: 'Read the current figure.', toolCategories: ['figure-authoring'],
118
+ state: { domain: MINE }, hostContext: { figureId: 'fig-1', draftSource: SOURCE }
119
+ })
120
+ expect(seen).toHaveLength(2)
121
+ expect(JSON.stringify(seen[1])).toContain('MIXER')
122
+ expect(JSON.stringify(seen[1])).not.toContain('FIGURE_TARGET_REQUIRED')
123
+ })
124
+
106
125
  describe('범주가 등록된다', () => {
107
126
  it('figure-authoring 이 도구 넷과 함께 올라온다', () => {
108
127
  const category = getToolCategories().find((one: any) => one.name === 'figure-authoring')!
@@ -160,8 +179,20 @@ describe('대화면 문맥에서 대상을 해소한다', () => {
160
179
  expect(result.source.type).toBe('MIXER')
161
180
  })
162
181
 
163
- it('열린 도형도 없고 id 도 없으면 명시 실패한다 — 아무 도형이나 고르지 않는다', async () => {
164
- await expect(toolNamed('getFigureSource').builder({}, ctx({}))).rejects.toThrow(/figureId is required/)
182
+ it.each(['getFigureSource', 'inspectFigure'])('%s: 목록의 대상 누락은 채팅을 끊지 않고 다음 동작을 안내한다', async name => {
183
+ const result = await toolNamed(name).builder({}, ctx({}))
184
+ expect(result.error).toBe('FIGURE_TARGET_REQUIRED')
185
+ expect(result.nextStep).toContain('listFigures')
186
+ expect(result.nextStep).toContain('create: true')
187
+ expect(result.source).toBeUndefined()
188
+ })
189
+
190
+ it('목록에서 대상을 찾아 다시 호출하면 해당 도형의 정본을 읽는다', async () => {
191
+ const catalog = await toolNamed('listFigures').builder({}, ctx({}))
192
+ const target = catalog.figures.find((figure: any) => figure.name === '믹서')
193
+ const result = await toolNamed('getFigureSource').builder({ figureId: target.id }, ctx({}))
194
+ expect(result.id).toBe('fig-1')
195
+ expect(result.source.type).toBe('MIXER')
165
196
  })
166
197
 
167
198
  it('정본이 없는 도형은 없다고 말한다 — 지어내지 않는다', async () => {
@@ -215,6 +246,27 @@ describe('대화면 문맥에서 대상을 해소한다', () => {
215
246
  )
216
247
  ).rejects.toThrow(/does not match Figure/)
217
248
  })
249
+
250
+ it('재현: 기존 도형 편집 중 미저장 초안의 type이 FIGURE여도 현재 도형 초안으로 허용해야 한다', async () => {
251
+ // DB의 row는 MIXER(또는 OHT)이지만, 캔버스/도크의 초안 type은 일반 'FIGURE'일 수 있음
252
+ const result = await toolNamed('getFigureSource').builder(
253
+ {},
254
+ ctx({ figureId: 'fig-1', palette: ['palette.primary'], draftSource: { ...SOURCE, type: 'FIGURE' } })
255
+ )
256
+ expect(result.draft).toBe(true)
257
+ expect(result.type).toBe('MIXER')
258
+ })
259
+
260
+ it('재현: LLM이 새 미저장 모델의 임시 타입(FIGURE_MTZO5R10)을 figureId로 전달해도 초안을 읽어야 한다', async () => {
261
+ const tempDraft = { ...SOURCE, type: 'FIGURE_MTZO5R10' }
262
+ const host = { editorMode: 'new', draftSource: tempDraft, palette: ['palette.primary'] }
263
+ const result = await toolNamed('getFigureSource').builder(
264
+ { figureId: 'FIGURE_MTZO5R10' },
265
+ ctx(host)
266
+ )
267
+ expect(result.draft).toBe(true)
268
+ expect(result.type).toBe('FIGURE_MTZO5R10')
269
+ })
218
270
  })
219
271
 
220
272
  /*
@@ -246,6 +298,43 @@ describe('모델이 넘긴 id 를 믿지 않는다', () => {
246
298
  })
247
299
 
248
300
  describe('후보를 요청할 때', () => {
301
+ it('저장 전 새 모델도 현재 초안을 읽고 수정 후보의 기반으로 사용한다', async () => {
302
+ const host = { editorMode: 'new', draftSource: SOURCE, palette: ['palette.primary'] }
303
+ const read = await toolNamed('getFigureSource').builder({}, ctx(host))
304
+ expect(read.source).toEqual(SOURCE)
305
+ expect(read.draft).toBe(true)
306
+ const result = await toolNamed('proposeFigureCandidate').builder({ prompt: '몸체를 키워줘' }, ctx(host))
307
+ expect(proposed[0].base).toEqual(SOURCE)
308
+ expect(result.args.draftType).toBe('MIXER')
309
+ expect(result.args.figureId).toBeUndefined()
310
+ })
311
+
312
+ it('목록에 남은 새 모델 초안은 현재 모델로 사용하지 않는다', async () => {
313
+ const result = await toolNamed('getFigureSource').builder({}, ctx({ editorMode: 'catalog', draftSource: SOURCE }))
314
+ expect(result.error).toBe('FIGURE_TARGET_REQUIRED')
315
+ })
316
+
317
+ it('목록에서 새 제작은 기존 도형을 요구하지 않는다', async () => {
318
+ const result = await toolNamed('proposeFigureCandidate').builder(
319
+ { prompt: '믹서를 새로 만들어줘', create: true }, ctx({ palette: ['palette.primary'] })
320
+ )
321
+ expect(result.proposed).toBe(true)
322
+ expect(proposed[0].base).toBeUndefined()
323
+ })
324
+
325
+ it('figureId null은 도구 설명대로 새 제작을 의미한다', async () => {
326
+ await toolNamed('proposeFigureCandidate').builder({ prompt: '새로 만들어줘', figureId: null }, ctx())
327
+ expect(proposed[0].base).toBeUndefined()
328
+ })
329
+
330
+ it('다른 도형을 지정하면 열린 도형의 미저장 초안을 섞지 않는다', async () => {
331
+ const result = await toolNamed('getFigureSource').builder(
332
+ { figureId: 'fig-blank' }, ctx({ figureId: 'fig-1', draftSource: SOURCE })
333
+ )
334
+ expect(result.id).toBe('fig-blank')
335
+ expect(result.source).toBeNull()
336
+ })
337
+
249
338
  it('열린 도형이 있으면 고치는 것으로 본다 — 그 정본이 함께 나간다', async () => {
250
339
  await toolNamed('proposeFigureCandidate').builder({ prompt: '재질을 줄여줘' }, ctx())
251
340