qwen-code/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx
ermin.zem 5c82857fea
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
Add harness infrastructure for web-shell package (#6517)
* test(web-shell): add browser and lint harness

* test(web-shell): harden browser smoke harness

* fix(web-shell): guard mock daemon model state

* test(web-shell): remove unused scenario harness

* fix(web-shell): remove stale lint disables

* test(web-shell): make matchMedia stub writable

* fix(web-shell): exclude tests from package typecheck

* test(web-shell): tighten mock daemon route contract

* Update packages/web-shell/client/e2e/utils/mockDaemon.ts

Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>

* test(web-shell): clear stale SSE connections

* ci(web-shell): gate smoke on full CI profile

---------

Co-authored-by: ermin.zem <ermin.zem@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
2026-07-09 08:11:58 +00:00

1969 lines
56 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StrictMode, act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider, type WebShellLanguage } from '../../i18n';
import { immediateClipboardWrite } from '../../test/reactHarness';
import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
const originalDocumentHidden = Object.getOwnPropertyDescriptor(
document,
'hidden',
);
const originalElementFromPoint = document.elementFromPoint;
const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column';
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.restoreAllMocks();
vi.useRealTimers();
document.getSelection()?.removeAllRanges();
if (originalDocumentHidden) {
Object.defineProperty(document, 'hidden', originalDocumentHidden);
} else {
Reflect.deleteProperty(document, 'hidden');
}
if (originalElementFromPoint) {
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
});
} else {
Reflect.deleteProperty(document, 'elementFromPoint');
}
});
function renderTableContent(
children: ReactNode,
language: WebShellLanguage = 'en',
fallback?: ReactNode,
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language={language}>
<EnhancedMarkdownTable fallback={fallback}>
{children}
</EnhancedMarkdownTable>
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}
function renderTable(language: WebShellLanguage = 'en'): HTMLElement {
return renderTableContent(
[
<thead key="head">
<tr>
<th>Team</th>
<th>Score</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>Alpha</td>
<td>10</td>
</tr>
<tr>
<td>Beta</td>
<td>2</td>
</tr>
<tr>
<td>Gamma</td>
<td>30</td>
</tr>
</tbody>,
],
language,
);
}
function renderWideTable(): HTMLElement {
return renderTableContent([
<thead key="head">
<tr>
<th>Team</th>
<th>Region</th>
<th>Score</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>Alpha</td>
<td>US</td>
<td>10</td>
</tr>
<tr>
<td>Beta</td>
<td>EMEA</td>
<td>2</td>
</tr>
</tbody>,
]);
}
function click(el: Element): void {
act(() => {
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
function inputValue(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
function selectValue(select: HTMLSelectElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(
HTMLSelectElement.prototype,
'value',
)?.set;
act(() => {
setter?.call(select, value);
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function rowTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('tbody tr')]
.filter((row) => row.querySelectorAll('td').length > 1)
.map((row) =>
[...row.querySelectorAll('td')]
.slice(1)
.map((cell) => cell.textContent ?? '')
.join('|'),
);
}
function textButton(container: HTMLElement, text: string): HTMLButtonElement {
const el = [...container.querySelectorAll('button')].find(
(button) => button.textContent === text,
);
expect(el).not.toBeNull();
return el!;
}
function textButtonContaining(
container: HTMLElement,
text: string,
): HTMLButtonElement {
const el = [...container.querySelectorAll('button')].find((button) =>
button.textContent?.includes(text),
);
expect(el).not.toBeNull();
return el!;
}
function mockClipboard() {
const writeText = vi.fn(() => immediateClipboardWrite());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
return writeText;
}
function mockClipboardRejecting() {
const writeText = vi.fn(() => Promise.reject(new Error('copy failed')));
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
return writeText;
}
function mockClipboardDelayed() {
let resolveCopy: (() => void) | undefined;
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveCopy = resolve;
}),
);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
return {
writeText,
resolveCopy: () => {
expect(resolveCopy).toBeDefined();
resolveCopy?.();
},
};
}
function button(container: HTMLElement, label: string): HTMLButtonElement {
const el = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
expect(el).not.toBeNull();
return el!;
}
function dataRows(container: HTMLElement): HTMLTableRowElement[] {
return [
...container.querySelectorAll<HTMLTableRowElement>('tbody tr'),
].filter((row) => row.querySelectorAll('td').length > 1);
}
function dataCell(
container: HTMLElement,
rowIndex: number,
visibleColumnIndex: number,
): HTMLTableCellElement {
const row = dataRows(container)[rowIndex];
expect(row).toBeDefined();
const cell = [...row!.querySelectorAll<HTMLTableCellElement>('td')].slice(1)[
visibleColumnIndex
];
expect(cell).toBeDefined();
return cell!;
}
function dragCells(from: Element, to: Element): void {
act(() => {
from.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
from.dispatchEvent(
new MouseEvent('mouseout', { bubbles: true, relatedTarget: to }),
);
to.dispatchEvent(
new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
}
function dispatchCopy(target: Element) {
const setData = vi.fn();
const event = new Event('copy', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { setData },
});
act(() => {
target.dispatchEvent(event);
});
return { event, setData };
}
function dragColumnElements(from: Element, to: Element): void {
const data = new Map<string, string>();
const dataTransfer = {
dropEffect: '',
effectAllowed: '',
get types() {
return [...data.keys()];
},
setData: vi.fn((type: string, value: string) => data.set(type, value)),
getData: vi.fn((type: string) => data.get(type) ?? ''),
};
act(() => {
from.dispatchEvent(
Object.assign(new Event('dragstart', { bubbles: true }), {
dataTransfer,
}),
);
to.dispatchEvent(
Object.assign(
new Event('dragover', { bubbles: true, cancelable: true }),
{
dataTransfer,
},
),
);
to.dispatchEvent(
Object.assign(new Event('drop', { bubbles: true, cancelable: true }), {
dataTransfer,
}),
);
from.dispatchEvent(new Event('dragend', { bubbles: true }));
});
}
function dragColumn(
container: HTMLElement,
fromLabel: string,
toLabel: string,
): void {
dragColumnElements(button(container, fromLabel), button(container, toLabel));
}
function dropExternalColumn(container: HTMLElement, toLabel: string): void {
const dataTransfer = {
dropEffect: '',
effectAllowed: '',
types: ['text/plain'],
setData: vi.fn(),
getData: vi.fn(() => ''),
};
const to = button(container, toLabel);
act(() => {
to.dispatchEvent(
Object.assign(
new Event('dragover', { bubbles: true, cancelable: true }),
{
dataTransfer,
},
),
);
to.dispatchEvent(
Object.assign(new Event('drop', { bubbles: true, cancelable: true }), {
dataTransfer,
}),
);
});
}
function dropForgedColumn(container: HTMLElement, toLabel: string): void {
const dataTransfer = {
dropEffect: '',
effectAllowed: '',
types: [COLUMN_DRAG_MIME],
setData: vi.fn(),
getData: vi.fn(() => '2'),
};
const to = button(container, toLabel);
act(() => {
to.dispatchEvent(
Object.assign(
new Event('dragover', { bubbles: true, cancelable: true }),
{
dataTransfer,
},
),
);
to.dispatchEvent(
Object.assign(new Event('drop', { bubbles: true, cancelable: true }), {
dataTransfer,
}),
);
});
}
function touchEvent(
type: string,
touches: Array<Pick<Touch, 'clientX' | 'clientY'>>,
): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'touches', { value: touches });
Object.defineProperty(event, 'changedTouches', { value: touches });
return event;
}
describe('EnhancedMarkdownTable', () => {
it('sorts numeric columns from header clicks', () => {
const container = renderTable();
click(button(container, 'Sort by Score'));
expect(rowTexts(container)).toEqual(['Beta|2', 'Alpha|10', 'Gamma|30']);
expect(button(container, 'Sort by Score, ascending')).toBeDefined();
expect(
button(container, 'Sort by Score, ascending')
.closest('th')
?.getAttribute('aria-sort'),
).toBe('ascending');
click(button(container, 'Sort by Score, ascending'));
expect(rowTexts(container)).toEqual(['Gamma|30', 'Alpha|10', 'Beta|2']);
expect(button(container, 'Sort by Score, descending')).toBeDefined();
expect(
button(container, 'Sort by Score, descending')
.closest('th')
?.getAttribute('aria-sort'),
).toBe('descending');
click(button(container, 'Sort by Score, descending'));
expect(rowTexts(container)).toEqual(['Alpha|10', 'Beta|2', 'Gamma|30']);
expect(button(container, 'Sort by Score')).toBeDefined();
});
it('filters rows from a column value menu', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
const search = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-option-search-0"]',
);
expect(search?.placeholder).toBe('Search filter values');
expect(container.textContent).toContain('Select current results');
const beta = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-filter-option-0-1"]',
);
expect(beta).not.toBeNull();
click(beta!);
click(
[...container.querySelectorAll('button')].find(
(el) => el.textContent === 'Confirm',
)!,
);
expect(rowTexts(container)).toEqual(['Alpha|10', 'Gamma|30']);
expect(container.textContent).toContain('2/3 rows');
});
it('applies a custom number filter', () => {
const container = renderTable();
click(button(container, 'Filter Score'));
const numberFilter = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-number-filter-1"]',
);
expect(numberFilter).not.toBeNull();
inputValue(numberFilter!, '10');
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Gamma|30']);
expect(container.textContent).toContain('1/3 rows');
});
it.each<[string, string[]]>([
['gte', ['Alpha|10', 'Gamma|30']],
['lt', ['Beta|2']],
['lte', ['Alpha|10', 'Beta|2']],
])('applies the %s number filter operator', (operator, expectedRows) => {
const container = renderTable();
click(button(container, 'Filter Score'));
selectValue(
container.querySelector<HTMLSelectElement>(
'select[name="markdown-table-number-operator-1"]',
)!,
operator,
);
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-number-filter-1"]',
)!,
'10',
);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(expectedRows);
});
it('applies text filter operators and reset', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
expect(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-option-search-0"]',
),
).toBe(document.activeElement);
selectValue(
container.querySelector<HTMLSelectElement>(
'select[name="markdown-table-text-operator-0"]',
)!,
'equals',
);
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-text-filter-0"]',
)!,
'Alpha',
);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Alpha|10']);
click(button(container, 'Filter Team'));
click(textButton(container, 'Reset'));
expect(rowTexts(container)).toEqual(['Alpha|10', 'Beta|2', 'Gamma|30']);
click(button(container, 'Filter Team'));
selectValue(
container.querySelector<HTMLSelectElement>(
'select[name="markdown-table-text-operator-0"]',
)!,
'startsWith',
);
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-text-filter-0"]',
)!,
'Ga',
);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Gamma|30']);
click(button(container, 'Filter Team'));
click(textButton(container, 'Reset'));
click(button(container, 'Filter Team'));
selectValue(
container.querySelector<HTMLSelectElement>(
'select[name="markdown-table-text-operator-0"]',
)!,
'endsWith',
);
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-text-filter-0"]',
)!,
'ta',
);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Beta|2']);
click(button(container, 'Filter Team'));
click(textButton(container, 'Reset'));
click(button(container, 'Filter Team'));
selectValue(
container.querySelector<HTMLSelectElement>(
'select[name="markdown-table-text-operator-0"]',
)!,
'notEquals',
);
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-text-filter-0"]',
)!,
'Beta',
);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Alpha|10', 'Gamma|30']);
});
it('applies a between number filter', () => {
const container = renderTable();
click(button(container, 'Filter Score'));
selectValue(
container.querySelector<HTMLSelectElement>(
'select[name="markdown-table-number-operator-1"]',
)!,
'between',
);
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-number-filter-1"]',
)!,
'10',
);
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-number-filter-to-1"]',
)!,
'2',
);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Alpha|10', 'Beta|2']);
});
it('sorts decimal values without leading zero numerically', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th>Value</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>.5</td>
</tr>
<tr>
<td>-.75</td>
</tr>
<tr>
<td>.123</td>
</tr>
</tbody>,
]);
click(button(container, 'Sort by Value'));
expect(rowTexts(container)).toEqual(['-.75', '.123', '.5']);
});
it('sorts currency values numerically', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th>Amount</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>$100</td>
</tr>
<tr>
<td>$20</td>
</tr>
<tr>
<td>$3</td>
</tr>
</tbody>,
]);
click(button(container, 'Sort by Amount'));
expect(rowTexts(container)).toEqual(['$3', '$20', '$100']);
});
it('filters percentage values as fractions', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th>Ratio</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>40%</td>
</tr>
<tr>
<td>0.5</td>
</tr>
<tr>
<td>75%</td>
</tr>
</tbody>,
]);
click(button(container, 'Filter Ratio'));
inputValue(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-number-filter-0"]',
)!,
'.45',
);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['0.5', '75%']);
});
it('preserves markdown table cell alignment', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th style={{ textAlign: 'right' }}>Amount</th>
<th style={{ textAlign: 'center' }}>Status</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td style={{ textAlign: 'right' }}>$10</td>
<td style={{ textAlign: 'center' }}>Done</td>
</tr>
</tbody>,
]);
const headerCells = [
...container.querySelectorAll<HTMLTableCellElement>('thead th'),
].slice(1);
expect(headerCells[0]?.style.textAlign).toBe('right');
expect(headerCells[1]?.style.textAlign).toBe('center');
expect(dataCell(container, 0, 0).style.textAlign).toBe('right');
expect(dataCell(container, 0, 1).style.textAlign).toBe('center');
click(button(container, 'View details for row 1'));
const detailElements = [...container.querySelectorAll<HTMLElement>('div')];
expect(
detailElements.find(
(element) =>
element.textContent === '$10' && element.style.textAlign === 'right',
),
).toBeDefined();
expect(
detailElements.find(
(element) =>
element.textContent === 'Done' &&
element.style.textAlign === 'center',
),
).toBeDefined();
});
it('quick copies the visible sorted table', () => {
const writeText = mockClipboard();
const container = renderTable();
click(button(container, 'Sort by Score'));
click(textButton(container, 'Quick copy'));
expect(writeText).toHaveBeenCalledWith(
['Team\tScore', 'Beta\t2', 'Alpha\t10', 'Gamma\t30'].join('\n'),
);
});
it('sanitizes spreadsheet formulas when copying TSV', () => {
const writeText = mockClipboard();
const hiddenFormula = '\u200B=2+2';
const container = renderTableContent([
<thead key="head">
<tr>
<th>Name</th>
<th>Formula</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>Alpha</td>
<td>=1+1</td>
</tr>
<tr>
<td>Beta</td>
<td>-10</td>
</tr>
<tr>
<td>Gamma</td>
<td>{hiddenFormula}</td>
</tr>
</tbody>,
]);
click(textButton(container, 'Quick copy'));
expect(writeText).toHaveBeenCalledWith(
[
'Name\tFormula',
"Alpha\t'=1+1",
"Beta\t'-10",
`Gamma\t'${hiddenFormula}`,
].join('\n'),
);
dragCells(dataCell(container, 0, 1), dataCell(container, 0, 1));
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenLastCalledWith("'=1+1");
});
it('hides columns and restores them from the toolbar', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
click(textButton(container, 'Hide column'));
expect(rowTexts(container)).toEqual(['10', '2', '30']);
expect(container.textContent).toContain('Show 1 hidden column');
expect(container.querySelector('thead')?.textContent).not.toContain('Team');
click(textButton(container, 'Show 1 hidden column'));
expect(rowTexts(container)).toEqual(['Alpha|10', 'Beta|2', 'Gamma|30']);
});
it('quick copy skips hidden columns', () => {
const writeText = mockClipboard();
const container = renderTable();
click(button(container, 'Filter Team'));
click(textButton(container, 'Hide column'));
click(textButton(container, 'Quick copy'));
expect(writeText).toHaveBeenCalledWith(
['Score', '10', '2', '30'].join('\n'),
);
});
it('resizes a column from its header handle', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 160 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'220px',
);
expect(dataCell(container, 0, 0).style.width).toBe('220px');
});
it('clamps resized columns to the minimum width', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 0 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'80px',
);
expect(dataCell(container, 0, 0).style.width).toBe('80px');
});
it('clamps resized columns to the maximum width', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 900 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'640px',
);
expect(dataCell(container, 0, 0).style.width).toBe('640px');
});
it('stops resizing a column when the window blurs', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(new Event('blur'));
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 220 }),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('flushes pending resize width when the window blurs', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 200 }),
);
window.dispatchEvent(new Event('blur'));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'260px',
);
});
it('stops resizing a column when page visibility changes', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
Object.defineProperty(document, 'hidden', {
configurable: true,
value: true,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 220 }),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('keeps resizing when page visibility changes while visible', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
Object.defineProperty(document, 'hidden', {
configurable: true,
value: false,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 220 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'280px',
);
});
it('resizes a column with keyboard arrows', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
key: 'ArrowRight',
}),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'176px',
);
act(() => {
resize.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
key: 'ArrowLeft',
}),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('ignores keyboard resize arrows with modifiers', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
ctrlKey: true,
key: 'ArrowRight',
}),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('shows column move handles only for the active column', () => {
const container = renderWideTable();
const teamHandle = button(container, 'Move Team');
const scoreHandle = button(container, 'Move Score');
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
expect(scoreHandle.className).not.toContain('reorderHandleVisible');
expect(scoreHandle.tabIndex).toBe(-1);
click(button(container, 'Sort by Team'));
expect(teamHandle.className).toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(0);
expect(scoreHandle.className).not.toContain('reorderHandleVisible');
expect(scoreHandle.tabIndex).toBe(-1);
expect(
button(container, 'Sort by Team, ascending').closest('th')?.className,
).toContain('activeHeaderCell');
});
it('clears the active column move handle on outside click, cell selection, and Escape', () => {
const container = renderWideTable();
const teamHandle = button(container, 'Move Team');
click(button(container, 'Sort by Team'));
act(() => {
document.body.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
click(button(container, 'Sort by Team, ascending'));
act(() => {
dataCell(container, 0, 0).dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
click(button(container, 'Sort by Team, descending'));
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
});
it('keeps the active column when Escape closes an open filter menu first', () => {
const container = renderWideTable();
const teamHandle = button(container, 'Move Team');
click(button(container, 'Sort by Team'));
click(button(container, 'Filter Team'));
expect(container.textContent).toContain('Custom filter');
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
expect(container.textContent).not.toContain('Custom filter');
expect(teamHandle.className).toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(0);
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
});
it('reorders columns and quick copies in the visible order', () => {
const writeText = mockClipboard();
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
expect(rowTexts(container)).toEqual(['10|Alpha|US', '2|Beta|EMEA']);
click(textButton(container, 'Quick copy'));
expect(writeText).toHaveBeenCalledWith(
['Score\tTeam\tRegion', '10\tAlpha\tUS', '2\tBeta\tEMEA'].join('\n'),
);
});
it('ignores external drops on column move handles', () => {
const container = renderWideTable();
dropExternalColumn(container, 'Move Score');
expect(rowTexts(container)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
});
it('ignores forged column drag payloads', () => {
const container = renderWideTable();
dropForgedColumn(container, 'Move Team');
expect(rowTexts(container)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
});
it('ignores column drags from another table', () => {
const source = renderWideTable();
const target = renderWideTable();
dragColumnElements(
button(source, 'Move Score'),
button(target, 'Move Team'),
);
expect(rowTexts(source)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
expect(rowTexts(target)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
});
it('drops reordered columns on the target header cell', () => {
const container = renderWideTable();
dragColumnElements(
button(container, 'Move Score'),
button(container, 'Sort by Team'),
);
expect(rowTexts(container)).toEqual(['10|Alpha|US', '2|Beta|EMEA']);
});
it('preserves hidden column slots when reordering visible columns', () => {
const container = renderWideTable();
click(button(container, 'Filter Region'));
click(textButton(container, 'Hide column'));
dragColumn(container, 'Move Score', 'Move Team');
click(textButton(container, 'Show 1 hidden column'));
expect(rowTexts(container)).toEqual(['10|US|Alpha', '2|EMEA|Beta']);
});
it('keeps hidden columns out of reordered selections', () => {
const writeText = mockClipboard();
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
click(button(container, 'Filter Region'));
click(textButton(container, 'Hide column'));
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenCalledWith(['10\tAlpha', '2\tBeta'].join('\n'));
});
it('toggles sticky classes for the action column and first visible column', () => {
const container = renderWideTable();
click(textButton(container, 'Freeze first column'));
expect(textButton(container, 'Unfreeze first column')).toBeDefined();
expect(container.querySelector('thead th')?.className).toContain(
'stickyActionHeaderCell',
);
expect(
button(container, 'Sort by Team').closest('th')?.className,
).toContain('frozenHeaderCell');
expect(dataCell(container, 0, 0).className).toContain('frozenCell');
click(button(container, 'Sort by Team'));
expect(
button(container, 'Sort by Team, ascending').closest('th')?.className,
).toContain('activeHeaderCell');
expect(
button(container, 'Sort by Team, ascending').closest('th')?.className,
).toContain('frozenHeaderCell');
dragColumn(container, 'Move Score', 'Move Team');
expect(
button(container, 'Sort by Score').closest('th')?.className,
).toContain('frozenHeaderCell');
click(textButton(container, 'Unfreeze first column'));
expect(container.textContent).toContain('Freeze first column');
expect(
button(container, 'Sort by Score').closest('th')?.className,
).not.toContain('frozenHeaderCell');
});
it('keeps selected cell classes visible on a frozen column', () => {
const container = renderWideTable();
click(textButton(container, 'Freeze first column'));
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
expect(container.textContent).toContain('4 cells selected');
expect(dataCell(container, 0, 0).className).toContain('frozenCell');
expect(dataCell(container, 0, 0).className).toContain('selectedCell');
expect(dataCell(container, 0, 1).className).toContain('selectedCell');
});
it('copies reordered selections from the keyboard copy event', () => {
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
const scroller = container.querySelector<HTMLElement>('div[tabindex="0"]');
expect(scroller).not.toBeNull();
const { event, setData } = dispatchCopy(scroller!);
expect(event.defaultPrevented).toBe(true);
expect(setData).toHaveBeenCalledWith(
'text/plain',
['10\tAlpha', '2\tBeta'].join('\n'),
);
});
it('copies reordered selections after freezing the first column', () => {
const writeText = mockClipboard();
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
click(textButton(container, 'Freeze first column'));
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenCalledWith(['10\tAlpha', '2\tBeta'].join('\n'));
});
it('shows checkmark feedback after quick copy', async () => {
vi.useFakeTimers();
mockClipboard();
const container = renderTable();
await act(async () => {
textButton(container, 'Quick copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('✓');
expect(container.textContent).toContain('Copied!');
expect(container.textContent).not.toContain('Quick copy');
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(container.textContent).not.toContain('✓');
expect(container.textContent).toContain('Quick copy');
});
it('keeps copy feedback working under StrictMode effect replay', async () => {
mockClipboard();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<StrictMode>
<I18nProvider language="en">
<EnhancedMarkdownTable>
<thead>
<tr>
<th>Team</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alpha</td>
</tr>
</tbody>
</EnhancedMarkdownTable>
</I18nProvider>
</StrictMode>,
);
});
mounted.push({ root, container });
await act(async () => {
textButton(container, 'Quick copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('Copied!');
});
it('keeps quick copy feedback visible for the latest click', async () => {
vi.useFakeTimers();
mockClipboard();
const container = renderTable();
await act(async () => {
textButton(container, 'Quick copy').click();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(1000);
textButtonContaining(container, 'Copied!').click();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).toContain('Copied!');
expect(container.textContent).not.toContain('Quick copy');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).not.toContain('Copied!');
expect(container.textContent).toContain('Quick copy');
});
it('resets quick copy feedback when visible table data changes', async () => {
vi.useFakeTimers();
mockClipboard();
const container = renderTable();
await act(async () => {
textButton(container, 'Quick copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('Copied!');
click(button(container, 'Sort by Score'));
expect(container.textContent).not.toContain('Copied!');
expect(container.textContent).toContain('Quick copy');
});
it('ignores stale quick copy feedback after visible data changes', async () => {
const clipboard = mockClipboardDelayed();
const container = renderTable();
act(() => {
textButton(container, 'Quick copy').click();
});
click(button(container, 'Sort by Score'));
await act(async () => {
clipboard.resolveCopy();
await Promise.resolve();
});
expect(container.textContent).not.toContain('Copied!');
expect(container.textContent).toContain('Quick copy');
});
it('resets quick copy feedback when filters change visible rows', async () => {
vi.useFakeTimers();
mockClipboard();
const container = renderTable();
await act(async () => {
textButton(container, 'Quick copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('Copied!');
click(button(container, 'Filter Team'));
const beta = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-filter-option-0-1"]',
);
expect(beta).not.toBeNull();
click(beta!);
click(textButton(container, 'Confirm'));
expect(container.textContent).not.toContain('Copied!');
expect(container.textContent).toContain('Quick copy');
});
it('does not show copied feedback when clipboard write fails', async () => {
mockClipboardRejecting();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const container = renderTable();
await act(async () => {
textButton(container, 'Quick copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(warn).toHaveBeenCalled();
expect(container.textContent).not.toContain('Copied!');
expect(container.textContent).toContain('Quick copy');
});
it('shows checkmark feedback after copying a selection', async () => {
vi.useFakeTimers();
mockClipboard();
const container = renderTable();
dragCells(dataCell(container, 0, 0), dataCell(container, 0, 0));
await act(async () => {
textButton(container, 'Copy TSV').click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('✓');
expect(container.textContent).toContain('Copied!');
expect(container.textContent).not.toContain('Copy TSV');
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(container.textContent).not.toContain('✓');
expect(container.textContent).toContain('Copy TSV');
});
it('resets selection copy feedback when the selection changes', async () => {
vi.useFakeTimers();
mockClipboard();
const container = renderTable();
dragCells(dataCell(container, 0, 0), dataCell(container, 0, 0));
await act(async () => {
textButton(container, 'Copy TSV').click();
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain('Copied!');
dragCells(dataCell(container, 1, 0), dataCell(container, 1, 0));
expect(container.textContent).not.toContain('Copied!');
expect(container.textContent).toContain('Copy TSV');
});
it('ignores stale selection copy feedback after selection changes', async () => {
const clipboard = mockClipboardDelayed();
const container = renderTable();
dragCells(dataCell(container, 0, 0), dataCell(container, 0, 0));
act(() => {
textButton(container, 'Copy TSV').click();
});
dragCells(dataCell(container, 1, 0), dataCell(container, 1, 0));
await act(async () => {
clipboard.resolveCopy();
await Promise.resolve();
});
expect(container.textContent).not.toContain('Copied!');
expect(container.textContent).toContain('Copy TSV');
});
it('resets interactive state when table columns change', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const render = (children: ReactNode) => {
act(() => {
root.render(
<I18nProvider language="en">
<EnhancedMarkdownTable>{children}</EnhancedMarkdownTable>
</I18nProvider>,
);
});
};
render([
<thead key="head">
<tr>
<th>Team</th>
<th>Score</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>Alpha</td>
<td>10</td>
</tr>
</tbody>,
]);
click(button(container, 'Filter Team'));
click(textButton(container, 'Hide column'));
expect(rowTexts(container)).toEqual(['10']);
render([
<thead key="head">
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>Beta</td>
<td>20</td>
</tr>
</tbody>,
]);
expect(rowTexts(container)).toEqual(['Beta|20']);
mounted.push({ root, container });
});
it('clears hidden column filters and sort', () => {
const container = renderTable();
click(button(container, 'Sort by Team'));
click(button(container, 'Sort by Team, ascending'));
click(button(container, 'Filter Team'));
const beta = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-filter-option-0-1"]',
);
expect(beta).not.toBeNull();
click(beta!);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Gamma|30', 'Alpha|10']);
click(button(container, 'Filter Team'));
click(textButton(container, 'Hide column'));
expect(rowTexts(container)).toEqual(['10', '2', '30']);
expect(container.textContent).toContain('3 rows');
});
it('selection copy skips hidden columns between selected cells', () => {
const writeText = mockClipboard();
const container = renderWideTable();
click(button(container, 'Filter Region'));
click(textButton(container, 'Hide column'));
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenCalledWith(['Alpha\t10', 'Beta\t2'].join('\n'));
});
it('copies selected cells from the focused keyboard copy event', () => {
const container = renderTable();
const outsideButton = document.createElement('button');
document.body.appendChild(outsideButton);
outsideButton.focus();
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
const scroller = container.querySelector<HTMLElement>('div[tabindex="0"]');
expect(scroller).not.toBeNull();
expect(document.activeElement).toBe(scroller);
const { event, setData } = dispatchCopy(document.activeElement!);
expect(event.defaultPrevented).toBe(true);
expect(setData).toHaveBeenCalledWith(
'text/plain',
['Alpha\t10', 'Beta\t2'].join('\n'),
);
outsideButton.remove();
});
it('keeps native text selection copy behavior', () => {
const container = renderTable();
const selection = document.getSelection();
const range = document.createRange();
range.selectNodeContents(dataCell(container, 0, 0));
act(() => {
selection?.removeAllRanges();
selection?.addRange(range);
});
const scroller = container.querySelector<HTMLElement>('div[tabindex="0"]');
expect(scroller).not.toBeNull();
const { event, setData } = dispatchCopy(scroller!);
expect(event.defaultPrevented).toBe(false);
expect(setData).not.toHaveBeenCalled();
});
it('keeps cross-boundary native text selection copy behavior', () => {
const container = renderTable();
const outside = document.createElement('span');
outside.textContent = 'outside';
document.body.appendChild(outside);
const selection = document.getSelection();
const range = document.createRange();
const startNode = dataCell(container, 0, 0).firstChild;
const endNode = outside.firstChild;
expect(startNode).not.toBeNull();
expect(endNode).not.toBeNull();
range.setStart(startNode!, 0);
range.setEnd(endNode!, outside.textContent.length);
act(() => {
selection?.removeAllRanges();
selection?.addRange(range);
});
const scroller = container.querySelector<HTMLElement>('div[tabindex="0"]');
expect(scroller).not.toBeNull();
const { event, setData } = dispatchCopy(scroller!);
expect(event.defaultPrevented).toBe(false);
expect(setData).not.toHaveBeenCalled();
outside.remove();
});
it('prevents native text selection when selecting cells with the mouse', () => {
const container = renderTable();
const event = new MouseEvent('mousedown', {
bubbles: true,
button: 0,
cancelable: true,
});
act(() => {
dataCell(container, 0, 0).dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(true);
});
it('stops extending a selection after window blur', () => {
const writeText = mockClipboard();
const container = renderTable();
const from = dataCell(container, 0, 0);
const to = dataCell(container, 1, 1);
act(() => {
from.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
window.dispatchEvent(new Event('blur'));
to.dispatchEvent(
new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }),
);
});
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenCalledWith('Alpha');
});
it('selects cells with touch drag', () => {
const writeText = mockClipboard();
const container = renderTable();
const from = dataCell(container, 0, 0);
const to = dataCell(container, 1, 1);
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: vi.fn(() => to),
});
act(() => {
from.dispatchEvent(
touchEvent('touchstart', [{ clientX: 1, clientY: 1 }]),
);
from.dispatchEvent(touchEvent('touchmove', [{ clientX: 2, clientY: 2 }]));
from.dispatchEvent(touchEvent('touchend', []));
});
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenCalledWith(['Alpha\t10', 'Beta\t2'].join('\n'));
});
it('resets filter menu draft state when switching columns', () => {
const container = renderWideTable();
click(button(container, 'Filter Team'));
const teamSearch = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-option-search-0"]',
);
expect(teamSearch).not.toBeNull();
inputValue(teamSearch!, 'Al');
click(button(container, 'Filter Score'));
const scoreSearch = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-option-search-2"]',
);
expect(scoreSearch?.value).toBe('');
});
it('closes the filter menu when clicking outside it', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
expect(container.textContent).toContain('Custom filter');
act(() => {
dataCell(container, 0, 0).dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
});
expect(container.textContent).not.toContain('Custom filter');
});
it('returns focus to the filter trigger when Escape closes the menu', async () => {
const container = renderTable();
const filterButton = button(container, 'Filter Team');
click(filterButton);
expect(container.textContent).toContain('Custom filter');
expect(
container.querySelector<HTMLInputElement>(
'input[name="markdown-table-option-search-0"]',
),
).toBe(document.activeElement);
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
await act(async () => {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
});
expect(container.textContent).not.toContain('Custom filter');
expect(document.activeElement).toBe(filterButton);
});
it('keeps Tab focus within the filter dialog', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
const menu = container.querySelector<HTMLElement>('[role="dialog"]');
expect(menu).not.toBeNull();
const focusableElements = Array.from(
menu!.querySelectorAll<HTMLElement>('button, input, select'),
).filter((element) => !element.hasAttribute('disabled'));
expect(focusableElements.length).toBeGreaterThan(1);
focusableElements[0]!.focus();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Tab',
shiftKey: true,
}),
);
});
expect(document.activeElement).toBe(
focusableElements[focusableElements.length - 1],
);
});
it('does not trap Tab when focus is outside the filter dialog', () => {
const container = renderTable();
const outsideButton = document.createElement('button');
document.body.appendChild(outsideButton);
click(button(container, 'Filter Team'));
outsideButton.focus();
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Tab',
});
act(() => {
document.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(false);
expect(document.activeElement).toBe(outsideButton);
outsideButton.remove();
});
it('closes the filter menu on scroll', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
expect(container.textContent).toContain('Custom filter');
act(() => {
document.dispatchEvent(new Event('scroll'));
});
expect(container.textContent).not.toContain('Custom filter');
});
it('does not offer hiding the last visible column', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
click(textButton(container, 'Hide column'));
click(button(container, 'Filter Score'));
expect(
[...container.querySelectorAll('button')].some(
(el) => el.textContent === 'Hide column',
),
).toBe(false);
});
it('shows row details for visible columns', () => {
const container = renderTable();
const detailsButton = button(container, 'View details for row 2');
click(detailsButton);
const detailsId = detailsButton.getAttribute('aria-controls');
expect(detailsId).toBeTruthy();
expect(container.ownerDocument.getElementById(detailsId!)).not.toBeNull();
expect(container.textContent).toContain('Row details');
expect(container.textContent).toContain('Team');
expect(container.textContent).toContain('Beta');
expect(container.textContent).toContain('Score');
expect(container.textContent).toContain('2');
click(button(container, 'Filter Team'));
click(textButton(container, 'Hide column'));
expect(container.textContent).not.toContain('Beta');
expect(container.textContent).toContain('Score');
expect(container.textContent).toContain('2');
});
it('closes row details when the row is filtered out', () => {
const container = renderTable();
click(button(container, 'View details for row 2'));
click(button(container, 'Filter Team'));
const beta = container.querySelector<HTMLInputElement>(
'input[name="markdown-table-filter-option-0-1"]',
);
expect(beta).not.toBeNull();
click(beta!);
click(textButton(container, 'Confirm'));
expect(rowTexts(container)).toEqual(['Alpha|10', 'Gamma|30']);
expect(container.textContent).not.toContain('Row details');
expect(container.textContent).not.toContain('Beta');
});
it('falls back for oversized tables', () => {
const rows = Array.from({ length: 501 }, (_, index) => (
<tr key={index}>
<td>{index}</td>
</tr>
));
const container = renderTableContent(
[
<thead key="head">
<tr>
<th>Value</th>
</tr>
</thead>,
<tbody key="body">{rows}</tbody>,
],
'en',
<table>
<tbody>
<tr>
<td>plain fallback</td>
</tr>
</tbody>
</table>,
);
expect(container.textContent).toContain('plain fallback');
expect(container.textContent).not.toContain('Quick copy');
});
it('falls back when a table has no parsed columns', () => {
const container = renderTableContent(
<tbody>
<tr />
</tbody>,
'en',
<table>
<tbody>
<tr>
<td>plain fallback</td>
</tr>
</tbody>
</table>,
);
expect(container.textContent).toContain('plain fallback');
expect(container.textContent).not.toContain('Quick copy');
});
it('shows a distinct message for empty tables', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th>Value</th>
</tr>
</thead>,
]);
expect(container.textContent).toContain('This table has no data.');
expect(container.textContent).not.toContain('No rows match the filters.');
});
it('keeps footer rows visible', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th>Item</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>Alpha</td>
</tr>
</tbody>,
<tfoot key="foot">
<tr>
<td>Total</td>
</tr>
</tfoot>,
]);
expect(rowTexts(container)).toEqual(['Alpha', 'Total']);
});
it('parses direct table row children', () => {
const container = renderTableContent([
<tr key="head">
<th>Team</th>
<th>Score</th>
</tr>,
<tr key="alpha">
<td>Alpha</td>
<td>10</td>
</tr>,
]);
expect(container.textContent).toContain('Quick copy');
expect(rowTexts(container)).toEqual(['Alpha|10']);
});
it('localizes the new controls', () => {
const container = renderTable('zh-CN');
expect(container.textContent).toContain('快捷复制');
expect(container.textContent).toContain('冻结首列');
expect(container.textContent).toContain('详情');
click(button(container, '筛选 Team'));
expect(container.textContent).toContain('隐藏列');
});
});