google-docs-mcp / src /tools /sheets /sheetsNewTools.test.ts
iFightDucks's picture
Initial HF Space deploy: a-bonus/google-docs-mcp with HF metadata
7dc28be
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { z } from 'zod';
// --- colIndexToLetters (duplicated from getConditionalFormatting for unit testing) ---
function colIndexToLetters(index: number): string {
let s = '';
let i = index;
do {
s = String.fromCharCode(65 + (i % 26)) + s;
i = Math.floor(i / 26) - 1;
} while (i >= 0);
return s;
}
// --- Mocks ---
const mockResolveSheetId = vi.fn(async () => 0);
const mockBatchUpdate = vi.fn(async () => ({ data: { replies: [{}] } }));
const mockSheets = {
spreadsheets: {
batchUpdate: mockBatchUpdate,
get: vi.fn(),
},
};
vi.mock('../../clients.js', () => ({
getSheetsClient: vi.fn(async () => mockSheets),
}));
vi.mock('../../googleSheetsApiHelpers.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../googleSheetsApiHelpers.js')>();
return {
...actual,
resolveSheetId: mockResolveSheetId,
parseA1ToGridRange: vi.fn((_a1: string, sheetId: number) => ({
sheetId,
startRowIndex: 0,
endRowIndex: 1,
startColumnIndex: 0,
endColumnIndex: 1,
})),
parseRange: vi.fn((range: string) => {
const idx = range.indexOf('!');
return idx !== -1
? { sheetName: range.slice(0, idx), a1Range: range.slice(idx + 1) }
: { sheetName: null, a1Range: range };
}),
hexToRgb: vi.fn((hex: string) => {
if (hex === '#FF0000') return { red: 1, green: 0, blue: 0 };
return { red: 0, green: 0, blue: 0 };
}),
};
});
beforeEach(() => {
vi.clearAllMocks();
mockResolveSheetId.mockResolvedValue(0);
mockBatchUpdate.mockResolvedValue({ data: { replies: [{}] } });
});
// ============================================================
// colIndexToLetters
// ============================================================
describe('colIndexToLetters', () => {
it('converts single-letter columns correctly', () => {
expect(colIndexToLetters(0)).toBe('A');
expect(colIndexToLetters(1)).toBe('B');
expect(colIndexToLetters(25)).toBe('Z');
});
it('converts multi-letter columns correctly (beyond Z)', () => {
expect(colIndexToLetters(26)).toBe('AA');
expect(colIndexToLetters(27)).toBe('AB');
expect(colIndexToLetters(51)).toBe('AZ');
expect(colIndexToLetters(52)).toBe('BA');
expect(colIndexToLetters(701)).toBe('ZZ');
expect(colIndexToLetters(702)).toBe('AAA');
});
});
// ============================================================
// autoResizeRows — Zod validation
// ============================================================
describe('autoResizeRows schema validation', () => {
const schema = z
.object({
spreadsheetId: z.string(),
startRow: z.number().int().min(1).optional(),
endRow: z.number().int().min(1).optional(),
})
.refine((d) => d.startRow === undefined || d.endRow === undefined || d.endRow >= d.startRow, {
message: 'endRow must be greater than or equal to startRow.',
});
it('passes when startRow <= endRow', () => {
expect(() => schema.parse({ spreadsheetId: 'id', startRow: 2, endRow: 10 })).not.toThrow();
});
it('passes when only startRow provided', () => {
expect(() => schema.parse({ spreadsheetId: 'id', startRow: 5 })).not.toThrow();
});
it('passes when neither startRow nor endRow provided', () => {
expect(() => schema.parse({ spreadsheetId: 'id' })).not.toThrow();
});
it('fails when endRow < startRow', () => {
const result = schema.safeParse({ spreadsheetId: 'id', startRow: 10, endRow: 5 });
expect(result.success).toBe(false);
});
it('passes when startRow === endRow', () => {
expect(() => schema.parse({ spreadsheetId: 'id', startRow: 3, endRow: 3 })).not.toThrow();
});
});
// ============================================================
// setRowHeights — Zod validation
// ============================================================
describe('setRowHeights schema validation', () => {
const schema = z
.object({
spreadsheetId: z.string(),
startRow: z.number().int().min(1),
endRow: z.number().int().min(1),
pixelSize: z.number().int().min(2),
})
.refine((d) => d.endRow >= d.startRow, {
message: 'endRow must be greater than or equal to startRow.',
});
it('passes with valid input', () => {
expect(() =>
schema.parse({ spreadsheetId: 'id', startRow: 1, endRow: 10, pixelSize: 40 })
).not.toThrow();
});
it('fails when endRow < startRow', () => {
const result = schema.safeParse({
spreadsheetId: 'id',
startRow: 10,
endRow: 5,
pixelSize: 40,
});
expect(result.success).toBe(false);
});
it('fails when pixelSize < 2', () => {
const result = schema.safeParse({ spreadsheetId: 'id', startRow: 1, endRow: 5, pixelSize: 1 });
expect(result.success).toBe(false);
});
it('passes when startRow === endRow', () => {
expect(() =>
schema.parse({ spreadsheetId: 'id', startRow: 5, endRow: 5, pixelSize: 40 })
).not.toThrow();
});
});
// ============================================================
// deleteConditionalFormatting — auto-sort descending
// ============================================================
describe('deleteConditionalFormatting sort order', () => {
it('sorts indices descending before building requests', () => {
const indices = [0, 3, 1];
const sorted = [...indices].sort((a, b) => b - a);
expect(sorted).toEqual([3, 1, 0]);
});
it('single index unchanged after sort', () => {
const sorted = [...[2]].sort((a, b) => b - a);
expect(sorted).toEqual([2]);
});
it('already-descending input stays correct', () => {
const sorted = [...[5, 3, 1, 0]].sort((a, b) => b - a);
expect(sorted).toEqual([5, 3, 1, 0]);
});
it('ascending input gets reversed', () => {
const sorted = [...[0, 1, 2, 3]].sort((a, b) => b - a);
expect(sorted).toEqual([3, 2, 1, 0]);
});
});
// ============================================================
// setRowHeights — API call shape
// ============================================================
describe('setRowHeights API call shape', () => {
it('sends updateDimensionProperties with correct fields', async () => {
const { getSheetsClient } = await import('../../clients.js');
const sheets = await getSheetsClient();
const sheetId = await mockResolveSheetId();
await sheets.spreadsheets.batchUpdate({
spreadsheetId: 'test-id',
requestBody: {
requests: [
{
updateDimensionProperties: {
range: { sheetId, dimension: 'ROWS', startIndex: 1, endIndex: 5 },
properties: { pixelSize: 40 },
fields: 'pixelSize',
},
},
],
},
});
expect(mockBatchUpdate).toHaveBeenCalledOnce();
const req = mockBatchUpdate.mock.calls[0][0].requestBody.requests[0].updateDimensionProperties;
expect(req.range.dimension).toBe('ROWS');
expect(req.properties.pixelSize).toBe(40);
expect(req.fields).toBe('pixelSize');
});
});
// ============================================================
// autoResizeRows — API call shape
// ============================================================
describe('autoResizeRows API call shape', () => {
it('sends autoResizeDimensions with ROWS dimension', async () => {
const { getSheetsClient } = await import('../../clients.js');
const sheets = await getSheetsClient();
const sheetId = await mockResolveSheetId();
const dimensionRange: any = { sheetId, dimension: 'ROWS', startIndex: 1, endIndex: 10 };
await sheets.spreadsheets.batchUpdate({
spreadsheetId: 'test-id',
requestBody: {
requests: [{ autoResizeDimensions: { dimensions: dimensionRange } }],
},
});
expect(mockBatchUpdate).toHaveBeenCalledOnce();
const dims =
mockBatchUpdate.mock.calls[0][0].requestBody.requests[0].autoResizeDimensions.dimensions;
expect(dims.dimension).toBe('ROWS');
expect(dims.startIndex).toBe(1);
expect(dims.endIndex).toBe(10);
});
});
// ============================================================
// setCellBorders — Zod validation
// ============================================================
describe('setCellBorders schema validation', () => {
const borderSchema = z
.object({
style: z.enum(['SOLID', 'SOLID_MEDIUM', 'SOLID_THICK', 'DOTTED', 'DASHED', 'DOUBLE', 'NONE']),
color: z.string().optional(),
})
.optional();
const schema = z
.object({
spreadsheetId: z.string(),
range: z.string(),
top: borderSchema,
bottom: borderSchema,
left: borderSchema,
right: borderSchema,
innerHorizontal: borderSchema,
innerVertical: borderSchema,
})
.refine(
(d) =>
d.top !== undefined ||
d.bottom !== undefined ||
d.left !== undefined ||
d.right !== undefined ||
d.innerHorizontal !== undefined ||
d.innerVertical !== undefined,
{ message: 'At least one border side must be specified.' }
);
it('passes with at least one side', () => {
expect(() =>
schema.parse({ spreadsheetId: 'id', range: 'A1:D10', top: { style: 'SOLID' } })
).not.toThrow();
});
it('fails when no sides specified', () => {
const result = schema.safeParse({ spreadsheetId: 'id', range: 'A1:D10' });
expect(result.success).toBe(false);
});
it('passes with multiple sides', () => {
expect(() =>
schema.parse({
spreadsheetId: 'id',
range: 'A1:D10',
top: { style: 'SOLID_MEDIUM', color: '#000000' },
bottom: { style: 'NONE' },
innerHorizontal: { style: 'DOTTED' },
})
).not.toThrow();
});
it('rejects invalid border style', () => {
const result = schema.safeParse({
spreadsheetId: 'id',
range: 'A1',
top: { style: 'INVALID_STYLE' },
});
expect(result.success).toBe(false);
});
});