@things-factory/board-import 10.0.0-beta.71 → 10.0.0-beta.73
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/dist-server/index.d.ts +1 -1
- package/dist-server/index.js +1 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
- package/dist-server/service/adapters/dxf-adapter.js +18 -4
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
- package/dist-server/service/adapters/image-adapter.d.ts +32 -0
- package/dist-server/service/adapters/image-adapter.js +420 -0
- package/dist-server/service/adapters/image-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +96 -4
- package/dist-server/service/ai/types.js +221 -12
- package/dist-server/service/ai/types.js.map +1 -1
- package/dist-server/service/converters/generic-to.d.ts +17 -8
- package/dist-server/service/converters/generic-to.js +44 -44
- package/dist-server/service/converters/generic-to.js.map +1 -1
- package/dist-server/service/import-session/import-actions.d.ts +79 -0
- package/dist-server/service/import-session/import-actions.js +98 -0
- package/dist-server/service/import-session/import-actions.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
- package/dist-server/service/import-session/import-session-resolver.js +166 -38
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
- package/dist-server/service/import-session/import-worker.js +55 -13
- package/dist-server/service/import-session/import-worker.js.map +1 -1
- package/dist-server/service/import-session/index.d.ts +5 -3
- package/dist-server/service/import-session/index.js +16 -9
- package/dist-server/service/import-session/index.js.map +1 -1
- package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
- package/dist-server/service/import-session/materialize-from-session.js +104 -0
- package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
- package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
- package/dist-server/service/import-session/suggest-board-name.js +195 -0
- package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
- package/dist-server/service/import-tools.d.ts +10 -0
- package/dist-server/service/import-tools.js +227 -0
- package/dist-server/service/import-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +22 -19
- package/dist-server/service/index.js +46 -36
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/pipeline/index.d.ts +42 -9
- package/dist-server/service/pipeline/index.js +92 -23
- package/dist-server/service/pipeline/index.js.map +1 -1
- package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
- package/dist-server/service/pipeline/stage2-mapping.js +84 -26
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
- package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
- package/dist-server/service/pipeline/stage3-board.js +65 -10
- package/dist-server/service/pipeline/stage3-board.js.map +1 -1
- package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
- package/dist-server/service/pipeline/stage4-binding.js +2 -2
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
- package/dist-server/service/registry/index.d.ts +1 -1
- package/dist-server/service/registry/index.js.map +1 -1
- package/dist-server/service/types/index.d.ts +35 -0
- package/dist-server/service/types/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -6
- package/server/index.ts +1 -1
- package/server/service/adapters/dxf-adapter.ts +17 -4
- package/server/service/adapters/image-adapter.test.ts +545 -0
- package/server/service/adapters/image-adapter.ts +464 -0
- package/server/service/ai/types.ts +354 -19
- package/server/service/converters/generic-to.test.ts +91 -0
- package/server/service/converters/generic-to.ts +66 -49
- package/server/service/import-session/import-actions.test.ts +185 -0
- package/server/service/import-session/import-actions.ts +164 -0
- package/server/service/import-session/import-session-resolver.ts +171 -39
- package/server/service/import-session/import-worker.ts +56 -12
- package/server/service/import-session/index.ts +19 -3
- package/server/service/import-session/materialize-from-session.test.ts +274 -0
- package/server/service/import-session/materialize-from-session.ts +158 -0
- package/server/service/import-session/suggest-board-name.test.ts +271 -0
- package/server/service/import-session/suggest-board-name.ts +279 -0
- package/server/service/import-tools.test.ts +137 -0
- package/server/service/import-tools.ts +255 -0
- package/server/service/index.ts +35 -18
- package/server/service/pipeline/index.ts +118 -23
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +102 -27
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +76 -11
- package/server/service/pipeline/stage4-binding.ts +2 -2
- package/server/service/registry/index.ts +1 -1
- package/server/service/types/index.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/board-import",
|
|
3
|
-
"version": "10.0.0-beta.
|
|
3
|
+
"version": "10.0.0-beta.73",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@operato/graphql": "^10.0.0-beta.1",
|
|
26
|
-
"@things-factory/ai-client-base": "^10.0.0-beta.
|
|
27
|
-
"@things-factory/attachment-base": "^10.0.0-beta.
|
|
28
|
-
"@things-factory/auth-base": "^10.0.0-beta.
|
|
26
|
+
"@things-factory/ai-client-base": "^10.0.0-beta.72",
|
|
27
|
+
"@things-factory/attachment-base": "^10.0.0-beta.72",
|
|
28
|
+
"@things-factory/auth-base": "^10.0.0-beta.72",
|
|
29
|
+
"@things-factory/board-service": "^10.0.0-beta.72",
|
|
29
30
|
"@things-factory/env": "^10.0.0-beta.19",
|
|
30
|
-
"@things-factory/shell": "^10.0.0-beta.
|
|
31
|
+
"@things-factory/shell": "^10.0.0-beta.72",
|
|
31
32
|
"dxf-parser": "^1.1.2"
|
|
32
33
|
},
|
|
33
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "341da092718582e73103234d4ecd28d22d4fd85a"
|
|
34
35
|
}
|
package/server/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import type {
|
|
|
27
27
|
ParseOptions,
|
|
28
28
|
ParseResult,
|
|
29
29
|
UniversalEntity
|
|
30
|
-
} from '../types/index
|
|
30
|
+
} from '../types/index'
|
|
31
31
|
|
|
32
32
|
const DXF_INSUNITS_TO_UNITS: Record<number, ParseResult['units']> = {
|
|
33
33
|
0: 'unknown',
|
|
@@ -49,9 +49,22 @@ export class DxfAdapter implements FormatAdapter {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
async detect(file: AdapterInput, filename?: string): Promise<boolean> {
|
|
52
|
-
if (filename
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
if (filename) {
|
|
53
|
+
const lower = filename.toLowerCase()
|
|
54
|
+
if (lower.endsWith('.dxf')) return true
|
|
55
|
+
// 명백한 non-DXF 확장자는 content peek 자체를 시도하지 않음.
|
|
56
|
+
// 잘못된 입력 타입(Promise/Stream 등) 이 흘러도 detect 가 throw 하지 않게 가드.
|
|
57
|
+
if (/\.(jpe?g|png|gif|webp|bmp|svg|pdf|ifc|gltf|glb|json|xml|csv|xlsx?|docx?)$/.test(lower)) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const head = readHead(file, 256)
|
|
63
|
+
return /\b0\s*\n\s*SECTION\b/.test(head) || /^\s*999/.test(head)
|
|
64
|
+
} catch {
|
|
65
|
+
// 입력 타입을 텍스트로 못 읽으면 DXF 가 아니다 — false 로 흘려보내 다음 어댑터에 양보.
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
async parse(file: AdapterInput, options: ParseOptions = {}): Promise<ParseResult> {
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageAdapter — 단위 테스트.
|
|
3
|
+
*
|
|
4
|
+
* VLM (BoardImportAI.parseFromImage) 가 외부 의존이라 mock 으로 격리.
|
|
5
|
+
* - detect: filename 확장자 + magic byte 검사
|
|
6
|
+
* - parse: AI client 미등록 / vision 미지원 / 빈 응답 / 비정규 응답 정규화
|
|
7
|
+
* - parse: 정상 응답에 대한 entity / bbox / metadata
|
|
8
|
+
*/
|
|
9
|
+
import { ImageAdapter } from './image-adapter'
|
|
10
|
+
import { setAIClient, type BoardImportAI } from '../ai/types'
|
|
11
|
+
import type { UniversalEntity } from '../types/index'
|
|
12
|
+
|
|
13
|
+
// ── mock AI client ──────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface MockAIOptions {
|
|
16
|
+
parseFromImage?: BoardImportAI['parseFromImage']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeMockAI(opts: MockAIOptions = {}): BoardImportAI {
|
|
20
|
+
return {
|
|
21
|
+
id: 'mock:test',
|
|
22
|
+
parseFromImage: opts.parseFromImage
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 매 테스트 사이 client 격리 — root jest setup 의 clearMocks 와 별개.
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
setAIClient(undefined)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// ── detect ───────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe('ImageAdapter.detect', () => {
|
|
34
|
+
const adapter = new ImageAdapter()
|
|
35
|
+
|
|
36
|
+
test('파일명 확장자 .png/.jpg/.jpeg/.webp/.gif/.bmp 통과', async () => {
|
|
37
|
+
for (const ext of ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']) {
|
|
38
|
+
expect(await adapter.detect(Buffer.alloc(0), `floor${ext}`)).toBe(true)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('PNG magic byte 인식 (filename 없어도)', async () => {
|
|
43
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0])
|
|
44
|
+
expect(await adapter.detect(png)).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('JPEG magic byte 인식', async () => {
|
|
48
|
+
const jpg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0])
|
|
49
|
+
expect(await adapter.detect(jpg)).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('GIF magic byte 인식', async () => {
|
|
53
|
+
const gif = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0])
|
|
54
|
+
expect(await adapter.detect(gif)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('WebP (RIFF...WEBP) magic byte 인식', async () => {
|
|
58
|
+
const webp = Buffer.from([
|
|
59
|
+
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50
|
|
60
|
+
])
|
|
61
|
+
expect(await adapter.detect(webp)).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('비이미지 binary (DXF 텍스트) 는 false', async () => {
|
|
65
|
+
const dxf = Buffer.from(' 0\nSECTION\n 2\nHEADER\n')
|
|
66
|
+
expect(await adapter.detect(dxf)).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('너무 짧은 buffer 는 false', async () => {
|
|
70
|
+
expect(await adapter.detect(Buffer.from([0x89, 0x50]))).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ── parse: 사전 조건 검증 ────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe('ImageAdapter.parse — 사전 검증', () => {
|
|
77
|
+
const adapter = new ImageAdapter()
|
|
78
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
79
|
+
|
|
80
|
+
test('AI client 미등록 시 명확한 에러', async () => {
|
|
81
|
+
setAIClient(undefined)
|
|
82
|
+
await expect(adapter.parse(png)).rejects.toThrow(/registered BoardImportAI/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('parseFromImage 미구현 (vision 미지원) 시 명확한 에러', async () => {
|
|
86
|
+
setAIClient(makeMockAI({ parseFromImage: undefined }))
|
|
87
|
+
await expect(adapter.parse(png)).rejects.toThrow(/parseFromImage/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('string 입력은 거절 (binary 만 수용)', async () => {
|
|
91
|
+
setAIClient(makeMockAI({ parseFromImage: async () => [] }))
|
|
92
|
+
await expect(adapter.parse('not-a-buffer' as any)).rejects.toThrow(/Buffer/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('Uint8Array / ArrayBuffer 도 수용', async () => {
|
|
96
|
+
let calledWith: Buffer | undefined
|
|
97
|
+
setAIClient(
|
|
98
|
+
makeMockAI({
|
|
99
|
+
parseFromImage: async (buf: any) => {
|
|
100
|
+
calledWith = buf
|
|
101
|
+
return []
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
const arr = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0])
|
|
106
|
+
await adapter.parse(arr)
|
|
107
|
+
expect(Buffer.isBuffer(calledWith)).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ── parse: 결과 정규화 ──────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe('ImageAdapter.parse — VLM 응답 정규화', () => {
|
|
114
|
+
const adapter = new ImageAdapter()
|
|
115
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
116
|
+
|
|
117
|
+
test('빈 응답 시 entities=[] + zero bbox', async () => {
|
|
118
|
+
setAIClient(makeMockAI({ parseFromImage: async () => [] }))
|
|
119
|
+
const result = await adapter.parse(png)
|
|
120
|
+
expect(result.entities).toEqual([])
|
|
121
|
+
expect(result.bbox).toEqual({ min: { x: 0, y: 0 }, max: { x: 0, y: 0 } })
|
|
122
|
+
expect(result.units).toBe('unknown')
|
|
123
|
+
expect(result.metadata.format).toBe('image')
|
|
124
|
+
expect(result.metadata.source).toBe('vlm')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('uid 누락 시 인덱스 기반 fallback', async () => {
|
|
128
|
+
setAIClient(
|
|
129
|
+
makeMockAI({
|
|
130
|
+
parseFromImage: async () =>
|
|
131
|
+
[
|
|
132
|
+
{ kind: 'shape', position: { x: 10, y: 20 }, size: { width: 30, height: 40 } } as any,
|
|
133
|
+
{ kind: 'shape', position: { x: 50, y: 60 }, size: { width: 10, height: 10 } } as any
|
|
134
|
+
] as UniversalEntity[]
|
|
135
|
+
})
|
|
136
|
+
)
|
|
137
|
+
const result = await adapter.parse(png)
|
|
138
|
+
expect(result.entities[0].uid).toBe('img-0')
|
|
139
|
+
expect(result.entities[1].uid).toBe('img-1')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('bbox 누락 시 position+size 로 합성', async () => {
|
|
143
|
+
setAIClient(
|
|
144
|
+
makeMockAI({
|
|
145
|
+
parseFromImage: async () =>
|
|
146
|
+
[
|
|
147
|
+
{
|
|
148
|
+
uid: 'a',
|
|
149
|
+
kind: 'shape',
|
|
150
|
+
position: { x: 10, y: 20 },
|
|
151
|
+
size: { width: 30, height: 40 }
|
|
152
|
+
} as any
|
|
153
|
+
] as UniversalEntity[]
|
|
154
|
+
})
|
|
155
|
+
)
|
|
156
|
+
const result = await adapter.parse(png)
|
|
157
|
+
expect(result.entities[0].bbox).toEqual({ min: { x: 10, y: 20 }, max: { x: 40, y: 60 } })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('비숫자 position 은 (0,0) 으로 fallback', async () => {
|
|
161
|
+
setAIClient(
|
|
162
|
+
makeMockAI({
|
|
163
|
+
parseFromImage: async () =>
|
|
164
|
+
[{ uid: 'a', kind: 'shape', position: { x: 'bad', y: NaN } } as any] as UniversalEntity[]
|
|
165
|
+
})
|
|
166
|
+
)
|
|
167
|
+
const result = await adapter.parse(png)
|
|
168
|
+
expect(result.entities[0].position).toEqual({ x: 0, y: 0 })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('전체 bbox 는 모든 entity bbox 합집합', async () => {
|
|
172
|
+
setAIClient(
|
|
173
|
+
makeMockAI({
|
|
174
|
+
parseFromImage: async () =>
|
|
175
|
+
[
|
|
176
|
+
{ uid: 'a', kind: 'shape', bbox: { min: { x: 0, y: 0 }, max: { x: 10, y: 10 } } } as any,
|
|
177
|
+
{
|
|
178
|
+
uid: 'b',
|
|
179
|
+
kind: 'shape',
|
|
180
|
+
bbox: { min: { x: 50, y: 30 }, max: { x: 100, y: 80 } }
|
|
181
|
+
} as any
|
|
182
|
+
] as UniversalEntity[]
|
|
183
|
+
})
|
|
184
|
+
)
|
|
185
|
+
const result = await adapter.parse(png)
|
|
186
|
+
expect(result.bbox).toEqual({ min: { x: 0, y: 0 }, max: { x: 100, y: 80 } })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('AI client id 가 metadata.aiClientId 로 노출', async () => {
|
|
190
|
+
setAIClient(makeMockAI({ parseFromImage: async () => [] }))
|
|
191
|
+
const result = await adapter.parse(png)
|
|
192
|
+
expect(result.metadata.aiClientId).toBe('mock:test')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('options.context 와 options.categories 가 parseFromImage 에 전달됨', async () => {
|
|
196
|
+
let receivedOpts: any
|
|
197
|
+
setAIClient(
|
|
198
|
+
makeMockAI({
|
|
199
|
+
parseFromImage: async (_buf, opts) => {
|
|
200
|
+
receivedOpts = opts
|
|
201
|
+
return []
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
)
|
|
205
|
+
await adapter.parse(png, { context: 'factory floor', categories: ['stocker', 'agv'] } as any)
|
|
206
|
+
expect(receivedOpts.context).toBe('factory floor')
|
|
207
|
+
expect(receivedOpts.categories).toEqual(['stocker', 'agv'])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('non-array 응답은 빈 배열로 안전 처리 (LLM 출력 신뢰 X)', async () => {
|
|
211
|
+
setAIClient(makeMockAI({ parseFromImage: async () => null as any }))
|
|
212
|
+
const result = await adapter.parse(png)
|
|
213
|
+
expect(result.entities).toEqual([])
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// ── parse: 이미지 dimension + 좌표 정규화 보정 ─────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 1024 x 768 PNG 헤더 (IHDR width=0x400, height=0x300).
|
|
221
|
+
* width@16-19, height@20-23 (BE).
|
|
222
|
+
*/
|
|
223
|
+
function makePng1024x768(): Buffer {
|
|
224
|
+
const buf = Buffer.alloc(26)
|
|
225
|
+
buf[0] = 0x89; buf[1] = 0x50; buf[2] = 0x4e; buf[3] = 0x47
|
|
226
|
+
buf[4] = 0x0d; buf[5] = 0x0a; buf[6] = 0x1a; buf[7] = 0x0a
|
|
227
|
+
// length(4) + 'IHDR'(4) + width(4 BE) + height(4 BE) + ...
|
|
228
|
+
buf.writeUInt32BE(13, 8)
|
|
229
|
+
buf[12] = 0x49; buf[13] = 0x48; buf[14] = 0x44; buf[15] = 0x52
|
|
230
|
+
buf.writeUInt32BE(1024, 16)
|
|
231
|
+
buf.writeUInt32BE(768, 20)
|
|
232
|
+
return buf
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
describe('ImageAdapter.parse — 이미지 dimension', () => {
|
|
236
|
+
const adapter = new ImageAdapter()
|
|
237
|
+
|
|
238
|
+
test('PNG 헤더에서 width/height 읽어 metadata + parseFromImage 옵션에 주입', async () => {
|
|
239
|
+
let receivedOpts: any
|
|
240
|
+
setAIClient(
|
|
241
|
+
makeMockAI({
|
|
242
|
+
parseFromImage: async (_buf, opts) => {
|
|
243
|
+
receivedOpts = opts
|
|
244
|
+
return []
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
)
|
|
248
|
+
const result = await adapter.parse(makePng1024x768())
|
|
249
|
+
expect((receivedOpts as any).imageWidth).toBe(1024)
|
|
250
|
+
expect((receivedOpts as any).imageHeight).toBe(768)
|
|
251
|
+
expect(result.metadata.imageWidth).toBe(1024)
|
|
252
|
+
expect(result.metadata.imageHeight).toBe(768)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('헤더만 있는 짧은 buffer 도 안전 — dimension 못 읽으면 undefined', async () => {
|
|
256
|
+
let receivedOpts: any
|
|
257
|
+
setAIClient(
|
|
258
|
+
makeMockAI({
|
|
259
|
+
parseFromImage: async (_buf, opts) => {
|
|
260
|
+
receivedOpts = opts
|
|
261
|
+
return []
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
// 짧은 PNG signature 만 — IHDR 없음 → undefined
|
|
266
|
+
const tiny = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
267
|
+
await adapter.parse(tiny)
|
|
268
|
+
expect((receivedOpts as any).imageWidth).toBeUndefined()
|
|
269
|
+
expect((receivedOpts as any).imageHeight).toBeUndefined()
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('ImageAdapter.parse — 좌표 정규화 자동 보정', () => {
|
|
274
|
+
const adapter = new ImageAdapter()
|
|
275
|
+
|
|
276
|
+
test('모든 좌표가 [0..1] 범위면 image dimension 으로 곱해서 픽셀 좌표로 보정', async () => {
|
|
277
|
+
setAIClient(
|
|
278
|
+
makeMockAI({
|
|
279
|
+
parseFromImage: async () =>
|
|
280
|
+
[
|
|
281
|
+
{
|
|
282
|
+
uid: 'a',
|
|
283
|
+
kind: 'shape',
|
|
284
|
+
position: { x: 0.1, y: 0.2 },
|
|
285
|
+
size: { width: 0.05, height: 0.05 },
|
|
286
|
+
bbox: { min: { x: 0.1, y: 0.2 }, max: { x: 0.15, y: 0.25 } }
|
|
287
|
+
} as any
|
|
288
|
+
] as UniversalEntity[]
|
|
289
|
+
})
|
|
290
|
+
)
|
|
291
|
+
const result = await adapter.parse(makePng1024x768())
|
|
292
|
+
const e = result.entities[0]
|
|
293
|
+
// 1024 x 768 로 곱해진 값
|
|
294
|
+
expect(e.position.x).toBeCloseTo(102.4)
|
|
295
|
+
expect(e.position.y).toBeCloseTo(153.6)
|
|
296
|
+
expect(e.size!.width).toBeCloseTo(51.2)
|
|
297
|
+
expect(e.size!.height).toBeCloseTo(38.4)
|
|
298
|
+
expect(e.bbox.max.x).toBeCloseTo(153.6)
|
|
299
|
+
expect(e.bbox.max.y).toBeCloseTo(192)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('좌표 중 하나라도 2 초과면 정규화 좌표가 아니라고 판단 — 그대로 보존', async () => {
|
|
303
|
+
setAIClient(
|
|
304
|
+
makeMockAI({
|
|
305
|
+
parseFromImage: async () =>
|
|
306
|
+
[
|
|
307
|
+
{
|
|
308
|
+
uid: 'a',
|
|
309
|
+
kind: 'shape',
|
|
310
|
+
position: { x: 100, y: 200 },
|
|
311
|
+
size: { width: 50, height: 50 },
|
|
312
|
+
bbox: { min: { x: 100, y: 200 }, max: { x: 150, y: 250 } }
|
|
313
|
+
} as any
|
|
314
|
+
] as UniversalEntity[]
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
const result = await adapter.parse(makePng1024x768())
|
|
318
|
+
const e = result.entities[0]
|
|
319
|
+
expect(e.position.x).toBe(100)
|
|
320
|
+
expect(e.size!.width).toBe(50)
|
|
321
|
+
expect(e.bbox.max.x).toBe(150)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('image dimension 을 못 읽었으면 정규화 의심돼도 그대로 (스케일 못 함)', async () => {
|
|
325
|
+
setAIClient(
|
|
326
|
+
makeMockAI({
|
|
327
|
+
parseFromImage: async () =>
|
|
328
|
+
[
|
|
329
|
+
{
|
|
330
|
+
uid: 'a',
|
|
331
|
+
kind: 'shape',
|
|
332
|
+
position: { x: 0.1, y: 0.2 },
|
|
333
|
+
size: { width: 0.05, height: 0.05 },
|
|
334
|
+
bbox: { min: { x: 0.1, y: 0.2 }, max: { x: 0.15, y: 0.25 } }
|
|
335
|
+
} as any
|
|
336
|
+
] as UniversalEntity[]
|
|
337
|
+
})
|
|
338
|
+
)
|
|
339
|
+
// dimension 못 읽는 짧은 PNG signature
|
|
340
|
+
const tiny = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
341
|
+
const result = await adapter.parse(tiny)
|
|
342
|
+
expect(result.entities[0].position.x).toBeCloseTo(0.1)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// ── parse: 1-pass structured response (viewType + confidence + reasoning) ──
|
|
347
|
+
|
|
348
|
+
describe('ImageAdapter.parse — 1-pass structured response', () => {
|
|
349
|
+
const adapter = new ImageAdapter()
|
|
350
|
+
|
|
351
|
+
test('VLM 이 ImageAnalysis 형태로 반환 시 viewType / confidence / reasoning 이 metadata 노출', async () => {
|
|
352
|
+
// top-down 으로 테스트 — perspective-3d 는 grid fallback 으로 좌표가 override 되어
|
|
353
|
+
// metadata 노출과 좌표 보존을 한 번에 확인하기 어렵다. metadata 노출만 검증.
|
|
354
|
+
setAIClient(
|
|
355
|
+
makeMockAI({
|
|
356
|
+
parseFromImage: async () =>
|
|
357
|
+
({
|
|
358
|
+
viewType: 'top-down',
|
|
359
|
+
confidence: 0.85,
|
|
360
|
+
reasoning: 'orthographic floor plan from above',
|
|
361
|
+
entities: [
|
|
362
|
+
{
|
|
363
|
+
uid: 'a',
|
|
364
|
+
kind: 'shape',
|
|
365
|
+
position: { x: 100, y: 200 },
|
|
366
|
+
size: { width: 50, height: 60 },
|
|
367
|
+
bbox: { min: { x: 100, y: 200 }, max: { x: 150, y: 260 } }
|
|
368
|
+
}
|
|
369
|
+
]
|
|
370
|
+
} as any)
|
|
371
|
+
})
|
|
372
|
+
)
|
|
373
|
+
const result = await adapter.parse(makePng1024x768())
|
|
374
|
+
expect(result.metadata.viewType).toBe('top-down')
|
|
375
|
+
expect(result.metadata.viewTypeConfidence).toBe(0.85)
|
|
376
|
+
expect(result.metadata.viewTypeReasoning).toBe('orthographic floor plan from above')
|
|
377
|
+
expect(result.entities.length).toBe(1)
|
|
378
|
+
expect(result.entities[0].position.x).toBe(100)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
test('viewType 누락 시 기본 top-down', async () => {
|
|
382
|
+
setAIClient(
|
|
383
|
+
makeMockAI({
|
|
384
|
+
parseFromImage: async () =>
|
|
385
|
+
({ entities: [{ uid: 'a', kind: 'shape', position: { x: 10, y: 20 } }] } as any)
|
|
386
|
+
})
|
|
387
|
+
)
|
|
388
|
+
const result = await adapter.parse(makePng1024x768())
|
|
389
|
+
expect(result.metadata.viewType).toBe('top-down')
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('options.viewType 강제 시 VLM 응답 무시하고 그것으로', async () => {
|
|
393
|
+
setAIClient(
|
|
394
|
+
makeMockAI({
|
|
395
|
+
parseFromImage: async () =>
|
|
396
|
+
({
|
|
397
|
+
viewType: 'top-down',
|
|
398
|
+
confidence: 0.9,
|
|
399
|
+
entities: [{ uid: 'a', kind: 'shape', position: { x: 10, y: 20 } }]
|
|
400
|
+
} as any)
|
|
401
|
+
})
|
|
402
|
+
)
|
|
403
|
+
const result = await adapter.parse(makePng1024x768(), { viewType: 'perspective-3d' } as any)
|
|
404
|
+
expect(result.metadata.viewType).toBe('perspective-3d')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('userPrompt 가 parseFromImage 옵션으로 흘러감', async () => {
|
|
408
|
+
let receivedOpts: any
|
|
409
|
+
setAIClient(
|
|
410
|
+
makeMockAI({
|
|
411
|
+
parseFromImage: async (_buf, opts) => {
|
|
412
|
+
receivedOpts = opts
|
|
413
|
+
return [] as any
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
)
|
|
417
|
+
await adapter.parse(makePng1024x768(), {
|
|
418
|
+
userPrompt: 'fab lithography zone, central rectangles are stockers'
|
|
419
|
+
} as any)
|
|
420
|
+
expect((receivedOpts as any).userPrompt).toBe(
|
|
421
|
+
'fab lithography zone, central rectangles are stockers'
|
|
422
|
+
)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
test('userPrompt 가 metadata 에도 노출 (디버깅)', async () => {
|
|
426
|
+
setAIClient(makeMockAI({ parseFromImage: async () => [] as any }))
|
|
427
|
+
const result = await adapter.parse(makePng1024x768(), { userPrompt: 'hint' } as any)
|
|
428
|
+
expect(result.metadata.userPrompt).toBe('hint')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('perspective-3d 모드는 좌표 추론 신뢰 못 하므로 grid 로 fallback', async () => {
|
|
432
|
+
// VLM 이 entity 좌표를 반환해도 grid layout 이 override (F-7).
|
|
433
|
+
// size 는 보존, position 은 격자 cell 로.
|
|
434
|
+
setAIClient(
|
|
435
|
+
makeMockAI({
|
|
436
|
+
parseFromImage: async () =>
|
|
437
|
+
({
|
|
438
|
+
viewType: 'perspective-3d',
|
|
439
|
+
confidence: 0.9,
|
|
440
|
+
entities: [
|
|
441
|
+
{
|
|
442
|
+
uid: 'a',
|
|
443
|
+
kind: 'shape',
|
|
444
|
+
position: { x: 999, y: 888 }, // 임의 좌표
|
|
445
|
+
size: { width: 50, height: 80 },
|
|
446
|
+
bbox: { min: { x: 999, y: 888 }, max: { x: 1049, y: 968 } }
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
uid: 'b',
|
|
450
|
+
kind: 'shape',
|
|
451
|
+
position: { x: 100, y: 200 },
|
|
452
|
+
size: { width: 60, height: 60 },
|
|
453
|
+
bbox: { min: { x: 100, y: 200 }, max: { x: 160, y: 260 } }
|
|
454
|
+
}
|
|
455
|
+
]
|
|
456
|
+
} as any)
|
|
457
|
+
})
|
|
458
|
+
)
|
|
459
|
+
const result = await adapter.parse(makePng1024x768())
|
|
460
|
+
// 첫 entity 는 (0, 0), 두번째는 grid 다음 cell
|
|
461
|
+
expect(result.entities[0].position.x).toBe(0)
|
|
462
|
+
expect(result.entities[0].position.y).toBe(0)
|
|
463
|
+
// size 는 보존
|
|
464
|
+
expect(result.entities[0].size!.width).toBe(50)
|
|
465
|
+
expect(result.entities[0].size!.height).toBe(80)
|
|
466
|
+
// 두 번째는 cols=ceil(sqrt(2))=2 라 col=1, row=0
|
|
467
|
+
expect(result.entities[1].position.y).toBe(0)
|
|
468
|
+
expect(result.entities[1].position.x).toBeGreaterThan(0)
|
|
469
|
+
// metadata 에 layout method 표시
|
|
470
|
+
expect(result.metadata.layoutMethod).toBe('grid')
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test('top-down 은 grid fallback 미적용 — VLM 좌표 그대로', async () => {
|
|
474
|
+
setAIClient(
|
|
475
|
+
makeMockAI({
|
|
476
|
+
parseFromImage: async () =>
|
|
477
|
+
({
|
|
478
|
+
viewType: 'top-down',
|
|
479
|
+
confidence: 0.95,
|
|
480
|
+
entities: [
|
|
481
|
+
{
|
|
482
|
+
uid: 'a',
|
|
483
|
+
kind: 'shape',
|
|
484
|
+
position: { x: 100, y: 200 },
|
|
485
|
+
size: { width: 50, height: 60 }
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
} as any)
|
|
489
|
+
})
|
|
490
|
+
)
|
|
491
|
+
const result = await adapter.parse(makePng1024x768())
|
|
492
|
+
expect(result.entities[0].position.x).toBe(100)
|
|
493
|
+
expect(result.entities[0].position.y).toBe(200)
|
|
494
|
+
expect(result.metadata.layoutMethod).toBeUndefined()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('photo 도 grid fallback 적용', async () => {
|
|
498
|
+
setAIClient(
|
|
499
|
+
makeMockAI({
|
|
500
|
+
parseFromImage: async () =>
|
|
501
|
+
({
|
|
502
|
+
viewType: 'photo',
|
|
503
|
+
confidence: 0.7,
|
|
504
|
+
entities: [
|
|
505
|
+
{ uid: 'a', kind: 'shape', position: { x: 999, y: 888 }, size: { width: 40, height: 40 } }
|
|
506
|
+
]
|
|
507
|
+
} as any)
|
|
508
|
+
})
|
|
509
|
+
)
|
|
510
|
+
const result = await adapter.parse(makePng1024x768())
|
|
511
|
+
expect(result.entities[0].position).toEqual({ x: 0, y: 0 })
|
|
512
|
+
expect(result.metadata.layoutMethod).toBe('grid')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('grid 는 4개 entity 시 2x2 배치', async () => {
|
|
516
|
+
setAIClient(
|
|
517
|
+
makeMockAI({
|
|
518
|
+
parseFromImage: async () =>
|
|
519
|
+
({
|
|
520
|
+
viewType: 'perspective-3d',
|
|
521
|
+
confidence: 0.8,
|
|
522
|
+
entities: Array.from({ length: 4 }, (_, i) => ({
|
|
523
|
+
uid: `e${i}`,
|
|
524
|
+
kind: 'shape',
|
|
525
|
+
position: { x: 0, y: 0 },
|
|
526
|
+
size: { width: 50, height: 50 }
|
|
527
|
+
}))
|
|
528
|
+
} as any)
|
|
529
|
+
})
|
|
530
|
+
)
|
|
531
|
+
const result = await adapter.parse(makePng1024x768())
|
|
532
|
+
expect(result.entities[0].position.x).toBe(0)
|
|
533
|
+
expect(result.entities[0].position.y).toBe(0)
|
|
534
|
+
// ceil(sqrt(4))=2 cols
|
|
535
|
+
// entity 1: col=1, row=0
|
|
536
|
+
expect(result.entities[1].position.y).toBe(0)
|
|
537
|
+
expect(result.entities[1].position.x).toBeGreaterThan(0)
|
|
538
|
+
// entity 2: col=0, row=1
|
|
539
|
+
expect(result.entities[2].position.x).toBe(0)
|
|
540
|
+
expect(result.entities[2].position.y).toBeGreaterThan(0)
|
|
541
|
+
// entity 3: col=1, row=1
|
|
542
|
+
expect(result.entities[3].position.x).toBeGreaterThan(0)
|
|
543
|
+
expect(result.entities[3].position.y).toBeGreaterThan(0)
|
|
544
|
+
})
|
|
545
|
+
})
|