@sealab/mcp-server 1.0.2 → 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.
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ projectElevationPlacement,
4
+ projectPlanPlacement,
5
+ projectPlanWallAnchoredPlacement,
6
+ isProjectedPlacementOutsideImage,
7
+ type ElevationCalibration,
8
+ type PlanCalibration,
9
+ } from './placement-projection';
10
+
11
+ const elevationCal: ElevationCalibration = {
12
+ canvasType: 'north',
13
+ imageSizePx: { width: 1000, height: 600 },
14
+ horizontalPixelsPerInch: 5,
15
+ verticalPixelsPerInch: 5,
16
+ floorZ0Px: 550,
17
+ horizontalOriginPx: { x: 100, y: 550 },
18
+ horizontalOriginCoordinateInches: 0,
19
+ horizontalAxisPositiveDirection: 'right',
20
+ verticalAxisPositiveDirection: 'up',
21
+ };
22
+
23
+ describe('projectElevationPlacement', () => {
24
+ it('places a base cabinet face from the floor up', () => {
25
+ // 36" wide base cabinet starting 12" along the wall, 30" body height, on the floor.
26
+ const result = projectElevationPlacement(
27
+ { horizontalStartInches: 12, widthInches: 36, heightInches: 30, bottomFromFloorInches: 0 },
28
+ elevationCal
29
+ );
30
+ // left = 100 + 12*5 = 160, right = 100 + 48*5 = 340 => centerX 250, width 180
31
+ expect(result.centerX).toBe(250);
32
+ expect(result.width).toBe(180);
33
+ // bottom = floor 550, top = 550 - 30*5 = 400 => centerY 475, height 150
34
+ expect(result.centerY).toBe(475);
35
+ expect(result.height).toBe(150);
36
+ expect(result.rotation).toBe(0);
37
+ });
38
+
39
+ it('lifts an upper cabinet off the floor by bottomFromFloorInches', () => {
40
+ // upper: bottom at 54" above floor, 42" tall.
41
+ const result = projectElevationPlacement(
42
+ { horizontalStartInches: 0, widthInches: 24, heightInches: 42, bottomFromFloorInches: 54 },
43
+ elevationCal
44
+ );
45
+ // bottom = 550 - 54*5 = 280, top = 550 - 96*5 = 70 => centerY 175, height 210
46
+ expect(result.centerY).toBe(175);
47
+ expect(result.height).toBe(210);
48
+ });
49
+
50
+ it('produces gap-free adjacent boxes for a run of equal modules (no cumulative drift)', () => {
51
+ const widths = [24, 24, 24, 24];
52
+ let cursor = 0;
53
+ const rights: number[] = [];
54
+ const lefts: number[] = [];
55
+ for (const w of widths) {
56
+ const r = projectElevationPlacement(
57
+ { horizontalStartInches: cursor, widthInches: w, heightInches: 30 },
58
+ elevationCal
59
+ );
60
+ lefts.push(r.centerX - r.width / 2);
61
+ rights.push(r.centerX + r.width / 2);
62
+ cursor += w;
63
+ }
64
+ // Each box's right edge equals the next box's left edge exactly.
65
+ expect(rights[0]).toBeCloseTo(lefts[1], 6);
66
+ expect(rights[1]).toBeCloseTo(lefts[2], 6);
67
+ expect(rights[2]).toBeCloseTo(lefts[3], 6);
68
+ });
69
+
70
+ it('honors a left-positive horizontal axis (e.g. east/west wall reading reversed)', () => {
71
+ const leftPositive: ElevationCalibration = {
72
+ ...elevationCal,
73
+ horizontalAxisPositiveDirection: 'left',
74
+ };
75
+ const result = projectElevationPlacement(
76
+ { horizontalStartInches: 12, widthInches: 36, heightInches: 30 },
77
+ leftPositive
78
+ );
79
+ // left = 100 - 12*5 = 40, right = 100 - 48*5 = -140 => centerX -50, width 180
80
+ expect(result.centerX).toBe(-50);
81
+ expect(result.width).toBe(180);
82
+ });
83
+ });
84
+
85
+ const planCal: PlanCalibration = {
86
+ canvasType: 'plan',
87
+ imageSizePx: { width: 800, height: 800 },
88
+ pixelsPerInch: 2,
89
+ roomOriginPx: { x: 50, y: 750 },
90
+ xPositiveDirection: 'right',
91
+ yPositiveDirection: 'up',
92
+ };
93
+
94
+ describe('projectPlanPlacement', () => {
95
+ it('projects a footprint with width along X and depth along Y at rotation 0', () => {
96
+ const result = projectPlanPlacement(
97
+ { centerXInches: 100, centerYInches: 50, widthInches: 30, depthInches: 24, rotation: 0 },
98
+ planCal
99
+ );
100
+ // centerX = 50 + 100*2 = 250, centerY = 750 - 50*2 = 650
101
+ expect(result.centerX).toBe(250);
102
+ expect(result.centerY).toBe(650);
103
+ expect(result.width).toBe(60); // 30 * 2
104
+ expect(result.height).toBe(48); // 24 * 2
105
+ });
106
+
107
+ it('swaps width/depth for a quarter-turn rotation', () => {
108
+ const result = projectPlanPlacement(
109
+ { centerXInches: 100, centerYInches: 50, widthInches: 30, depthInches: 24, rotation: 90 },
110
+ planCal
111
+ );
112
+ expect(result.width).toBe(48); // depth 24 * 2 now along X
113
+ expect(result.height).toBe(60); // width 30 * 2 now along Y
114
+ expect(result.rotation).toBe(0);
115
+ });
116
+ });
117
+
118
+ describe('projectPlanWallAnchoredPlacement', () => {
119
+ // Room origin at pixel (50,750); +x is right, +y is up (screen y decreases as room y rises).
120
+ it('pins the back edge to the north wall and grows depth into the room', () => {
121
+ // North wall face at room y = 120; interior is toward smaller y (depthDirection negative).
122
+ const result = projectPlanWallAnchoredPlacement(
123
+ {
124
+ wallBackEdgeInches: 120,
125
+ depthAxis: 'y',
126
+ depthDirection: 'negative',
127
+ alongWallStartInches: 30,
128
+ widthInches: 36,
129
+ depthInches: 24,
130
+ },
131
+ planCal
132
+ );
133
+ // along-wall center x = 30 + 18 = 48 -> px = 50 + 48*2 = 146
134
+ expect(result.centerX).toBe(146);
135
+ // depth center y = 120 - 12 = 108 -> px = 750 - 108*2 = 534
136
+ expect(result.centerY).toBe(534);
137
+ expect(result.width).toBe(72); // 36 along wall * 2
138
+ expect(result.height).toBe(48); // 24 depth * 2
139
+
140
+ // The back edge (room y = 120) must land exactly on its wall-face pixel, with
141
+ // the whole box on the interior side of it (larger pixel-y / lower on screen).
142
+ const backEdgePx = 750 - 120 * 2; // 510
143
+ const boxNearWallEdge = result.centerY - result.height / 2; // 534 - 24 = 510
144
+ expect(boxNearWallEdge).toBe(backEdgePx);
145
+ });
146
+
147
+ it('handles a cabinet backing an east wall (depth along x)', () => {
148
+ const result = projectPlanWallAnchoredPlacement(
149
+ {
150
+ wallBackEdgeInches: 200,
151
+ depthAxis: 'x',
152
+ depthDirection: 'negative',
153
+ alongWallStartInches: 40,
154
+ widthInches: 30,
155
+ depthInches: 24,
156
+ },
157
+ planCal
158
+ );
159
+ expect(result.width).toBe(48); // depth 24 * 2 along x
160
+ expect(result.height).toBe(60); // width 30 * 2 along y
161
+ const backEdgePx = 50 + 200 * 2; // 450
162
+ const boxWallEdge = result.centerX + result.width / 2; // depthDirection negative => box on smaller-x side
163
+ expect(boxWallEdge).toBe(backEdgePx);
164
+ });
165
+ });
166
+
167
+ describe('isProjectedPlacementOutsideImage', () => {
168
+ it('flags a box that extends past the image edge', () => {
169
+ expect(
170
+ isProjectedPlacementOutsideImage(
171
+ { centerX: 990, centerY: 300, width: 60, height: 60, rotation: 0 },
172
+ { width: 1000, height: 600 }
173
+ )
174
+ ).toBe(true);
175
+ });
176
+
177
+ it('accepts a box fully inside the image', () => {
178
+ expect(
179
+ isProjectedPlacementOutsideImage(
180
+ { centerX: 250, centerY: 475, width: 180, height: 150, rotation: 0 },
181
+ { width: 1000, height: 600 }
182
+ )
183
+ ).toBe(false);
184
+ });
185
+ });
@@ -0,0 +1,247 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Deterministic AIDrawingTool placement projection.
3
+ //
4
+ // Root cause of inaccurate cabinet placement: the agent was asked to eyeball
5
+ // raw centerX/centerY/width/height pixel rectangles directly from a drawing.
6
+ // Vision-language models are unreliable at precise pixel localization, so boxes
7
+ // drifted, overlapped, doubled, and overshot — especially across rows of
8
+ // near-identical modules where per-box error accumulates.
9
+ //
10
+ // This module removes that guesswork. The agent establishes ONE pixels-per-inch
11
+ // calibration per canvas (from a visible dimension string + the floor/Z0 line +
12
+ // one origin reference) and supplies each cabinet's REAL inch dimensions and
13
+ // position. Every placement rectangle is then computed from the same scale, so
14
+ // the entire layout is internally consistent and free of cumulative drift.
15
+ //
16
+ // These functions are pure (no IO) so they can be unit-tested directly.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type ElevationCanvasType = 'north' | 'south' | 'east' | 'west';
20
+ export type HorizontalDirection = 'left' | 'right';
21
+ export type VerticalDirection = 'up' | 'down';
22
+
23
+ export interface PixelPoint {
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ export interface ImageSizePx {
29
+ width: number;
30
+ height: number;
31
+ }
32
+
33
+ /**
34
+ * Lean elevation calibration. Mirrors the geometry fields of the stored
35
+ * canvasMeta.projectionCalibration (ElevationProjectionCalibrationSchema) but
36
+ * carries only what the pixel projection needs.
37
+ */
38
+ export interface ElevationCalibration {
39
+ canvasType: ElevationCanvasType;
40
+ imageSizePx: ImageSizePx;
41
+ /** Pixels per inch along the wall (horizontal screen axis). */
42
+ horizontalPixelsPerInch: number;
43
+ /** Pixels per inch vertically (height screen axis). */
44
+ verticalPixelsPerInch: number;
45
+ /** Pixel Y of the finished-floor / Z0 reference line. */
46
+ floorZ0Px: number;
47
+ /** A pixel point whose horizontal inch coordinate is known. */
48
+ horizontalOriginPx: PixelPoint;
49
+ /** The along-wall inch coordinate that horizontalOriginPx maps to. */
50
+ horizontalOriginCoordinateInches: number;
51
+ /** Direction in which the along-wall inch coordinate increases on screen. */
52
+ horizontalAxisPositiveDirection: HorizontalDirection;
53
+ /** Direction in which height-above-floor increases on screen (normally 'up'). */
54
+ verticalAxisPositiveDirection: VerticalDirection;
55
+ }
56
+
57
+ /** Lean plan calibration mirroring PlanProjectionCalibrationSchema geometry. */
58
+ export interface PlanCalibration {
59
+ canvasType: 'plan';
60
+ imageSizePx: ImageSizePx;
61
+ pixelsPerInch: number;
62
+ /** Pixel point that maps to room/global coordinate origin (0, 0). */
63
+ roomOriginPx: PixelPoint;
64
+ xPositiveDirection: HorizontalDirection;
65
+ yPositiveDirection: VerticalDirection;
66
+ }
67
+
68
+ /** Real-world spec for one cabinet front face on an elevation. */
69
+ export interface ElevationPlacementSpec {
70
+ /** Left edge of the face along the wall, in the wall's horizontal inch coordinate. */
71
+ horizontalStartInches: number;
72
+ /** Cabinet face width in inches. */
73
+ widthInches: number;
74
+ /** Cabinet face height in inches (body height for a base cabinet — excludes toekick/counter). */
75
+ heightInches: number;
76
+ /** Finished-floor-to-bottom of this face, in inches. 0 for a base cabinet on the floor. */
77
+ bottomFromFloorInches?: number;
78
+ }
79
+
80
+ /** Real-world spec for one cabinet footprint on the plan. */
81
+ export interface PlanPlacementSpec {
82
+ /** Cabinet center X in room/global plan inches. */
83
+ centerXInches: number;
84
+ /** Cabinet center Y in room/global plan inches. */
85
+ centerYInches: number;
86
+ /** Cabinet width in inches (along the cabinet's face). */
87
+ widthInches: number;
88
+ /** Cabinet depth in inches (front-to-back). */
89
+ depthInches: number;
90
+ /** Wall rotation 0/90/180/270. Swaps which inch dimension runs along X vs Y. */
91
+ rotation?: number;
92
+ }
93
+
94
+ export interface ProjectedPlacement {
95
+ centerX: number;
96
+ centerY: number;
97
+ width: number;
98
+ height: number;
99
+ rotation: number;
100
+ }
101
+
102
+ const PIXEL_PRECISION = 2;
103
+
104
+ function roundPx(value: number): number {
105
+ const factor = 10 ** PIXEL_PRECISION;
106
+ return Math.round(value * factor) / factor;
107
+ }
108
+
109
+ function horizontalSign(direction: HorizontalDirection): number {
110
+ return direction === 'right' ? 1 : -1;
111
+ }
112
+
113
+ function elevationVerticalSign(direction: VerticalDirection): number {
114
+ // Height-above-floor grows upward; screen Y grows downward.
115
+ // 'up' positive => increasing height moves toward smaller pixel Y.
116
+ return direction === 'up' ? -1 : 1;
117
+ }
118
+
119
+ function planVerticalSign(direction: VerticalDirection): number {
120
+ return direction === 'down' ? 1 : -1;
121
+ }
122
+
123
+ function elevationPixelX(horizontalInches: number, cal: ElevationCalibration): number {
124
+ return (
125
+ cal.horizontalOriginPx.x +
126
+ horizontalSign(cal.horizontalAxisPositiveDirection) *
127
+ (horizontalInches - cal.horizontalOriginCoordinateInches) *
128
+ cal.horizontalPixelsPerInch
129
+ );
130
+ }
131
+
132
+ function elevationPixelY(heightAboveFloorInches: number, cal: ElevationCalibration): number {
133
+ return (
134
+ cal.floorZ0Px +
135
+ elevationVerticalSign(cal.verticalAxisPositiveDirection) *
136
+ heightAboveFloorInches *
137
+ cal.verticalPixelsPerInch
138
+ );
139
+ }
140
+
141
+ /** Project one elevation cabinet face spec into an image-pixel rectangle. */
142
+ export function projectElevationPlacement(
143
+ spec: ElevationPlacementSpec,
144
+ cal: ElevationCalibration
145
+ ): ProjectedPlacement {
146
+ const bottomFromFloor = spec.bottomFromFloorInches ?? 0;
147
+ const leftPx = elevationPixelX(spec.horizontalStartInches, cal);
148
+ const rightPx = elevationPixelX(spec.horizontalStartInches + spec.widthInches, cal);
149
+ const bottomPx = elevationPixelY(bottomFromFloor, cal);
150
+ const topPx = elevationPixelY(bottomFromFloor + spec.heightInches, cal);
151
+
152
+ return {
153
+ centerX: roundPx((leftPx + rightPx) / 2),
154
+ centerY: roundPx((topPx + bottomPx) / 2),
155
+ width: roundPx(Math.abs(rightPx - leftPx)),
156
+ height: roundPx(Math.abs(bottomPx - topPx)),
157
+ rotation: 0,
158
+ };
159
+ }
160
+
161
+ function planPixelX(xInches: number, cal: PlanCalibration): number {
162
+ return cal.roomOriginPx.x + horizontalSign(cal.xPositiveDirection) * xInches * cal.pixelsPerInch;
163
+ }
164
+
165
+ function planPixelY(yInches: number, cal: PlanCalibration): number {
166
+ return cal.roomOriginPx.y + planVerticalSign(cal.yPositiveDirection) * yInches * cal.pixelsPerInch;
167
+ }
168
+
169
+ /** Project one plan cabinet footprint spec into an image-pixel rectangle. */
170
+ export function projectPlanPlacement(spec: PlanPlacementSpec, cal: PlanCalibration): ProjectedPlacement {
171
+ const rotation = ((spec.rotation ?? 0) % 360 + 360) % 360;
172
+ const rotatedQuarterTurn = rotation === 90 || rotation === 270;
173
+ const inchesAlongX = rotatedQuarterTurn ? spec.depthInches : spec.widthInches;
174
+ const inchesAlongY = rotatedQuarterTurn ? spec.widthInches : spec.depthInches;
175
+
176
+ return {
177
+ centerX: roundPx(planPixelX(spec.centerXInches, cal)),
178
+ centerY: roundPx(planPixelY(spec.centerYInches, cal)),
179
+ width: roundPx(inchesAlongX * cal.pixelsPerInch),
180
+ height: roundPx(inchesAlongY * cal.pixelsPerInch),
181
+ // The rectangle is axis-aligned to the room/image; orientation is captured
182
+ // by the dimension swap above, so the rendered box rotation stays 0.
183
+ rotation: 0,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Wall-anchored plan footprint spec. The agent pins the cabinet's BACK edge to
189
+ * the wall's inner face it can see, and depth grows into the room — so the
190
+ * footprint can never bleed into the wall band, the way a free center ± half
191
+ * depth can. This is the plan analog of anchoring elevation height to the floor.
192
+ */
193
+ export interface PlanWallAnchoredSpec {
194
+ /** Room-inch coordinate of the cabinet back edge on the depth axis (the wall inner face). */
195
+ wallBackEdgeInches: number;
196
+ /** Which room axis runs perpendicular to the wall (the depth direction). */
197
+ depthAxis: 'x' | 'y';
198
+ /** Direction, in room coordinates, that depth grows from the wall into the room. */
199
+ depthDirection: 'positive' | 'negative';
200
+ /** Start of the run along the wall, in room inches on the other axis. */
201
+ alongWallStartInches: number;
202
+ /** Extent along the wall in inches. */
203
+ widthInches: number;
204
+ /** Cabinet depth in inches, growing from the wall face into the room. */
205
+ depthInches: number;
206
+ }
207
+
208
+ /**
209
+ * Project a wall-anchored plan footprint. The back edge lands exactly on
210
+ * wallBackEdgeInches; the box extends widthInches along the wall and depthInches
211
+ * into the room. The rendered rectangle is axis-aligned (rotation 0).
212
+ */
213
+ export function projectPlanWallAnchoredPlacement(
214
+ spec: PlanWallAnchoredSpec,
215
+ cal: PlanCalibration
216
+ ): ProjectedPlacement {
217
+ const sign = spec.depthDirection === 'positive' ? 1 : -1;
218
+ const alongCenter = spec.alongWallStartInches + spec.widthInches / 2;
219
+ const depthCenter = spec.wallBackEdgeInches + sign * (spec.depthInches / 2);
220
+
221
+ const centerXInches = spec.depthAxis === 'x' ? depthCenter : alongCenter;
222
+ const centerYInches = spec.depthAxis === 'y' ? depthCenter : alongCenter;
223
+ const inchesAlongX = spec.depthAxis === 'x' ? spec.depthInches : spec.widthInches;
224
+ const inchesAlongY = spec.depthAxis === 'y' ? spec.depthInches : spec.widthInches;
225
+
226
+ return {
227
+ centerX: roundPx(planPixelX(centerXInches, cal)),
228
+ centerY: roundPx(planPixelY(centerYInches, cal)),
229
+ width: roundPx(inchesAlongX * cal.pixelsPerInch),
230
+ height: roundPx(inchesAlongY * cal.pixelsPerInch),
231
+ rotation: 0,
232
+ };
233
+ }
234
+
235
+ /** True when any part of the rectangle falls outside the calibration image. */
236
+ export function isProjectedPlacementOutsideImage(
237
+ placement: ProjectedPlacement,
238
+ imageSizePx: ImageSizePx
239
+ ): boolean {
240
+ const halfWidth = placement.width / 2;
241
+ const halfHeight = placement.height / 2;
242
+ const minX = placement.centerX - halfWidth;
243
+ const maxX = placement.centerX + halfWidth;
244
+ const minY = placement.centerY - halfHeight;
245
+ const maxY = placement.centerY + halfHeight;
246
+ return minX < 0 || minY < 0 || maxX > imageSizePx.width || maxY > imageSizePx.height;
247
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as apiClientModule from '../client/api-client';
3
+
4
+ vi.mock('../client/api-client', () => ({
5
+ client: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
6
+ handleAxiosError: vi.fn((e) => { throw e; }),
7
+ McpApiError: class McpApiError extends Error {
8
+ constructor(public status: number, message: string) { super(message); }
9
+ },
10
+ }));
11
+
12
+ import {
13
+ listProjects,
14
+ getProject,
15
+ createProject,
16
+ updateProject,
17
+ attachOrderToProject,
18
+ updateProjectArticleMaterials,
19
+ shareProjectAccess,
20
+ } from './projects';
21
+
22
+ describe('project MCP tools', () => {
23
+ beforeEach(() => vi.clearAllMocks());
24
+
25
+ it('lists compact project summaries', async () => {
26
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({
27
+ data: [{ projectId: 1, name: 'Smith Kitchen', status: 'Active', orders: [{ orderId: 'ORD-1' }] }],
28
+ });
29
+
30
+ const result = await listProjects({});
31
+
32
+ expect(vi.mocked(apiClientModule.client.get)).toHaveBeenCalledWith('/projects');
33
+ expect(result).toContain('Smith Kitchen');
34
+ expect(result).toContain('orderCount');
35
+ });
36
+
37
+ it('gets full project detail', async () => {
38
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({
39
+ data: { projectId: 1, name: 'Smith Kitchen', summary: { projectTotalValue: 100 } },
40
+ });
41
+
42
+ const result = await getProject({ projectId: 1 });
43
+
44
+ expect(vi.mocked(apiClientModule.client.get)).toHaveBeenCalledWith('/projects/1');
45
+ expect(result).toContain('projectTotalValue');
46
+ });
47
+
48
+ it('creates a project', async () => {
49
+ vi.mocked(apiClientModule.client.post).mockResolvedValue({
50
+ data: { projectId: 2, name: 'New Project' },
51
+ });
52
+
53
+ const result = await createProject({ name: 'New Project', status: 'Active' });
54
+
55
+ expect(vi.mocked(apiClientModule.client.post)).toHaveBeenCalledWith('/projects', {
56
+ name: 'New Project',
57
+ status: 'Active',
58
+ });
59
+ expect(result).toContain('Project ID: 2');
60
+ });
61
+
62
+ it('merges current project before update', async () => {
63
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({
64
+ data: { projectId: 3, name: 'Old Name', address1: '1 Main', status: 'Active' },
65
+ });
66
+ vi.mocked(apiClientModule.client.put).mockResolvedValue({
67
+ data: { projectId: 3, name: 'New Name', address1: '1 Main', status: 'Active' },
68
+ });
69
+
70
+ const result = await updateProject({ projectId: 3, name: 'New Name' });
71
+
72
+ expect(vi.mocked(apiClientModule.client.get)).toHaveBeenCalledWith('/projects/3');
73
+ expect(vi.mocked(apiClientModule.client.put)).toHaveBeenCalledWith('/projects/3', {
74
+ projectId: 3,
75
+ name: 'New Name',
76
+ address1: '1 Main',
77
+ status: 'Active',
78
+ });
79
+ expect(result).toContain('New Name');
80
+ });
81
+
82
+ it('attaches an order to a project', async () => {
83
+ vi.mocked(apiClientModule.client.post).mockResolvedValue({ data: 'ok' });
84
+
85
+ const result = await attachOrderToProject({ projectId: 4, orderId: 'ORD-1' });
86
+
87
+ expect(vi.mocked(apiClientModule.client.post)).toHaveBeenCalledWith('/projects/4/orders/ORD-1');
88
+ expect(result).toContain('attached');
89
+ });
90
+
91
+ it('bulk updates project article materials', async () => {
92
+ vi.mocked(apiClientModule.client.patch).mockResolvedValue({
93
+ data: { updatedArticles: 2 },
94
+ });
95
+
96
+ const result = await updateProjectArticleMaterials({
97
+ projectId: 5,
98
+ orderId: 'ORD-2',
99
+ positionNames: ['Base_01', 'Base_02'],
100
+ field: 'caseMaterial',
101
+ materialDescription: 'Prefinished Maple',
102
+ });
103
+
104
+ expect(vi.mocked(apiClientModule.client.patch)).toHaveBeenCalledWith(
105
+ '/projects/5/orders/ORD-2/articles/materials',
106
+ {
107
+ positionNames: ['Base_01', 'Base_02'],
108
+ field: 'caseMaterial',
109
+ materialDescription: 'Prefinished Maple',
110
+ }
111
+ );
112
+ expect(result).toContain('updatedArticles');
113
+ });
114
+
115
+ it('shares project access', async () => {
116
+ vi.mocked(apiClientModule.client.post).mockResolvedValue({ data: 'ok' });
117
+
118
+ const result = await shareProjectAccess({
119
+ projectId: 6,
120
+ grantedToEmail: 'client@example.com',
121
+ permissionType: 'VIEW',
122
+ hidePrice: true,
123
+ });
124
+
125
+ expect(vi.mocked(apiClientModule.client.post)).toHaveBeenCalledWith(
126
+ '/projects/6/permissions/grant',
127
+ {
128
+ grantedToEmail: 'client@example.com',
129
+ permissionType: 'VIEW',
130
+ hidePrice: true,
131
+ }
132
+ );
133
+ expect(result).toContain('Pricing will be hidden');
134
+ });
135
+ });