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
10 changes: 8 additions & 2 deletions packages/plugin-gantt/demo/main.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,9 +32,11 @@ const GANTT_ZH = {
jumpToToday: '跳到今天', today: '今天', showTaskList: '显示任务列表', hideTaskList: '隐藏任务列表',
viewMode: '时间粒度', enterFullscreen: '进入全屏', exitFullscreen: '退出全屏',
criticalPath: '高亮关键路径', autoSchedule: '自动排程依赖', exportPng: '导出 PNG',
exportPdf: '导出 PDF', saveLayout: '保存布局',
thisWeek: '本周', thisMonth: '本月',
undo: '撤销', redo: '重做',
},
viewMode: { day: '日', week: '周', month: '月', quarter: '季' },
viewMode: { day: '日', week: '周', month: '月', quarter: '季', year: '年' },
row: { expand: '展开', collapse: '折叠' },
aria: { taskList: '任务列表' },
tooltip: { days: '天' },
Expand DownExpand Up@@ -362,7 +364,9 @@ function App() {
) : (
<GanttView
tasks={tasks}
viewMode={(params.get('mode') as GanttViewMode) || 'day'}
// Only force the granularity when ?mode= is given; otherwise let a
// persisted layout (保存布局) restore it on reload.
viewMode={params.get('mode') ? (params.get('mode') as GanttViewMode) : undefined}
markers={markers}
autoSchedule
rescheduleOnConflict
Expand All@@ -372,6 +376,8 @@ function App() {
readOnly={readOnly}
groupBy={groupBy}
ungroupedLabel="未分组"
persistLayoutKey="demo-project"
onLayoutChange={(l) => console.log('[gantt-demo] layout saved', l)}
inlineEdit
onTaskClick={(t) => console.log('[gantt-demo] click', t.id)}
onTaskUpdate={(t, changes) => patch(t.id, changes)}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions packages/plugin-gantt/docs/verification/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -526,3 +526,30 @@ The script asserts (5/5 checks passed):
topological forward pass (FS/SS/FF/SF aware, summaries fixed), pushing t4 back
to satisfy the link.
- ![Auto-rescheduled](44-conflict-rescheduled.png)

## Navigation + year scale + save layout + PNG/PDF export — 导航 + 年刻度 + 保存布局 + 导出

`scripts/verify-export-layout.mjs` drives the `?lang=zh` project fixture and
exercises the Group 3 toolbar additions. Covered by unit tests in
[`GanttView.layout.test.tsx`](../../src/GanttView.layout.test.tsx) (12 cases)
and [`ObjectGantt.test.tsx`](../../src/ObjectGantt.test.tsx) (persistLayoutKey
wiring).

The script asserts (7/7 checks passed):

- **年刻度** — a new 年 granularity button widens the timeline to one column
per year, with a `2020s` decade band above each year header.
- ![Year granularity](45-year-granularity.png)
- **导航** — 本周 / 本月 buttons scroll the timeline to the start of the current
week / month (alongside the existing 今天 jump).
- ![Navigation](46-navigation.png)
- **保存布局** — the 保存布局 button snapshots the current granularity + zoom +
list-collapse to `localStorage` (key `gantt-layout:<object>:<view>`) and fires
`onLayoutChange`; the button briefly highlights to confirm.
- ![Save layout](47-save-layout.png)
- **持久化** — reloading without a `?mode=` override restores the saved 月
granularity from `localStorage`.
- ![Layout restored](48-layout-restored.png)
- **导出 PNG / PDF** — both export buttons download real files: a valid PNG
(`‰PNG` magic bytes) and a dependency-free single-page PDF (`%PDF-` header)
embedding the rasterized chart as a JPEG via `DCTDecode`.
108 changes: 108 additions & 0 deletions packages/plugin-gantt/scripts/verify-export-layout.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
/**
* 导航 + 年刻度 + 保存布局 + 导出 PNG/PDF verification (Group 3).
*
* Drives the demo (?lang=zh) project fixture and asserts:
* 1. the 年 (year) granularity button switches the timeline (年刻度),
* 2. 本周 / 本月 navigation buttons scroll the timeline,
* 3. 保存布局 persists granularity to localStorage and survives a reload,
* 4. 导出 PNG / 导出 PDF download files with the right extensions/magic bytes.
* Persists screenshots 45-48 under docs/verification/.
*
* GANTT_DEMO_URL=http://localhost:5200 node packages/plugin-gantt/scripts/verify-export-layout.mjs
*/
import { chromium } from 'playwright';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';

const EXEC =
'/Users/baozhoutao/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell';
const BASE = process.env.GANTT_DEMO_URL || 'http://localhost:5199';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.resolve(__dirname, '../docs/verification');
const DL = fs.mkdtempSync(path.join(os.tmpdir(), 'gantt-dl-'));

const browser = await chromium.launch({ executablePath: EXEC });
const ctx = await browser.newContext({ viewport: { width: 1500, height: 900 }, acceptDownloads: true });
const page = await ctx.newPage();
const fails = [];
const ok = (cond, msg, detail = '') => {
if (!cond) fails.push(msg);
console.log(`${cond ? '✓' : '✗'} ${msg}${detail ? ` — ${detail}` : ''}`);
};
const pressed = (testid) =>
page.$eval(`[data-testid="${testid}"]`, (el) => el.getAttribute('aria-pressed')).catch(() => null);

const saveDownload = async (triggerSel) => {
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 8000 }),
page.click(triggerSel),
]);
const name = download.suggestedFilename();
const dest = path.join(DL, name);
await download.saveAs(dest);
return { name, bytes: fs.readFileSync(dest) };
};

