Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/feishu/tools/__tests__/doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mockDocxDocumentBlockList = vi.fn();
const mockDocxDocumentBlockBatchUpdate = vi.fn();
const mockDocxDocumentBlockChildrenBatchDelete = vi.fn();
const mockDocxDocumentBlockChildrenCreate = vi.fn();
const mockDocxDocumentBlockDescendantCreate = vi.fn();

vi.mock('../../client.js', () => ({
feishuClient: {
Expand All @@ -32,6 +33,9 @@ vi.mock('../../client.js', () => ({
batchDelete: (...args: unknown[]) => mockDocxDocumentBlockChildrenBatchDelete(...args),
create: (...args: unknown[]) => mockDocxDocumentBlockChildrenCreate(...args),
},
documentBlockDescendant: {
create: (...args: unknown[]) => mockDocxDocumentBlockDescendantCreate(...args),
},
},
},
},
Expand Down Expand Up @@ -334,7 +338,7 @@ describe('feishu_doc tool', () => {
code: 0,
data: {
items: [
{ block_id: 'page_1', block_type: 1 },
{ block_id: 'page_1', block_type: 1, children: ['text_1'] },
{ block_id: 'text_1', block_type: 2 },
],
},
Expand All @@ -360,7 +364,7 @@ describe('feishu_doc tool', () => {
code: 0,
data: {
items: [
{ block_id: 'page_1', block_type: 1 },
{ block_id: 'page_1', block_type: 1, children: ['text_1'] },
{ block_id: 'text_1', block_type: 2 },
],
},
Expand All @@ -385,6 +389,62 @@ describe('feishu_doc tool', () => {
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('99999');
});

it('deletes by page direct-children count, not the flat block total (doc with a native table)', async () => {
// documentBlock.list returns a FLAT list: the page has 4 DIRECT children
// (h1, p1, table, p2), but the table's 4 cells + 4 cell-texts are also listed →
// 12 non-page blocks total. batchDelete removes the page's direct children, so
// end_index must be 4, not 12 — else a doc containing a native table fails with 1770001.
mockDocxDocumentBlockList.mockResolvedValue({
code: 0,
data: {
items: [
{ block_id: 'page_1', block_type: 1, children: ['h1', 'p1', 'tbl', 'p2'] },
{ block_id: 'h1', block_type: 3 },
{ block_id: 'p1', block_type: 2 },
{ block_id: 'tbl', block_type: 31, children: ['c0', 'c1', 'c2', 'c3'] },
{ block_id: 'c0', block_type: 32, children: ['t0'] },
{ block_id: 'c1', block_type: 32, children: ['t1'] },
{ block_id: 'c2', block_type: 32, children: ['t2'] },
{ block_id: 'c3', block_type: 32, children: ['t3'] },
{ block_id: 't0', block_type: 2 },
{ block_id: 't1', block_type: 2 },
{ block_id: 't2', block_type: 2 },
{ block_id: 't3', block_type: 2 },
{ block_id: 'p2', block_type: 2 },
],
},
});
mockDocxDocumentBlockChildrenBatchDelete.mockResolvedValue({ code: 0 });
mockDocxDocumentBlockChildrenCreate.mockResolvedValue({ code: 0 });

const result = await capturedHandler({
action: 'write', doc_token: 'ABC123', content: 'hello',
});

expect(result.content[0].text).toBe('文档已更新');
expect(result.isError).toBeUndefined();
expect(mockDocxDocumentBlockChildrenBatchDelete).toHaveBeenCalledTimes(1);
const delArg = mockDocxDocumentBlockChildrenBatchDelete.mock.calls[0][0];
expect(delArg.data.start_index).toBe(0);
expect(delArg.data.end_index).toBe(4); // page direct children — NOT 12 flat blocks
expect(delArg.path.block_id).toBe('page_1');
});

it('skips batchDelete when the page has no direct children (empty doc)', async () => {
mockDocxDocumentBlockList.mockResolvedValue({
code: 0,
data: { items: [{ block_id: 'page_1', block_type: 1, children: [] }] },
});
mockDocxDocumentBlockChildrenCreate.mockResolvedValue({ code: 0 });

const result = await capturedHandler({
action: 'write', doc_token: 'ABC123', content: 'hi',
});

expect(result.content[0].text).toBe('文档已更新');
expect(mockDocxDocumentBlockChildrenBatchDelete).not.toHaveBeenCalled();
});
});

describe('append action', () => {
Expand Down
180 changes: 180 additions & 0 deletions src/feishu/tools/__tests__/markdown-to-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest';
import {
parseInlineMarkdown,
markdownToBlocks,
markdownToSegments,
parseMarkdownTable,
buildTableDescendants,
batchBlocks,
BLOCK_TYPE,
LANGUAGE_MAP,
Expand Down Expand Up @@ -351,3 +354,180 @@ describe('batchBlocks', () => {
expect(batchBlocks([])).toEqual([]);
});
});

describe('parseMarkdownTable', () => {
it('should parse a GFM table with header separator', () => {
const table = parseMarkdownTable([
'| A | B | C |',
'| --- | --- | --- |',
'| 1 | 2 | 3 |',
'| 4 | 5 | 6 |',
]);
expect(table).not.toBeNull();
expect(table!.hasHeader).toBe(true);
expect(table!.columnSize).toBe(3);
expect(table!.rowSize).toBe(3); // header + 2 body rows
expect(table!.rows[0]).toEqual(['A', 'B', 'C']);
expect(table!.rows[1]).toEqual(['1', '2', '3']);
expect(table!.rows[2]).toEqual(['4', '5', '6']);
});

it('should recognize alignment separators (:--, --:, :-:)', () => {
const table = parseMarkdownTable([
'| L | C | R |',
'| :-- | :-: | --: |',
'| a | b | c |',
]);
expect(table).not.toBeNull();
expect(table!.hasHeader).toBe(true);
expect(table!.rowSize).toBe(2);
});

it('should pad ragged rows to the widest column count', () => {
const table = parseMarkdownTable([
'| A | B | C |',
'| --- | --- | --- |',
'| 1 | 2 |',
]);
expect(table!.columnSize).toBe(3);
expect(table!.rows[1]).toEqual(['1', '2', '']);
});

it('should treat a table without a separator as headerless', () => {
const table = parseMarkdownTable([
'| 1 | 2 |',
'| 3 | 4 |',
]);
expect(table).not.toBeNull();
expect(table!.hasHeader).toBe(false);
expect(table!.rowSize).toBe(2);
expect(table!.rows[0]).toEqual(['1', '2']);
});

it('should return null when there are no data rows', () => {
expect(parseMarkdownTable(['| --- | --- |'])).toBeNull();
});
});

describe('markdownToSegments', () => {
it('should emit a single blocks segment for flat markdown', () => {
const segments = markdownToSegments('# Title\n\nsome text');
expect(segments).toHaveLength(1);
expect(segments[0].type).toBe('blocks');
});

it('should split flat content and tables into ordered segments', () => {
const md = [
'intro paragraph',
'',
'| A | B |',
'| --- | --- |',
'| 1 | 2 |',
'',
'outro paragraph',
].join('\n');
const segments = markdownToSegments(md);
expect(segments.map((s) => s.type)).toEqual(['blocks', 'table', 'blocks']);
expect(segments[1].type).toBe('table');
if (segments[1].type === 'table') {
expect(segments[1].table.columnSize).toBe(2);
expect(segments[1].table.hasHeader).toBe(true);
}
});

it('should keep two adjacent tables as separate segments', () => {
const md = [
'| A |',
'| --- |',
'| 1 |',
'',
'| B |',
'| --- |',
'| 2 |',
].join('\n');
const segments = markdownToSegments(md);
expect(segments.map((s) => s.type)).toEqual(['table', 'table']);
});

it('should fall back to a code block for malformed pipe lines', () => {
// Only a separator line — not a usable table.
const segments = markdownToSegments('| --- | --- |');
expect(segments).toHaveLength(1);
expect(segments[0].type).toBe('blocks');
if (segments[0].type === 'blocks') {
expect(segments[0].blocks[0].block_type).toBe(BLOCK_TYPE.CODE);
}
});
});

describe('buildTableDescendants', () => {
it('should build a native table structure with correct block types', () => {
const table = parseMarkdownTable([
'| A | B |',
'| --- | --- |',
'| 1 | 2 |',
])!;
const { childrenId, descendants } = buildTableDescendants(table);

// Top-level attaches exactly the table block.
expect(childrenId).toHaveLength(1);
const tableBlock = descendants.find((b) => b.block_id === childrenId[0])!;
expect(tableBlock.block_type).toBe(BLOCK_TYPE.TABLE);
expect(tableBlock.table).toEqual({
property: { row_size: 2, column_size: 2, header_row: true },
});

// row_size * column_size cells, each referenced by the table in row-major order.
const cellIds = tableBlock.children as string[];
expect(cellIds).toHaveLength(4);
for (const id of cellIds) {
const cell = descendants.find((b) => b.block_id === id)!;
expect(cell.block_type).toBe(BLOCK_TYPE.TABLE_CELL);
expect((cell.children as string[]).length).toBe(1);
}

// Every cell has a text child; descendants total = 1 table + 4 cells + 4 texts.
expect(descendants).toHaveLength(9);
const textBlocks = descendants.filter((b) => b.block_type === BLOCK_TYPE.TEXT);
expect(textBlocks).toHaveLength(4);
});

it('should render inline markdown inside cells', () => {
const table = parseMarkdownTable([
'| **bold** | plain |',
'| --- | --- |',
'| x | y |',
])!;
const { descendants } = buildTableDescendants(table);
const headerText = descendants.find((b) => b.block_id === 't_0_0')!;
const elements = (headerText.text as { elements: unknown[] }).elements as Array<{
text_run: { content: string; text_element_style?: { bold?: boolean } };
}>;
expect(elements[0].text_run.content).toBe('bold');
expect(elements[0].text_run.text_element_style?.bold).toBe(true);
});

it('should give empty cells a single empty-content text_run (Feishu rejects empty elements)', () => {
const table = parseMarkdownTable([
'| A | B |',
'| --- | --- |',
'| 1 | |',
])!;
const { descendants } = buildTableDescendants(table);
const emptyCellText = descendants.find((b) => b.block_id === 't_1_1')!;
// Verified against the live Feishu API: `elements: []` returns 1770001 invalid param.
expect((emptyCellText.text as { elements: unknown[] }).elements).toEqual([
{ text_run: { content: '' } },
]);
});
});

describe('markdownToBlocks (table backward compat)', () => {
it('should render tables as a plaintext code block in the flat API', () => {
const blocks = markdownToBlocks('| A | B |\n| --- | --- |\n| 1 | 2 |');
expect(blocks).toHaveLength(1);
expect(blocks[0].block_type).toBe(BLOCK_TYPE.CODE);
const code = blocks[0].code as { language: number };
expect(code.language).toBe(1); // plaintext
});
});
Loading
Loading