@sealab/mcp-server 1.0.3 → 1.0.4
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/index.js +2 -1
- package/dist/tools/ai-drawing.js +563 -1
- package/dist/tools/cart-cross-check.js +182 -0
- package/dist/tools/elevation-run-segmentation.js +99 -0
- package/dist/tools/orders.js +2 -0
- package/dist/tools/placement-projection.js +119 -0
- package/dist/tools/projects.js +368 -0
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/tools/ai-drawing.test.ts +464 -44
- package/src/tools/ai-drawing.ts +750 -36
- package/src/tools/cart-cross-check.test.ts +84 -0
- package/src/tools/cart-cross-check.ts +267 -0
- package/src/tools/elevation-run-segmentation.test.ts +64 -0
- package/src/tools/elevation-run-segmentation.ts +175 -0
- package/src/tools/orders.ts +7 -3
- package/src/tools/placement-projection.test.ts +185 -0
- package/src/tools/placement-projection.ts +247 -0
- package/src/tools/projects.test.ts +135 -0
- package/src/tools/projects.ts +370 -0
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
AiCanvasCabinetPlacementsPatchInputSchema,
|
|
35
35
|
AiCanvasCabinetPlacementsReplaceInputSchema,
|
|
36
36
|
AiCanvasCabinetPlacementsUpsertInputSchema,
|
|
37
|
+
ProjectAiCanvasCabinetPlacementsInputSchema,
|
|
38
|
+
ProjectAiCanvasElevationRunInputSchema,
|
|
37
39
|
AiCartIdParamSchema,
|
|
38
40
|
AiCartCreateInputSchema,
|
|
39
41
|
AiCartItemIdParamSchema,
|
|
@@ -72,6 +74,8 @@ import {
|
|
|
72
74
|
upsertAiCanvasCabinetPlacements,
|
|
73
75
|
patchAiCanvasCabinetPlacements,
|
|
74
76
|
deleteAiCanvasCabinetPlacements,
|
|
77
|
+
projectAiCanvasCabinetPlacements,
|
|
78
|
+
projectAiCanvasElevationRun,
|
|
75
79
|
replaceAiCabinetCoordinates,
|
|
76
80
|
patchAiCabinetCoordinates,
|
|
77
81
|
deleteAiCabinetCoordinates,
|
|
@@ -135,26 +139,26 @@ describe('renderAiDrawingSessionReviewTool', () => {
|
|
|
135
139
|
vi.mocked(rendererModule.renderAiDrawingSessionReview).mockReturnValue({
|
|
136
140
|
sessionId,
|
|
137
141
|
generatedAt: '2026-05-07T00:00:00.000Z',
|
|
138
|
-
visualInspectionRequired: true,
|
|
139
|
-
visualReviewStatus: 'requires_native_vision',
|
|
140
|
-
completionBlockedUntil: 'Open every full-canvas outputImagePath with native vision, record PASS/FAIL/UNCERTAIN per canvas and placement, patch failures, and rerender.',
|
|
141
|
-
visualInspectionChecklist: [
|
|
142
|
-
'Open every full-canvas overlay with native vision.',
|
|
143
|
-
'Separate cabinet boxes must not overlap each other.',
|
|
144
|
-
'Do not include appliances, fixtures, counters, toe kicks, walls, dimension strings, labels, or blank/context areas.',
|
|
145
|
-
],
|
|
142
|
+
visualInspectionRequired: true,
|
|
143
|
+
visualReviewStatus: 'requires_native_vision',
|
|
144
|
+
completionBlockedUntil: 'Open every full-canvas outputImagePath with native vision, record PASS/FAIL/UNCERTAIN per canvas and placement, patch failures, and rerender.',
|
|
145
|
+
visualInspectionChecklist: [
|
|
146
|
+
'Open every full-canvas overlay with native vision.',
|
|
147
|
+
'Separate cabinet boxes must not overlap each other.',
|
|
148
|
+
'Do not include appliances, fixtures, counters, toe kicks, walls, dimension strings, labels, or blank/context areas.',
|
|
149
|
+
],
|
|
146
150
|
manifestPath: 'D:\\review\\manifest.json',
|
|
147
151
|
canvases: [
|
|
148
152
|
{
|
|
149
153
|
canvasType: 'plan',
|
|
150
154
|
outputImagePath: 'D:\\review\\plan.png',
|
|
151
|
-
imageWidth: 100,
|
|
152
|
-
imageHeight: 80,
|
|
153
|
-
renderedPlacementCount: 1,
|
|
154
|
-
sourceBoundaryReviewTasks: [],
|
|
155
|
-
issues: [],
|
|
156
|
-
},
|
|
157
|
-
],
|
|
155
|
+
imageWidth: 100,
|
|
156
|
+
imageHeight: 80,
|
|
157
|
+
renderedPlacementCount: 1,
|
|
158
|
+
sourceBoundaryReviewTasks: [],
|
|
159
|
+
issues: [],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
158
162
|
issues: [],
|
|
159
163
|
});
|
|
160
164
|
|
|
@@ -178,30 +182,30 @@ describe('renderAiDrawingSessionReviewTool', () => {
|
|
|
178
182
|
manifestPath: 'D:\\review\\manifest.json',
|
|
179
183
|
aggregateIssueCount: 0,
|
|
180
184
|
visualReviewRequired: true,
|
|
181
|
-
visualReviewStatus: 'requires_native_vision',
|
|
182
|
-
completionBlockedUntil: expect.stringContaining('PASS/FAIL/UNCERTAIN'),
|
|
183
|
-
machineValidationScope: expect.stringContaining('aggregateIssueCount 0 is not visual approval'),
|
|
184
|
-
completionPolicy: expect.stringContaining('source_boundary_review_required'),
|
|
185
|
-
visualQaChecklist: expect.arrayContaining([
|
|
186
|
-
expect.stringContaining('Separate cabinet boxes must not overlap each other'),
|
|
187
|
-
expect.stringContaining('Do not include appliances, fixtures, counters, toe kicks, walls, dimension strings, labels, or blank/context areas'),
|
|
185
|
+
visualReviewStatus: 'requires_native_vision',
|
|
186
|
+
completionBlockedUntil: expect.stringContaining('PASS/FAIL/UNCERTAIN'),
|
|
187
|
+
machineValidationScope: expect.stringContaining('aggregateIssueCount 0 is not visual approval'),
|
|
188
|
+
completionPolicy: expect.stringContaining('source_boundary_review_required'),
|
|
189
|
+
visualQaChecklist: expect.arrayContaining([
|
|
190
|
+
expect.stringContaining('Separate cabinet boxes must not overlap each other'),
|
|
191
|
+
expect.stringContaining('Do not include appliances, fixtures, counters, toe kicks, walls, dimension strings, labels, or blank/context areas'),
|
|
188
192
|
]),
|
|
189
193
|
reviewImages: [
|
|
190
194
|
{
|
|
191
195
|
canvasType: 'plan',
|
|
192
|
-
outputImagePath: 'D:\\review\\plan.png',
|
|
193
|
-
imageWidth: 100,
|
|
194
|
-
imageHeight: 80,
|
|
195
|
-
renderedPlacementCount: 1,
|
|
196
|
-
sourceBoundaryReviewTaskCount: 0,
|
|
197
|
-
issueCount: 0,
|
|
198
|
-
},
|
|
199
|
-
],
|
|
200
|
-
sourceBoundaryReviewTasks: [],
|
|
201
|
-
});
|
|
202
|
-
expect(parsed).not.toHaveProperty('placementReviewCrops');
|
|
203
|
-
expect(parsed.canvases[0]).not.toHaveProperty('placementReviewCrops');
|
|
204
|
-
expect(parsed.canvases[0].sourceBoundaryReviewTasks).toEqual([]);
|
|
196
|
+
outputImagePath: 'D:\\review\\plan.png',
|
|
197
|
+
imageWidth: 100,
|
|
198
|
+
imageHeight: 80,
|
|
199
|
+
renderedPlacementCount: 1,
|
|
200
|
+
sourceBoundaryReviewTaskCount: 0,
|
|
201
|
+
issueCount: 0,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
sourceBoundaryReviewTasks: [],
|
|
205
|
+
});
|
|
206
|
+
expect(parsed).not.toHaveProperty('placementReviewCrops');
|
|
207
|
+
expect(parsed.canvases[0]).not.toHaveProperty('placementReviewCrops');
|
|
208
|
+
expect(parsed.canvases[0].sourceBoundaryReviewTasks).toEqual([]);
|
|
205
209
|
});
|
|
206
210
|
|
|
207
211
|
it('materializes uploaded session s3Key backgrounds before rendering', async () => {
|
|
@@ -339,6 +343,8 @@ describe('aiDrawingTools', () => {
|
|
|
339
343
|
'upsert_ai_canvas_cabinet_placements',
|
|
340
344
|
'patch_ai_canvas_cabinet_placements',
|
|
341
345
|
'delete_ai_canvas_cabinet_placements',
|
|
346
|
+
'project_ai_canvas_cabinet_placements',
|
|
347
|
+
'project_ai_canvas_elevation_run',
|
|
342
348
|
'replace_ai_cabinet_coordinates',
|
|
343
349
|
'patch_ai_cabinet_coordinates',
|
|
344
350
|
'delete_ai_cabinet_coordinates',
|
|
@@ -432,12 +438,12 @@ describe('aiDrawingTools', () => {
|
|
|
432
438
|
it('describes the review renderer workflow as render, vision inspect, correct, and rerender', () => {
|
|
433
439
|
const tool = aiDrawingTools.find((candidate) => candidate.name === 'render_ai_drawing_session_review');
|
|
434
440
|
|
|
435
|
-
expect(tool?.description).toContain('after writing or updating AI cart items and per-canvas placements');
|
|
436
|
-
expect(tool?.description).toContain('open every returned full-canvas PNG path with native vision');
|
|
437
|
-
expect(tool?.description).toContain('complete every returned sourceBoundaryReviewTasks[] item against the actual source drawing pixels');
|
|
438
|
-
expect(tool?.description).toContain('no per-placement crop artifacts are generated');
|
|
441
|
+
expect(tool?.description).toContain('after writing or updating AI cart items and per-canvas placements');
|
|
442
|
+
expect(tool?.description).toContain('open every returned full-canvas PNG path with native vision');
|
|
443
|
+
expect(tool?.description).toContain('complete every returned sourceBoundaryReviewTasks[] item against the actual source drawing pixels');
|
|
444
|
+
expect(tool?.description).toContain('no per-placement crop artifacts are generated');
|
|
439
445
|
expect(tool?.description).toContain('apply the visual QA checklist');
|
|
440
|
-
expect(tool?.description).toContain('Correct bad placements through the existing AI-only placement tools');
|
|
446
|
+
expect(tool?.description).toContain('Correct bad placements through the existing AI-only placement tools');
|
|
441
447
|
expect(tool?.description).toContain('render again/rerender');
|
|
442
448
|
expect(tool?.description).toContain('JSON validity');
|
|
443
449
|
expect(tool?.description).toContain('not visual correctness');
|
|
@@ -461,8 +467,8 @@ describe('aiDrawingTools', () => {
|
|
|
461
467
|
it('describes concrete visual QA failure modes for rendered placement review', () => {
|
|
462
468
|
const tool = aiDrawingTools.find((candidate) => candidate.name === 'render_ai_drawing_session_review');
|
|
463
469
|
|
|
464
|
-
expect(tool?.description).toContain('compare each rectangle against the visible drawing');
|
|
465
|
-
expect(tool?.description).toContain('source drawing pixels are the authority');
|
|
470
|
+
expect(tool?.description).toContain('compare each rectangle against the visible drawing');
|
|
471
|
+
expect(tool?.description).toContain('source drawing pixels are the authority');
|
|
466
472
|
expect(tool?.description).toContain('inside the actual cabinet face or plan footprint boundaries');
|
|
467
473
|
expect(tool?.description).toContain('overlap other cabinets, appliances, fixtures, counters, toe kicks, walls, dimension strings, or blank/context areas');
|
|
468
474
|
expect(tool?.description).toContain('shifted boxes that extend beyond wood/front boundaries');
|
|
@@ -879,6 +885,420 @@ describe('AI drawing operation tool handlers', () => {
|
|
|
879
885
|
expect(result).toContain('BASE_01');
|
|
880
886
|
});
|
|
881
887
|
|
|
888
|
+
it('project_ai_canvas_cabinet_placements computes elevation rectangles from calibration and upserts them', async () => {
|
|
889
|
+
vi.mocked(apiClientModule.aiDrawingClient.post).mockResolvedValue({
|
|
890
|
+
data: { canvasType: 'north', placements: [{ positionName: 'BASE_01' }] },
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const result = await projectAiCanvasCabinetPlacements({
|
|
894
|
+
sessionId,
|
|
895
|
+
canvasType: 'north',
|
|
896
|
+
elevationCalibration: {
|
|
897
|
+
canvasType: 'north',
|
|
898
|
+
imageSizePx: { width: 1000, height: 600 },
|
|
899
|
+
horizontalPixelsPerInch: 5,
|
|
900
|
+
verticalPixelsPerInch: 5,
|
|
901
|
+
floorZ0Px: 550,
|
|
902
|
+
horizontalOriginPx: { x: 100, y: 550 },
|
|
903
|
+
horizontalOriginCoordinateInches: 0,
|
|
904
|
+
horizontalAxisPositiveDirection: 'right',
|
|
905
|
+
verticalAxisPositiveDirection: 'up',
|
|
906
|
+
},
|
|
907
|
+
placements: [
|
|
908
|
+
{ positionName: 'BASE_01', horizontalStartInches: 12, widthInches: 36, heightInches: 30, bottomFromFloorInches: 0 },
|
|
909
|
+
],
|
|
910
|
+
writeMode: 'upsert',
|
|
911
|
+
crossCheckCartItems: false,
|
|
912
|
+
source: 'MCP',
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
expect(apiClientModule.aiDrawingClient.post).toHaveBeenCalledWith(`/sessions/${sessionId}/placements/north`, {
|
|
916
|
+
placements: [
|
|
917
|
+
{
|
|
918
|
+
positionName: 'BASE_01',
|
|
919
|
+
centerX: 250,
|
|
920
|
+
centerY: 475,
|
|
921
|
+
width: 180,
|
|
922
|
+
height: 150,
|
|
923
|
+
rotation: 0,
|
|
924
|
+
placementSpace: 'IMAGE_PIXELS',
|
|
925
|
+
basisImageWidthPx: 1000,
|
|
926
|
+
basisImageHeightPx: 600,
|
|
927
|
+
source: 'MCP',
|
|
928
|
+
},
|
|
929
|
+
],
|
|
930
|
+
});
|
|
931
|
+
expect(result).toContain('"wrote": true');
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('project_ai_canvas_cabinet_placements return_only does a dry run without writing', async () => {
|
|
935
|
+
const result = await projectAiCanvasCabinetPlacements({
|
|
936
|
+
sessionId,
|
|
937
|
+
canvasType: 'north',
|
|
938
|
+
elevationCalibration: {
|
|
939
|
+
canvasType: 'north',
|
|
940
|
+
imageSizePx: { width: 1000, height: 600 },
|
|
941
|
+
horizontalPixelsPerInch: 5,
|
|
942
|
+
verticalPixelsPerInch: 5,
|
|
943
|
+
floorZ0Px: 550,
|
|
944
|
+
horizontalOriginPx: { x: 100, y: 550 },
|
|
945
|
+
horizontalOriginCoordinateInches: 0,
|
|
946
|
+
horizontalAxisPositiveDirection: 'right',
|
|
947
|
+
verticalAxisPositiveDirection: 'up',
|
|
948
|
+
},
|
|
949
|
+
placements: [
|
|
950
|
+
{ positionName: 'BASE_01', horizontalStartInches: 12, widthInches: 36, heightInches: 30, bottomFromFloorInches: 0 },
|
|
951
|
+
],
|
|
952
|
+
writeMode: 'return_only',
|
|
953
|
+
crossCheckCartItems: false,
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
expect(apiClientModule.aiDrawingClient.post).not.toHaveBeenCalled();
|
|
957
|
+
expect(apiClientModule.aiDrawingClient.put).not.toHaveBeenCalled();
|
|
958
|
+
expect(result).toContain('"wrote": false');
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('project_ai_canvas_cabinet_placements reports out-of-bounds rectangles as warnings', async () => {
|
|
962
|
+
const result = await projectAiCanvasCabinetPlacements({
|
|
963
|
+
sessionId,
|
|
964
|
+
canvasType: 'north',
|
|
965
|
+
elevationCalibration: {
|
|
966
|
+
canvasType: 'north',
|
|
967
|
+
imageSizePx: { width: 200, height: 200 },
|
|
968
|
+
horizontalPixelsPerInch: 5,
|
|
969
|
+
verticalPixelsPerInch: 5,
|
|
970
|
+
floorZ0Px: 190,
|
|
971
|
+
horizontalOriginPx: { x: 100, y: 190 },
|
|
972
|
+
horizontalOriginCoordinateInches: 0,
|
|
973
|
+
horizontalAxisPositiveDirection: 'right',
|
|
974
|
+
verticalAxisPositiveDirection: 'up',
|
|
975
|
+
},
|
|
976
|
+
// 60" wide cabinet at 5 ppi = 300px, far wider than the 200px image.
|
|
977
|
+
placements: [
|
|
978
|
+
{ positionName: 'BASE_01', horizontalStartInches: 0, widthInches: 60, heightInches: 30 },
|
|
979
|
+
],
|
|
980
|
+
writeMode: 'return_only',
|
|
981
|
+
crossCheckCartItems: false,
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
expect(result).toContain('outOfBoundsWarnings');
|
|
985
|
+
expect(result).toContain('extends outside');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('project schema requires elevationCalibration for an elevation canvas', () => {
|
|
989
|
+
const parsed = ProjectAiCanvasCabinetPlacementsInputSchema.safeParse({
|
|
990
|
+
sessionId,
|
|
991
|
+
canvasType: 'north',
|
|
992
|
+
placements: [
|
|
993
|
+
{ positionName: 'BASE_01', horizontalStartInches: 0, widthInches: 36, heightInches: 30 },
|
|
994
|
+
],
|
|
995
|
+
});
|
|
996
|
+
expect(parsed.success).toBe(false);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it('project schema requires plan fields for a plan canvas', () => {
|
|
1000
|
+
const parsed = ProjectAiCanvasCabinetPlacementsInputSchema.safeParse({
|
|
1001
|
+
sessionId,
|
|
1002
|
+
canvasType: 'plan',
|
|
1003
|
+
planCalibration: {
|
|
1004
|
+
canvasType: 'plan',
|
|
1005
|
+
imageSizePx: { width: 800, height: 800 },
|
|
1006
|
+
pixelsPerInch: 2,
|
|
1007
|
+
roomOriginPx: { x: 50, y: 750 },
|
|
1008
|
+
xPositiveDirection: 'right',
|
|
1009
|
+
yPositiveDirection: 'up',
|
|
1010
|
+
},
|
|
1011
|
+
// Missing centerXInches/centerYInches/depthInches.
|
|
1012
|
+
placements: [{ positionName: 'BASE_01', widthInches: 30 }],
|
|
1013
|
+
});
|
|
1014
|
+
expect(parsed.success).toBe(false);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it('project_ai_canvas_cabinet_placements wall-anchored plan pins the back edge to the wall face', async () => {
|
|
1018
|
+
const result = await projectAiCanvasCabinetPlacements({
|
|
1019
|
+
sessionId,
|
|
1020
|
+
canvasType: 'plan',
|
|
1021
|
+
planCalibration: {
|
|
1022
|
+
canvasType: 'plan',
|
|
1023
|
+
imageSizePx: { width: 800, height: 800 },
|
|
1024
|
+
pixelsPerInch: 2,
|
|
1025
|
+
roomOriginPx: { x: 50, y: 750 },
|
|
1026
|
+
xPositiveDirection: 'right',
|
|
1027
|
+
yPositiveDirection: 'up',
|
|
1028
|
+
},
|
|
1029
|
+
placements: [
|
|
1030
|
+
{
|
|
1031
|
+
positionName: 'WALL1',
|
|
1032
|
+
widthInches: 36,
|
|
1033
|
+
depthInches: 24,
|
|
1034
|
+
wallBackEdgeInches: 120,
|
|
1035
|
+
depthAxis: 'y',
|
|
1036
|
+
depthDirection: 'negative',
|
|
1037
|
+
alongWallStartInches: 30,
|
|
1038
|
+
},
|
|
1039
|
+
],
|
|
1040
|
+
writeMode: 'return_only',
|
|
1041
|
+
crossCheckCartItems: false,
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const parsed = JSON.parse(result);
|
|
1045
|
+
const box = parsed.projectedPlacements[0];
|
|
1046
|
+
// Back edge (room y=120) -> pixel y = 750 - 240 = 510; box top edge must equal it.
|
|
1047
|
+
expect(box.centerY - box.height / 2).toBe(510);
|
|
1048
|
+
expect(box.width).toBe(72); // 36" along wall * 2
|
|
1049
|
+
expect(box.height).toBe(48); // 24" depth * 2
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it('project schema rejects mixing wall-anchored and center plan fields', () => {
|
|
1053
|
+
const parsed = ProjectAiCanvasCabinetPlacementsInputSchema.safeParse({
|
|
1054
|
+
sessionId,
|
|
1055
|
+
canvasType: 'plan',
|
|
1056
|
+
planCalibration: {
|
|
1057
|
+
canvasType: 'plan',
|
|
1058
|
+
imageSizePx: { width: 800, height: 800 },
|
|
1059
|
+
pixelsPerInch: 2,
|
|
1060
|
+
roomOriginPx: { x: 50, y: 750 },
|
|
1061
|
+
xPositiveDirection: 'right',
|
|
1062
|
+
yPositiveDirection: 'up',
|
|
1063
|
+
},
|
|
1064
|
+
placements: [
|
|
1065
|
+
{
|
|
1066
|
+
positionName: 'WALL1',
|
|
1067
|
+
widthInches: 36,
|
|
1068
|
+
depthInches: 24,
|
|
1069
|
+
wallBackEdgeInches: 120,
|
|
1070
|
+
depthAxis: 'y',
|
|
1071
|
+
depthDirection: 'negative',
|
|
1072
|
+
alongWallStartInches: 30,
|
|
1073
|
+
centerXInches: 48, // conflict
|
|
1074
|
+
},
|
|
1075
|
+
],
|
|
1076
|
+
});
|
|
1077
|
+
expect(parsed.success).toBe(false);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it('project_ai_canvas_elevation_run splits a run into one placement per cabinet', async () => {
|
|
1081
|
+
vi.mocked(apiClientModule.aiDrawingClient.post).mockResolvedValue({
|
|
1082
|
+
data: { canvasType: 'north', placements: [] },
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
const result = await projectAiCanvasElevationRun({
|
|
1086
|
+
sessionId,
|
|
1087
|
+
canvasType: 'north',
|
|
1088
|
+
elevationCalibration: {
|
|
1089
|
+
canvasType: 'north',
|
|
1090
|
+
imageSizePx: { width: 2000, height: 1000 },
|
|
1091
|
+
horizontalPixelsPerInch: 5,
|
|
1092
|
+
verticalPixelsPerInch: 5,
|
|
1093
|
+
floorZ0Px: 900,
|
|
1094
|
+
horizontalOriginPx: { x: 100, y: 900 },
|
|
1095
|
+
horizontalOriginCoordinateInches: 0,
|
|
1096
|
+
horizontalAxisPositiveDirection: 'right',
|
|
1097
|
+
verticalAxisPositiveDirection: 'up',
|
|
1098
|
+
},
|
|
1099
|
+
runs: [
|
|
1100
|
+
{
|
|
1101
|
+
runStartInches: 0,
|
|
1102
|
+
expectedRunTotalInches: 36,
|
|
1103
|
+
declaredSubdivisionCount: 2,
|
|
1104
|
+
modules: [
|
|
1105
|
+
{ positionName: 'U2A', widthInches: 18, heightInches: 42, bottomFromFloorInches: 54, frontType: 'double_door' },
|
|
1106
|
+
{ positionName: 'U2B', widthInches: 18, heightInches: 42, bottomFromFloorInches: 54, frontType: 'double_door' },
|
|
1107
|
+
],
|
|
1108
|
+
},
|
|
1109
|
+
],
|
|
1110
|
+
writeMode: 'upsert',
|
|
1111
|
+
crossCheckCartItems: false,
|
|
1112
|
+
source: 'MCP',
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
const [, body] = vi.mocked(apiClientModule.aiDrawingClient.post).mock.calls[0];
|
|
1116
|
+
expect(body.placements).toHaveLength(2);
|
|
1117
|
+
expect(body.placements[0].positionName).toBe('U2A');
|
|
1118
|
+
expect(body.placements[1].positionName).toBe('U2B');
|
|
1119
|
+
// Two distinct boxes, side by side, not one lumped box.
|
|
1120
|
+
expect(body.placements[0].centerX).not.toBe(body.placements[1].centerX);
|
|
1121
|
+
expect(result).toContain('"moduleCount": 2');
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it('project_ai_canvas_elevation_run warns when a lumped run looks like multiple cabinets', async () => {
|
|
1125
|
+
const result = await projectAiCanvasElevationRun({
|
|
1126
|
+
sessionId,
|
|
1127
|
+
canvasType: 'north',
|
|
1128
|
+
elevationCalibration: {
|
|
1129
|
+
canvasType: 'north',
|
|
1130
|
+
imageSizePx: { width: 2000, height: 1000 },
|
|
1131
|
+
horizontalPixelsPerInch: 5,
|
|
1132
|
+
verticalPixelsPerInch: 5,
|
|
1133
|
+
floorZ0Px: 900,
|
|
1134
|
+
horizontalOriginPx: { x: 100, y: 900 },
|
|
1135
|
+
horizontalOriginCoordinateInches: 0,
|
|
1136
|
+
horizontalAxisPositiveDirection: 'right',
|
|
1137
|
+
verticalAxisPositiveDirection: 'up',
|
|
1138
|
+
},
|
|
1139
|
+
runs: [
|
|
1140
|
+
{
|
|
1141
|
+
runStartInches: 0,
|
|
1142
|
+
expectedRunTotalInches: 36,
|
|
1143
|
+
declaredSubdivisionCount: 2,
|
|
1144
|
+
maxSingleCabinetWidthInches: 24,
|
|
1145
|
+
modules: [
|
|
1146
|
+
{ positionName: 'U2A', widthInches: 36, heightInches: 42, bottomFromFloorInches: 54, frontType: 'double_door' },
|
|
1147
|
+
],
|
|
1148
|
+
},
|
|
1149
|
+
],
|
|
1150
|
+
writeMode: 'return_only',
|
|
1151
|
+
crossCheckCartItems: false,
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
expect(result).toContain('segmentationWarnings');
|
|
1155
|
+
expect(result).toContain('single-cabinet threshold');
|
|
1156
|
+
expect(result).toContain('equal subdivisions');
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it('project_ai_canvas_elevation_run rejects duplicate positionName across runs on one canvas', () => {
|
|
1160
|
+
const parsed = ProjectAiCanvasElevationRunInputSchema.safeParse({
|
|
1161
|
+
sessionId,
|
|
1162
|
+
canvasType: 'north',
|
|
1163
|
+
elevationCalibration: {
|
|
1164
|
+
canvasType: 'north',
|
|
1165
|
+
imageSizePx: { width: 2000, height: 1000 },
|
|
1166
|
+
horizontalPixelsPerInch: 5,
|
|
1167
|
+
verticalPixelsPerInch: 5,
|
|
1168
|
+
floorZ0Px: 900,
|
|
1169
|
+
horizontalOriginPx: { x: 100, y: 900 },
|
|
1170
|
+
horizontalOriginCoordinateInches: 0,
|
|
1171
|
+
horizontalAxisPositiveDirection: 'right',
|
|
1172
|
+
},
|
|
1173
|
+
runs: [
|
|
1174
|
+
{ runStartInches: 0, modules: [{ positionName: 'DUP', widthInches: 18, heightInches: 42, frontType: 'double_door' }] },
|
|
1175
|
+
{ runStartInches: 40, modules: [{ positionName: 'DUP', widthInches: 18, heightInches: 42, frontType: 'double_door' }] },
|
|
1176
|
+
],
|
|
1177
|
+
});
|
|
1178
|
+
expect(parsed.success).toBe(false);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it('project_ai_canvas_elevation_run blocks the write when a box is wider than its real cart item', async () => {
|
|
1182
|
+
// Manifest has two 18" cabinets; agent tries to place one 36" box for U2A.
|
|
1183
|
+
vi.mocked(apiClientModule.aiDrawingClient.get).mockResolvedValue({
|
|
1184
|
+
data: { activeCart: { items: [
|
|
1185
|
+
{ id: 'a1', positionName: 'U2A', width: 18, height: 42 },
|
|
1186
|
+
{ id: 'a2', positionName: 'U2B', width: 18, height: 42 },
|
|
1187
|
+
] } },
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
const result = await projectAiCanvasElevationRun({
|
|
1191
|
+
sessionId,
|
|
1192
|
+
canvasType: 'north',
|
|
1193
|
+
elevationCalibration: {
|
|
1194
|
+
canvasType: 'north',
|
|
1195
|
+
imageSizePx: { width: 2000, height: 1000 },
|
|
1196
|
+
horizontalPixelsPerInch: 5,
|
|
1197
|
+
verticalPixelsPerInch: 5,
|
|
1198
|
+
floorZ0Px: 900,
|
|
1199
|
+
horizontalOriginPx: { x: 100, y: 900 },
|
|
1200
|
+
horizontalOriginCoordinateInches: 0,
|
|
1201
|
+
horizontalAxisPositiveDirection: 'right',
|
|
1202
|
+
verticalAxisPositiveDirection: 'up',
|
|
1203
|
+
},
|
|
1204
|
+
runs: [
|
|
1205
|
+
{
|
|
1206
|
+
runStartInches: 0,
|
|
1207
|
+
modules: [
|
|
1208
|
+
{ positionName: 'U2A', widthInches: 36, heightInches: 42, bottomFromFloorInches: 54, frontType: 'double_door' },
|
|
1209
|
+
],
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
writeMode: 'upsert',
|
|
1213
|
+
crossCheckCartItems: true,
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
expect(apiClientModule.aiDrawingClient.post).not.toHaveBeenCalled();
|
|
1217
|
+
expect(result).toContain('"crossCheckBlocked": true');
|
|
1218
|
+
expect(result).toContain('width_mismatch');
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it('project_ai_canvas_elevation_run writes when modules match real cart items', async () => {
|
|
1222
|
+
vi.mocked(apiClientModule.aiDrawingClient.get).mockResolvedValue({
|
|
1223
|
+
data: { activeCart: { items: [
|
|
1224
|
+
{ id: 'a1', positionName: 'U2A', width: 18, height: 42 },
|
|
1225
|
+
{ id: 'a2', positionName: 'U2B', width: 18, height: 42 },
|
|
1226
|
+
] } },
|
|
1227
|
+
});
|
|
1228
|
+
vi.mocked(apiClientModule.aiDrawingClient.post).mockResolvedValue({ data: { canvasType: 'north', placements: [] } });
|
|
1229
|
+
|
|
1230
|
+
const result = await projectAiCanvasElevationRun({
|
|
1231
|
+
sessionId,
|
|
1232
|
+
canvasType: 'north',
|
|
1233
|
+
elevationCalibration: {
|
|
1234
|
+
canvasType: 'north',
|
|
1235
|
+
imageSizePx: { width: 2000, height: 1000 },
|
|
1236
|
+
horizontalPixelsPerInch: 5,
|
|
1237
|
+
verticalPixelsPerInch: 5,
|
|
1238
|
+
floorZ0Px: 900,
|
|
1239
|
+
horizontalOriginPx: { x: 100, y: 900 },
|
|
1240
|
+
horizontalOriginCoordinateInches: 0,
|
|
1241
|
+
horizontalAxisPositiveDirection: 'right',
|
|
1242
|
+
verticalAxisPositiveDirection: 'up',
|
|
1243
|
+
},
|
|
1244
|
+
runs: [
|
|
1245
|
+
{
|
|
1246
|
+
runStartInches: 0,
|
|
1247
|
+
modules: [
|
|
1248
|
+
{ positionName: 'U2A', widthInches: 18, heightInches: 42, bottomFromFloorInches: 54, frontType: 'double_door' },
|
|
1249
|
+
{ positionName: 'U2B', widthInches: 18, heightInches: 42, bottomFromFloorInches: 54, frontType: 'double_door' },
|
|
1250
|
+
],
|
|
1251
|
+
},
|
|
1252
|
+
],
|
|
1253
|
+
writeMode: 'upsert',
|
|
1254
|
+
crossCheckCartItems: true,
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
expect(apiClientModule.aiDrawingClient.post).toHaveBeenCalledTimes(1);
|
|
1258
|
+
expect(result).toContain('"wrote": true');
|
|
1259
|
+
expect(result).toContain('"matchedCartItemCount": 2');
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('project_ai_canvas_cabinet_placements fails closed when the active cart cannot be read', async () => {
|
|
1263
|
+
vi.mocked(apiClientModule.aiDrawingClient.get).mockResolvedValue({ data: {} });
|
|
1264
|
+
|
|
1265
|
+
const result = await projectAiCanvasCabinetPlacements({
|
|
1266
|
+
sessionId,
|
|
1267
|
+
canvasType: 'north',
|
|
1268
|
+
elevationCalibration: {
|
|
1269
|
+
canvasType: 'north',
|
|
1270
|
+
imageSizePx: { width: 1000, height: 600 },
|
|
1271
|
+
horizontalPixelsPerInch: 5,
|
|
1272
|
+
verticalPixelsPerInch: 5,
|
|
1273
|
+
floorZ0Px: 550,
|
|
1274
|
+
horizontalOriginPx: { x: 100, y: 550 },
|
|
1275
|
+
horizontalOriginCoordinateInches: 0,
|
|
1276
|
+
horizontalAxisPositiveDirection: 'right',
|
|
1277
|
+
verticalAxisPositiveDirection: 'up',
|
|
1278
|
+
},
|
|
1279
|
+
placements: [
|
|
1280
|
+
{ positionName: 'BASE_01', horizontalStartInches: 12, widthInches: 36, heightInches: 30 },
|
|
1281
|
+
],
|
|
1282
|
+
writeMode: 'upsert',
|
|
1283
|
+
crossCheckCartItems: true,
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
expect(apiClientModule.aiDrawingClient.post).not.toHaveBeenCalled();
|
|
1287
|
+
expect(result).toContain('Could not read an active AI cart');
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('placement writes reject duplicate positionName on one canvas', () => {
|
|
1291
|
+
const parsed = AiCanvasCabinetPlacementsUpsertInputSchema.safeParse({
|
|
1292
|
+
sessionId,
|
|
1293
|
+
canvasType: 'north',
|
|
1294
|
+
placements: [
|
|
1295
|
+
{ positionName: 'BASE_01', centerX: 10, centerY: 10, width: 5, height: 5 },
|
|
1296
|
+
{ positionName: 'BASE_01', centerX: 20, centerY: 20, width: 5, height: 5 },
|
|
1297
|
+
],
|
|
1298
|
+
});
|
|
1299
|
+
expect(parsed.success).toBe(false);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
882
1302
|
it('duplicate_ai_cabinet posts to the session cabinet duplicate endpoint with selected placement copy fields', async () => {
|
|
883
1303
|
vi.mocked(apiClientModule.aiDrawingClient.post).mockResolvedValue({
|
|
884
1304
|
data: { duplicateItem: { id: itemId, positionName: 'BASE_01_copy' } },
|
|
@@ -3018,8 +3438,8 @@ function validElevationProjectionCalibration(canvasType: 'north' | 'south' | 'ea
|
|
|
3018
3438
|
function expectDescriptionRequiresVisualReview(toolName: string): void {
|
|
3019
3439
|
const tool = aiDrawingTools.find((candidate) => candidate.name === toolName);
|
|
3020
3440
|
|
|
3021
|
-
expect(tool?.description).toContain('render_ai_drawing_session_review');
|
|
3022
|
-
expect(tool?.description).toContain('inspect every returned full-canvas PNG with native vision');
|
|
3441
|
+
expect(tool?.description).toContain('render_ai_drawing_session_review');
|
|
3442
|
+
expect(tool?.description).toContain('inspect every returned full-canvas PNG with native vision');
|
|
3023
3443
|
expect(tool?.description).toContain('correct wrong placements with existing AI-only placement tools');
|
|
3024
3444
|
expect(tool?.description).toContain('rerender');
|
|
3025
3445
|
expect(tool?.description).toContain('PASS/FAIL/UNCERTAIN');
|