@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.
@@ -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');