@sealab/mcp-server 1.0.2 → 1.0.3
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/client/api-client.js +8 -1
- package/dist/index.js +5 -2
- package/dist/schema-normalizer.js +74 -0
- package/dist/tools/ai-drawing-overlay.js +4 -0
- package/dist/tools/ai-drawing-session-review-renderer.js +964 -0
- package/dist/tools/ai-drawing.js +2080 -0
- package/dist/tools/orders.js +4 -4
- package/package.json +5 -3
- package/src/client/api-client.ts +15 -7
- package/src/index.ts +5 -2
- package/src/schema-normalizer.test.ts +107 -0
- package/src/schema-normalizer.ts +86 -0
- package/src/tools/ai-drawing-overlay.test.ts +9 -0
- package/src/tools/ai-drawing-overlay.ts +8 -0
- package/src/tools/ai-drawing-session-review-renderer.test.ts +516 -0
- package/src/tools/ai-drawing-session-review-renderer.ts +1297 -0
- package/src/tools/ai-drawing.test.ts +3061 -0
- package/src/tools/ai-drawing.ts +2445 -0
- package/src/tools/orders.ts +1 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
renderAiDrawingSessionReview,
|
|
8
|
+
type AiDrawingSessionReviewIssueCode,
|
|
9
|
+
} from './ai-drawing-session-review-renderer';
|
|
10
|
+
|
|
11
|
+
const sessionId = '11111111-1111-4111-8111-111111111111';
|
|
12
|
+
const cartItemId = '22222222-2222-4222-8222-222222222222';
|
|
13
|
+
|
|
14
|
+
const tempDirs: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const tempDir of tempDirs.splice(0)) {
|
|
18
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('AI drawing session review renderer', () => {
|
|
23
|
+
it('renders a simple local PNG background with one placement box and label', () => {
|
|
24
|
+
const tempDir = createTempDir();
|
|
25
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 120, 90);
|
|
26
|
+
|
|
27
|
+
const manifest = renderAiDrawingSessionReview(
|
|
28
|
+
baseState({
|
|
29
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
30
|
+
placements: [placement({
|
|
31
|
+
centerX: 60,
|
|
32
|
+
centerY: 45,
|
|
33
|
+
width: 40,
|
|
34
|
+
height: 20,
|
|
35
|
+
basisImageWidthPx: 120,
|
|
36
|
+
basisImageHeightPx: 90,
|
|
37
|
+
})],
|
|
38
|
+
}),
|
|
39
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const canvas = manifest.canvases[0];
|
|
43
|
+
expect(canvas).toMatchObject({
|
|
44
|
+
canvasType: 'plan',
|
|
45
|
+
imageWidth: 120,
|
|
46
|
+
imageHeight: 90,
|
|
47
|
+
renderedPlacementCount: 1,
|
|
48
|
+
sourceBoundaryReviewTasks: [
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
canvasType: 'plan',
|
|
51
|
+
positionName: 'BASE_01',
|
|
52
|
+
placementCategory: 'plan_footprint',
|
|
53
|
+
sourceImageAuthority: true,
|
|
54
|
+
checklist: expect.arrayContaining([
|
|
55
|
+
expect.stringContaining('full source plan image'),
|
|
56
|
+
expect.stringContaining('actual cabinet/appliance footprint linework'),
|
|
57
|
+
]),
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
issues: [
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
code: 'source_boundary_review_required',
|
|
63
|
+
severity: 'error',
|
|
64
|
+
canvasType: 'plan',
|
|
65
|
+
positionName: 'BASE_01',
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
expect(canvas.outputImagePath).toBeTruthy();
|
|
70
|
+
expect(fs.existsSync(canvas.outputImagePath!)).toBe(true);
|
|
71
|
+
expect(canvas).not.toHaveProperty('placementReviewCrops');
|
|
72
|
+
expect(fs.existsSync(path.join(tempDir, 'placement-review-crops'))).toBe(false);
|
|
73
|
+
|
|
74
|
+
const rendered = PNG.sync.read(fs.readFileSync(canvas.outputImagePath!));
|
|
75
|
+
expect(rendered.width).toBe(120);
|
|
76
|
+
expect(rendered.height).toBe(90);
|
|
77
|
+
expect(pixelAt(rendered, 40, 35)).toMatchObject({ r: expect.any(Number), g: expect.any(Number), b: expect.any(Number) });
|
|
78
|
+
expect(pixelAt(rendered, 40, 35)).not.toEqual(pixelAt(rendered, 10, 10));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reports placement outside image bounds while still rendering it', () => {
|
|
82
|
+
const tempDir = createTempDir();
|
|
83
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 100, 80);
|
|
84
|
+
|
|
85
|
+
const manifest = renderAiDrawingSessionReview(
|
|
86
|
+
baseState({
|
|
87
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
88
|
+
placements: [placement({ centerX: 95, centerY: 40, width: 30, height: 20 })],
|
|
89
|
+
}),
|
|
90
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(issueCodes(manifest)).toContain('placement_outside_image_bounds');
|
|
94
|
+
expect(manifest.canvases[0].renderedPlacementCount).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reports invalid dimensions and skips the invalid placement', () => {
|
|
98
|
+
const tempDir = createTempDir();
|
|
99
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 100, 80);
|
|
100
|
+
|
|
101
|
+
const manifest = renderAiDrawingSessionReview(
|
|
102
|
+
baseState({
|
|
103
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
104
|
+
placements: [placement({ width: 0 })],
|
|
105
|
+
}),
|
|
106
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(issueCodes(manifest)).toContain('invalid_placement_dimensions');
|
|
110
|
+
expect(manifest.canvases[0].renderedPlacementCount).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reports missing basis image dimensions but renders when geometry is otherwise valid', () => {
|
|
114
|
+
const tempDir = createTempDir();
|
|
115
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 100, 80);
|
|
116
|
+
|
|
117
|
+
const manifest = renderAiDrawingSessionReview(
|
|
118
|
+
baseState({
|
|
119
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
120
|
+
placements: [placement({ basisImageWidthPx: undefined, basisImageHeightPx: undefined })],
|
|
121
|
+
}),
|
|
122
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(issueCodes(manifest)).toContain('missing_or_invalid_basis_image_dimensions');
|
|
126
|
+
expect(manifest.canvases[0].renderedPlacementCount).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('reports placement without matching cart item', () => {
|
|
130
|
+
const tempDir = createTempDir();
|
|
131
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 100, 80);
|
|
132
|
+
|
|
133
|
+
const manifest = renderAiDrawingSessionReview(
|
|
134
|
+
baseState({
|
|
135
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
136
|
+
placements: [placement({ aiCartItemId: '33333333-3333-4333-8333-333333333333', positionName: 'UNKNOWN' })],
|
|
137
|
+
}),
|
|
138
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(issueCodes(manifest)).toContain('placement_without_matching_cart_item');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('reports cart item without placement', () => {
|
|
145
|
+
const tempDir = createTempDir();
|
|
146
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 100, 80);
|
|
147
|
+
|
|
148
|
+
const manifest = renderAiDrawingSessionReview(
|
|
149
|
+
baseState({
|
|
150
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
151
|
+
cartItems: [
|
|
152
|
+
cartItem(),
|
|
153
|
+
cartItem({
|
|
154
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
155
|
+
positionName: 'UNPLACED',
|
|
156
|
+
}),
|
|
157
|
+
],
|
|
158
|
+
placements: [placement()],
|
|
159
|
+
}),
|
|
160
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(issueCodes(manifest)).toContain('cart_item_without_placement');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('reports duplicate positionName on the same canvas', () => {
|
|
167
|
+
const tempDir = createTempDir();
|
|
168
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 120, 90);
|
|
169
|
+
|
|
170
|
+
const manifest = renderAiDrawingSessionReview(
|
|
171
|
+
baseState({
|
|
172
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
173
|
+
placements: [
|
|
174
|
+
placement({ centerX: 40, positionName: 'BASE_01' }),
|
|
175
|
+
placement({ centerX: 80, positionName: 'BASE_01' }),
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(issueCodes(manifest)).toContain('duplicate_position_name_on_canvas');
|
|
182
|
+
expect(manifest.canvases[0].renderedPlacementCount).toBe(2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reports overlapping placement rectangles on the same canvas', () => {
|
|
186
|
+
const tempDir = createTempDir();
|
|
187
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 120, 90);
|
|
188
|
+
|
|
189
|
+
const manifest = renderAiDrawingSessionReview(
|
|
190
|
+
baseState({
|
|
191
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
192
|
+
cartItems: [
|
|
193
|
+
cartItem({ id: cartItemId, positionName: 'BASE_01' }),
|
|
194
|
+
cartItem({ id: '33333333-3333-4333-8333-333333333333', positionName: 'BASE_02' }),
|
|
195
|
+
],
|
|
196
|
+
placements: [
|
|
197
|
+
placement({ centerX: 50, width: 40, positionName: 'BASE_01', aiCartItemId: cartItemId }),
|
|
198
|
+
placement({
|
|
199
|
+
centerX: 65,
|
|
200
|
+
width: 40,
|
|
201
|
+
positionName: 'BASE_02',
|
|
202
|
+
aiCartItemId: '33333333-3333-4333-8333-333333333333',
|
|
203
|
+
}),
|
|
204
|
+
],
|
|
205
|
+
}),
|
|
206
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(issueCodes(manifest)).toContain('placement_overlap');
|
|
210
|
+
expect(manifest.issues.find((issue) => issue.code === 'placement_overlap')).toMatchObject({
|
|
211
|
+
canvasType: 'plan',
|
|
212
|
+
positionName: 'BASE_01',
|
|
213
|
+
relatedPositionName: 'BASE_02',
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('does not classify base cabinets as plinths just because evidence mentions excluded plinths', () => {
|
|
218
|
+
const tempDir = createTempDir();
|
|
219
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'east.png', 120, 90);
|
|
220
|
+
|
|
221
|
+
const manifest = renderAiDrawingSessionReview(
|
|
222
|
+
baseState({
|
|
223
|
+
canvasImages: [backgroundImage('east', backgroundPath)],
|
|
224
|
+
cartItems: [
|
|
225
|
+
cartItem({
|
|
226
|
+
serialNumber: 'BC_1D1DR_L_1762',
|
|
227
|
+
positionName: 'CEB1',
|
|
228
|
+
displayName: 'Base cabinet with plinth excluded',
|
|
229
|
+
originalItemPayload: JSON.stringify({
|
|
230
|
+
__aiDrawingClassificationEvidence: {
|
|
231
|
+
sourceElevationObject: { objectType: 'base_cabinet' },
|
|
232
|
+
baseCabinetBodyHeightEvidence: {
|
|
233
|
+
plinthDecision: 'separate_plinth_item_created',
|
|
234
|
+
excludedElements: ['toekick_plinth', 'countertop'],
|
|
235
|
+
evidence: 'Plinth/toekick excluded from base cabinet body.',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
}),
|
|
240
|
+
],
|
|
241
|
+
placements: [placement({
|
|
242
|
+
canvasType: 'east',
|
|
243
|
+
positionName: 'CEB1',
|
|
244
|
+
})],
|
|
245
|
+
}),
|
|
246
|
+
{ outputDir: tempDir, canvases: ['east'] }
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(issueCodes(manifest)).not.toContain('placement_category_conflict');
|
|
250
|
+
expect(issueCodes(manifest)).toContain('source_boundary_review_required');
|
|
251
|
+
expect(manifest.canvases[0].sourceBoundaryReviewTasks).toEqual([
|
|
252
|
+
expect.objectContaining({
|
|
253
|
+
canvasType: 'east',
|
|
254
|
+
positionName: 'CEB1',
|
|
255
|
+
placementCategory: 'base_cabinet_body',
|
|
256
|
+
sourceImageAuthority: true,
|
|
257
|
+
checklist: expect.arrayContaining([
|
|
258
|
+
expect.stringContaining('Verify against the full source image'),
|
|
259
|
+
expect.stringContaining('visible transition from cabinet/front/carcass body to the separate toekick/plinth band'),
|
|
260
|
+
expect.stringContaining('exclude any visible countertop'),
|
|
261
|
+
]),
|
|
262
|
+
}),
|
|
263
|
+
]);
|
|
264
|
+
const rendered = PNG.sync.read(fs.readFileSync(manifest.canvases[0].outputImagePath!));
|
|
265
|
+
const topEdgePixel = pixelAt(rendered, 60, 34);
|
|
266
|
+
expect(topEdgePixel.b).toBeGreaterThan(topEdgePixel.r);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('requires source-image boundary review for UC_E_B base cabinet labels from drawings', () => {
|
|
270
|
+
const tempDir = createTempDir();
|
|
271
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'east.png', 120, 90);
|
|
272
|
+
|
|
273
|
+
const manifest = renderAiDrawingSessionReview(
|
|
274
|
+
baseState({
|
|
275
|
+
canvasImages: [backgroundImage('east', backgroundPath)],
|
|
276
|
+
cartItems: [
|
|
277
|
+
cartItem({
|
|
278
|
+
serialNumber: 'UC_E_B30S',
|
|
279
|
+
positionName: 'UC_E_B30S',
|
|
280
|
+
displayName: 'UC_E_B30S',
|
|
281
|
+
}),
|
|
282
|
+
],
|
|
283
|
+
placements: [placement({
|
|
284
|
+
canvasType: 'east',
|
|
285
|
+
positionName: 'UC_E_B30S',
|
|
286
|
+
})],
|
|
287
|
+
}),
|
|
288
|
+
{ outputDir: tempDir, canvases: ['east'] }
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
expect(manifest.canvases[0].sourceBoundaryReviewTasks).toEqual([
|
|
292
|
+
expect.objectContaining({
|
|
293
|
+
positionName: 'UC_E_B30S',
|
|
294
|
+
placementCategory: 'base_cabinet_body',
|
|
295
|
+
sourceImageAuthority: true,
|
|
296
|
+
}),
|
|
297
|
+
]);
|
|
298
|
+
expect(issueCodes(manifest)).toContain('source_boundary_review_required');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('classifies explicit plinth catalog placements as plinths', () => {
|
|
302
|
+
const tempDir = createTempDir();
|
|
303
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'east.png', 120, 90);
|
|
304
|
+
|
|
305
|
+
const manifest = renderAiDrawingSessionReview(
|
|
306
|
+
baseState({
|
|
307
|
+
canvasImages: [backgroundImage('east', backgroundPath)],
|
|
308
|
+
cartItems: [
|
|
309
|
+
cartItem({
|
|
310
|
+
serialNumber: 'LP_PL_1571',
|
|
311
|
+
positionName: 'CEPL',
|
|
312
|
+
displayName: 'Continuous plinth/toekick',
|
|
313
|
+
}),
|
|
314
|
+
],
|
|
315
|
+
placements: [placement({
|
|
316
|
+
canvasType: 'east',
|
|
317
|
+
positionName: 'CEPL',
|
|
318
|
+
})],
|
|
319
|
+
}),
|
|
320
|
+
{ outputDir: tempDir, canvases: ['east'] }
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
expect(manifest.canvases[0].sourceBoundaryReviewTasks).toEqual([
|
|
324
|
+
expect.objectContaining({
|
|
325
|
+
canvasType: 'east',
|
|
326
|
+
positionName: 'CEPL',
|
|
327
|
+
placementCategory: 'plinth_toekick',
|
|
328
|
+
sourceImageAuthority: true,
|
|
329
|
+
checklist: expect.arrayContaining([
|
|
330
|
+
expect.stringContaining('not against the blue base cabinet bbox'),
|
|
331
|
+
expect.stringContaining('actual visible toekick/plinth band'),
|
|
332
|
+
expect.stringContaining('must not extend below the visible floor/Z0 line'),
|
|
333
|
+
]),
|
|
334
|
+
}),
|
|
335
|
+
]);
|
|
336
|
+
expect(issueCodes(manifest)).toContain('source_boundary_review_required');
|
|
337
|
+
const rendered = PNG.sync.read(fs.readFileSync(manifest.canvases[0].outputImagePath!));
|
|
338
|
+
const topEdgePixel = pixelAt(rendered, 60, 34);
|
|
339
|
+
expect(topEdgePixel.r).toBeGreaterThan(topEdgePixel.b);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('treats canvases with cart items but no placements as incomplete', () => {
|
|
343
|
+
const tempDir = createTempDir();
|
|
344
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'east.png', 120, 90);
|
|
345
|
+
|
|
346
|
+
const manifest = renderAiDrawingSessionReview(
|
|
347
|
+
baseState({
|
|
348
|
+
canvasImages: [backgroundImage('east', backgroundPath)],
|
|
349
|
+
placements: [],
|
|
350
|
+
cartItems: [cartItem({ positionName: 'CEB1' })],
|
|
351
|
+
}),
|
|
352
|
+
{ outputDir: tempDir, canvases: ['east'] }
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const issue = manifest.issues.find((candidate) => candidate.code === 'canvas_with_no_placements');
|
|
356
|
+
expect(issue).toMatchObject({
|
|
357
|
+
severity: 'error',
|
|
358
|
+
canvasType: 'east',
|
|
359
|
+
});
|
|
360
|
+
expect(issue?.message).toContain('not complete');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('reports unsupported non-local image references without crashing', () => {
|
|
364
|
+
const tempDir = createTempDir();
|
|
365
|
+
|
|
366
|
+
const manifest = renderAiDrawingSessionReview(
|
|
367
|
+
baseState({
|
|
368
|
+
canvasImages: [backgroundImage('north', 'https://example.test/north.png')],
|
|
369
|
+
placements: [placement({ canvasType: 'north' })],
|
|
370
|
+
}),
|
|
371
|
+
{ outputDir: tempDir, canvases: ['north'] }
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(issueCodes(manifest)).toContain('unsupported_background_image_reference');
|
|
375
|
+
expect(manifest.canvases[0].outputImagePath).toBeUndefined();
|
|
376
|
+
expect(manifest.canvases[0].renderedPlacementCount).toBe(0);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('preserves IMAGE_PIXELS center and width/height rectangle conversion', () => {
|
|
380
|
+
const tempDir = createTempDir();
|
|
381
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 100, 80);
|
|
382
|
+
|
|
383
|
+
const manifest = renderAiDrawingSessionReview(
|
|
384
|
+
baseState({
|
|
385
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
386
|
+
placements: [placement({ centerX: 50, centerY: 40, width: 20, height: 10 })],
|
|
387
|
+
}),
|
|
388
|
+
{ outputDir: tempDir, canvases: ['plan'] }
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const rendered = PNG.sync.read(fs.readFileSync(manifest.canvases[0].outputImagePath!));
|
|
392
|
+
const leftEdgePixel = pixelAt(rendered, 40, 40);
|
|
393
|
+
const topEdgePixel = pixelAt(rendered, 50, 35);
|
|
394
|
+
const outsidePixel = pixelAt(rendered, 35, 30);
|
|
395
|
+
|
|
396
|
+
expect(leftEdgePixel).not.toEqual(outsidePixel);
|
|
397
|
+
expect(topEdgePixel).not.toEqual(outsidePixel);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('writes a JSON manifest when requested', () => {
|
|
401
|
+
const tempDir = createTempDir();
|
|
402
|
+
const backgroundPath = writeBackgroundPng(tempDir, 'plan.png', 100, 80);
|
|
403
|
+
|
|
404
|
+
const manifest = renderAiDrawingSessionReview(
|
|
405
|
+
baseState({
|
|
406
|
+
canvasImages: [backgroundImage('plan', backgroundPath)],
|
|
407
|
+
placements: [placement()],
|
|
408
|
+
}),
|
|
409
|
+
{ outputDir: tempDir, canvases: ['plan'], writeJsonManifest: true }
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
expect(manifest.manifestPath).toBeTruthy();
|
|
413
|
+
expect(fs.existsSync(manifest.manifestPath!)).toBe(true);
|
|
414
|
+
const parsed = JSON.parse(fs.readFileSync(manifest.manifestPath!, 'utf8'));
|
|
415
|
+
expect(parsed.canvases[0]).toMatchObject({
|
|
416
|
+
canvasType: 'plan',
|
|
417
|
+
renderedPlacementCount: 1,
|
|
418
|
+
sourceBoundaryReviewTasks: [
|
|
419
|
+
expect.objectContaining({
|
|
420
|
+
placementCategory: 'plan_footprint',
|
|
421
|
+
sourceImageAuthority: true,
|
|
422
|
+
}),
|
|
423
|
+
],
|
|
424
|
+
});
|
|
425
|
+
expect(parsed.canvases[0].outputImagePath).toBe(manifest.canvases[0].outputImagePath);
|
|
426
|
+
expect(parsed.visualReviewStatus).toBe('requires_native_vision');
|
|
427
|
+
expect(parsed.visualInspectionRequired).toBe(true);
|
|
428
|
+
expect(parsed.completionBlockedUntil).toContain('source_boundary_review_required');
|
|
429
|
+
expect(parsed.canvases[0]).not.toHaveProperty('placementReviewCrops');
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
function baseState(input: {
|
|
434
|
+
canvasImages?: unknown[];
|
|
435
|
+
placements?: unknown[];
|
|
436
|
+
cartItems?: unknown[];
|
|
437
|
+
}) {
|
|
438
|
+
return {
|
|
439
|
+
session: { id: sessionId },
|
|
440
|
+
activeCart: {
|
|
441
|
+
id: '44444444-4444-4444-8444-444444444444',
|
|
442
|
+
items: input.cartItems ?? [cartItem()],
|
|
443
|
+
},
|
|
444
|
+
activeCanvases: ['plan', 'north'],
|
|
445
|
+
canvasImages: input.canvasImages ?? [],
|
|
446
|
+
canvasCabinetPlacements: input.placements ?? [],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function cartItem(overrides: Record<string, unknown> = {}) {
|
|
451
|
+
return {
|
|
452
|
+
id: cartItemId,
|
|
453
|
+
positionName: 'BASE_01',
|
|
454
|
+
serialNumber: 'B30',
|
|
455
|
+
...overrides,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function placement(overrides: Record<string, unknown> = {}) {
|
|
460
|
+
return {
|
|
461
|
+
id: 'placement-1',
|
|
462
|
+
aiCartItemId: cartItemId,
|
|
463
|
+
canvasType: 'plan',
|
|
464
|
+
positionName: 'BASE_01',
|
|
465
|
+
centerX: 50,
|
|
466
|
+
centerY: 40,
|
|
467
|
+
width: 30,
|
|
468
|
+
height: 20,
|
|
469
|
+
rotation: 0,
|
|
470
|
+
placementSpace: 'IMAGE_PIXELS',
|
|
471
|
+
basisImageWidthPx: 100,
|
|
472
|
+
basisImageHeightPx: 80,
|
|
473
|
+
...overrides,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function backgroundImage(canvasType: string, s3Key: string) {
|
|
478
|
+
return {
|
|
479
|
+
canvasType,
|
|
480
|
+
imageType: 'background',
|
|
481
|
+
s3Key,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function createTempDir(): string {
|
|
486
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-session-review-'));
|
|
487
|
+
tempDirs.push(tempDir);
|
|
488
|
+
return tempDir;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function writeBackgroundPng(tempDir: string, fileName: string, width: number, height: number): string {
|
|
492
|
+
const png = new PNG({ width, height });
|
|
493
|
+
for (let index = 0; index < png.data.length; index += 4) {
|
|
494
|
+
png.data[index] = 242;
|
|
495
|
+
png.data[index + 1] = 244;
|
|
496
|
+
png.data[index + 2] = 247;
|
|
497
|
+
png.data[index + 3] = 255;
|
|
498
|
+
}
|
|
499
|
+
const imagePath = path.join(tempDir, fileName);
|
|
500
|
+
fs.writeFileSync(imagePath, PNG.sync.write(png));
|
|
501
|
+
return imagePath;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function pixelAt(png: PNG, x: number, y: number): { r: number; g: number; b: number; a: number } {
|
|
505
|
+
const offset = (y * png.width + x) * 4;
|
|
506
|
+
return {
|
|
507
|
+
r: png.data[offset],
|
|
508
|
+
g: png.data[offset + 1],
|
|
509
|
+
b: png.data[offset + 2],
|
|
510
|
+
a: png.data[offset + 3],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function issueCodes(manifest: { issues: Array<{ code: AiDrawingSessionReviewIssueCode }> }): AiDrawingSessionReviewIssueCode[] {
|
|
515
|
+
return manifest.issues.map((issue) => issue.code);
|
|
516
|
+
}
|