try {
await page.goto(`${BASE}?lang=zh`, { waitUntil: 'networkidle' });
await page.waitForSelector('[data-testid="gantt-view-mode-year"]', { timeout: 8000 });

// 1) 年刻度 — switch to the year granularity.
await page.click('[data-testid="gantt-view-mode-year"]');
await page.waitForTimeout(300);
ok((await pressed('gantt-view-mode-year')) === 'true', '年 granularity button activates (年刻度)');
await page.screenshot({ path: path.join(OUT, '45-year-granularity.png') });

// back to a finer mode for the nav test
await page.click('[data-testid="gantt-view-mode-month"]');
await page.waitForTimeout(200);

// 2) Navigation — 本周 / 本月 scroll the timeline.
const scrollOf = () =>
page.$eval('[data-testid="gantt-timeline"]', (el) => el.scrollLeft).catch(() => null);
await page.click('[data-testid="gantt-jump-month"]');
await page.waitForTimeout(200);
const afterMonth = await scrollOf();
ok(afterMonth !== null && Number.isFinite(afterMonth), '本月 navigation scrolls the timeline', `scrollLeft=${afterMonth}`);
await page.click('[data-testid="gantt-jump-week"]');
await page.waitForTimeout(200);
ok(await page.$('[data-testid="gantt-jump-week"]'), '本周 navigation button present & clickable');
await page.screenshot({ path: path.join(OUT, '46-navigation.png') });

// 3) 保存布局 — set month, save, reload, expect month restored.
await page.click('[data-testid="gantt-view-mode-month"]');
await page.waitForTimeout(150);
await page.click('[data-testid="gantt-save-layout"]');
await page.waitForTimeout(150);
ok((await pressed('gantt-save-layout')) === 'true', '保存布局 button reflects a save (aria-pressed)');
await page.screenshot({ path: path.join(OUT, '47-save-layout.png') });

// Reload WITHOUT a ?mode= override so the persisted layout wins.
await page.goto(`${BASE}?lang=zh`, { waitUntil: 'networkidle' });
await page.waitForSelector('[data-testid="gantt-view-mode-month"]', { timeout: 8000 });
await page.waitForTimeout(300);
ok((await pressed('gantt-view-mode-month')) === 'true', '保存布局 restores 月 granularity after reload (持久化)');
await page.screenshot({ path: path.join(OUT, '48-layout-restored.png') });

// 4) 导出 PNG / PDF — verify real downloads with correct magic bytes.
const png = await saveDownload('[data-testid="gantt-export-png"]');
const pngMagic = png.bytes[0] === 0x89 && png.bytes[1] === 0x50 && png.bytes[2] === 0x4e && png.bytes[3] === 0x47;
ok(png.name.endsWith('.png') && pngMagic, '导出 PNG downloads a valid PNG', `${png.name} ${png.bytes.length}B`);

const pdf = await saveDownload('[data-testid="gantt-export-pdf"]');
const head = pdf.bytes.subarray(0, 5).toString('latin1');
ok(pdf.name.endsWith('.pdf') && head === '%PDF-', '导出 PDF downloads a valid PDF', `${pdf.name} ${pdf.bytes.length}B head=${head}`);

console.log(`\nscreenshots → ${OUT} (45–48)`);
} catch (err) {
console.error(err);
fails.push(String(err));
} finally {
await browser.close();
fs.rmSync(DL, { recursive: true, force: true });
}

if (fails.length) { console.error(`\n${fails.length} check(s) failed`); process.exit(1); }
console.log('\nall checks passed');
139 changes: 139 additions & 0 deletions packages/plugin-gantt/src/GanttView.layout.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
/**
* Group 3 tests: navigation buttons, year granularity, layout persistence
* (保存布局), and the PNG / PDF export toolbar buttons.
*
* Conventions match the other interaction tests: innerWidth=1280 →
* columnWidth 60, rowHeight 40.
*/
import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GanttView, type GanttTask, type GanttLayout } from './GanttView';

beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function makeTask(id: string, start: string, end: string, extra: Partial<GanttTask> = {}): GanttTask {
return { id, title: `Task ${id}`, start: new Date(start), end: new Date(end), progress: 0, ...extra };
}

const TASKS = () => [
makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { progress: 50 }),
makeTask('b', '2024-06-17T00:00:00.000Z', '2024-06-21T00:00:00.000Z'),
];

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={TASKS()}
startDate={new Date('2024-06-01T00:00:00.000Z')}
endDate={new Date('2024-12-30T00:00:00.000Z')}
{...props}
/>
</div>
);
}

describe('GanttView navigation buttons (导航)', () => {
it('renders jump-to-today / this-week / this-month controls', () => {
const { getByTestId } = renderView();
expect(getByTestId('gantt-jump-today')).toBeTruthy();
expect(getByTestId('gantt-jump-week')).toBeTruthy();
expect(getByTestId('gantt-jump-month')).toBeTruthy();
});

it('this-week / this-month scroll the timeline horizontally', () => {
const { getByTestId } = renderView();
const timeline = getByTestId('gantt-timeline') as HTMLElement;
// jsdom has no layout, so scrollLeft is a plain settable number; assert the
// handlers run without throwing and leave a finite scrollLeft.
act(() => { fireEvent.click(getByTestId('gantt-jump-week')); });
expect(Number.isFinite(timeline.scrollLeft)).toBe(true);
act(() => { fireEvent.click(getByTestId('gantt-jump-month')); });
expect(Number.isFinite(timeline.scrollLeft)).toBe(true);
});
});

describe('GanttView year granularity (年刻度)', () => {
it('exposes a year view-mode button and switches to it', () => {
const { getByTestId } = renderView();
const yearBtn = getByTestId('gantt-view-mode-year');
expect(yearBtn).toBeTruthy();
act(() => { fireEvent.click(yearBtn); });
expect(yearBtn.getAttribute('aria-pressed')).toBe('true');
});

it('seeds the year granularity from the viewMode prop', () => {
const { getByTestId } = renderView({ viewMode: 'year' });
expect(getByTestId('gantt-view-mode-year').getAttribute('aria-pressed')).toBe('true');
});
});

describe('GanttView export buttons (导出 PNG / PDF)', () => {
it('renders both the PNG and PDF export buttons', () => {
const { getByTestId } = renderView();
expect(getByTestId('gantt-export-png')).toBeTruthy();
expect(getByTestId('gantt-export-pdf')).toBeTruthy();
});
});

describe('GanttView save layout (保存布局)', () => {
it('hides the save-layout button without persistLayoutKey/onLayoutChange', () => {
const { queryByTestId } = renderView();
expect(queryByTestId('gantt-save-layout')).toBeNull();
});

it('shows the save-layout button when onLayoutChange is set', () => {
const { getByTestId } = renderView({ onLayoutChange: () => {} });
expect(getByTestId('gantt-save-layout')).toBeTruthy();
});

it('persists the current layout to localStorage under the key', () => {
const { getByTestId } = renderView({ persistLayoutKey: 'proj1' });
act(() => { fireEvent.click(getByTestId('gantt-view-mode-month')); });
act(() => { fireEvent.click(getByTestId('gantt-save-layout')); });
const raw = window.localStorage.getItem('gantt-layout:proj1');
expect(raw).toBeTruthy();
const saved = JSON.parse(raw!) as GanttLayout;
expect(saved.viewMode).toBe('month');
expect(saved.taskListCollapsed).toBe(false);
});

it('calls onLayoutChange with the snapshot on save', () => {
const onLayoutChange = vi.fn();
const { getByTestId } = renderView({ onLayoutChange });
act(() => { fireEvent.click(getByTestId('gantt-view-mode-quarter')); });
act(() => { fireEvent.click(getByTestId('gantt-save-layout')); });
expect(onLayoutChange).toHaveBeenCalledTimes(1);
expect(onLayoutChange.mock.calls[0][0].viewMode).toBe('quarter');
});

it('restores a persisted granularity on mount', () => {
window.localStorage.setItem(
'gantt-layout:proj2',
JSON.stringify({ viewMode: 'month', columnWidth: null, taskListCollapsed: false } satisfies GanttLayout)
);
const { getByTestId } = renderView({ persistLayoutKey: 'proj2' });
expect(getByTestId('gantt-view-mode-month').getAttribute('aria-pressed')).toBe('true');
});

it('lets the viewMode prop win over a persisted granularity', () => {
window.localStorage.setItem(
'gantt-layout:proj3',
JSON.stringify({ viewMode: 'month', columnWidth: null, taskListCollapsed: false } satisfies GanttLayout)
);
const { getByTestId } = renderView({ persistLayoutKey: 'proj3', viewMode: 'week' });
expect(getByTestId('gantt-view-mode-week').getAttribute('aria-pressed')).toBe('true');
expect(getByTestId('gantt-view-mode-month').getAttribute('aria-pressed')).toBe('false');
});

it('ignores malformed persisted layout JSON', () => {
window.localStorage.setItem('gantt-layout:proj4', '{not valid json');
const { getByTestId } = renderView({ persistLayoutKey: 'proj4' });
// Falls back to the default 'day' granularity without throwing.
expect(getByTestId('gantt-view-mode-day').getAttribute('aria-pressed')).toBe('true');
});
});
Loading
Loading