@finebi/finebi-cli 1.6.3 → 2.0.1
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.
- package/README.md +5 -1
- package/agent-installation-guide.md +70 -0
- package/package.json +2 -1
- package/skills/finebi-cli-agent/SKILL.md +1 -1
- package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
- package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
- package/skills/finebi-cli-agent/references/web-app.md +4 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +160 -160
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +27 -27
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +55 -55
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +96 -94
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -80
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -22
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/gitignore.template +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -63
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -17
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -40
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -58
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -235
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -20
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -70
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -31
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -189
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
- package/src/app.js +160 -160
- package/src/config.js +27 -27
- package/src/help.js +55 -55
- package/src/install-skill.js +89 -89
- package/src/profiles.js +158 -158
- package/src/skill-files.js +96 -94
- package/templates/web-app/AGENTS.md +1 -1
- package/templates/web-app/docs/acceptance.md +80 -80
- package/templates/web-app/examples/cockpit.config.json +22 -22
- package/templates/web-app/examples/retail.config.json +185 -185
- package/templates/web-app/gitignore.template +6 -6
- package/templates/web-app/index.html +2 -2
- package/templates/web-app/public/app-config.json +11 -11
- package/templates/web-app/src/App.tsx +63 -63
- package/templates/web-app/src/cockpit.css +3 -3
- package/templates/web-app/src/components/Chart.tsx +40 -40
- package/templates/web-app/src/components/Insights.tsx +17 -17
- package/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/templates/web-app/src/components/Widget.tsx +40 -40
- package/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/templates/web-app/src/components/calendar.ts +14 -14
- package/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/templates/web-app/src/components/month-picker.css +1 -1
- package/templates/web-app/src/finebi/config.d.ts +3 -3
- package/templates/web-app/src/finebi/config.js +58 -58
- package/templates/web-app/src/finebi/date.d.ts +1 -1
- package/templates/web-app/src/finebi/date.js +5 -5
- package/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/templates/web-app/src/finebi/filters.js +106 -106
- package/templates/web-app/src/finebi/normalize.ts +235 -235
- package/templates/web-app/src/finebi/types.ts +20 -20
- package/templates/web-app/src/main.tsx +5 -5
- package/templates/web-app/src/retail.css +12 -12
- package/templates/web-app/src/services/presentation.ts +36 -36
- package/templates/web-app/src/services/table-data.ts +47 -47
- package/templates/web-app/src/services/view-data.ts +18 -18
- package/templates/web-app/src/services/widget-queries.ts +18 -18
- package/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/templates/web-app/tests/browser/index.html +16 -16
- package/templates/web-app/tests/browser/main.tsx +149 -149
- package/templates/web-app/tests/browser/test-page.css +4 -4
- package/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/templates/web-app/tests/bundle.test.mjs +19 -19
- package/templates/web-app/tests/cockpit.test.mjs +73 -73
- package/templates/web-app/tests/field-selection.test.mjs +70 -70
- package/templates/web-app/tests/retail.test.mjs +31 -31
- package/templates/web-app/tests/table-normalize.test.mjs +189 -189
- package/templates/web-app/tsconfig.json +4 -4
|
@@ -1,149 +1,149 @@
|
|
|
1
|
-
import {createElement} from 'react';
|
|
2
|
-
import {createRoot} from 'react-dom/client';
|
|
3
|
-
import {flushSync} from 'react-dom';
|
|
4
|
-
import App from '../../src/App';
|
|
5
|
-
import '../../src/styles.css';
|
|
6
|
-
import {fixtureConfig, nativeFixture} from './fixtures.mjs';
|
|
7
|
-
import './test-page.css';
|
|
8
|
-
|
|
9
|
-
const root = createRoot(document.getElementById('root')!);
|
|
10
|
-
const result = document.getElementById('test-result')!;
|
|
11
|
-
const checks = document.getElementById('test-checks')!;
|
|
12
|
-
const runButton = document.getElementById('test-run') as HTMLButtonElement;
|
|
13
|
-
const layoutSelect = document.getElementById('test-layout') as HTMLSelectElement;
|
|
14
|
-
let config = fixtureConfig(layoutSelect.value), scenario = 'normal', generation = 0, failOnce = false;
|
|
15
|
-
let requests: any[] = [];
|
|
16
|
-
const runtimeErrors: string[] = [];
|
|
17
|
-
for (const level of ['error', 'warn'] as const) {
|
|
18
|
-
const original = console[level].bind(console);
|
|
19
|
-
console[level] = (...args) => {runtimeErrors.push(args.map(String).join(' ')); original(...args);};
|
|
20
|
-
}
|
|
21
|
-
window.addEventListener('error', event => runtimeErrors.push(event.message));
|
|
22
|
-
window.addEventListener('unhandledrejection', event => runtimeErrors.push(String(event.reason)));
|
|
23
|
-
|
|
24
|
-
// This page is a test-only network boundary. Unexpected app requests fail closed.
|
|
25
|
-
window.fetch = async (input, init = {}) => {
|
|
26
|
-
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
27
|
-
if (url.origin !== location.origin) throw new Error('浏览器夹具禁止外部请求');
|
|
28
|
-
if (url.pathname.endsWith('/app-config.json')) return Response.json(config);
|
|
29
|
-
if (url.pathname !== '/fixture-decision/v5/api/dashboard/report/fixture-dashboard/widgets/query') throw new Error(`未配置的夹具请求:${url.pathname}`);
|
|
30
|
-
const body = JSON.parse(String(init.body));
|
|
31
|
-
requests.push(body);
|
|
32
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
33
|
-
if (init.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
34
|
-
const id = body.widgetIds[0];
|
|
35
|
-
if (failOnce && id === 'chart1') {failOnce = false; return Response.json({}, {status: 403});}
|
|
36
|
-
return Response.json({success: true, data: {[id]: nativeFixture(id, body.refer, scenario)}});
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
function assert(condition: unknown, message: string): asserts condition {if (!condition) throw new Error(message);}
|
|
40
|
-
async function wait(check: () => boolean, label: string) {
|
|
41
|
-
const deadline = performance.now() + 15000;
|
|
42
|
-
while (!check()) {
|
|
43
|
-
assert(performance.now() < deadline, `超时:${label}`);
|
|
44
|
-
await new Promise(resolve => setTimeout(resolve, 40));
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
function pass(message: string) {const li = document.createElement('li'); li.textContent = `PASS · ${message}`; checks.append(li);}
|
|
48
|
-
const panels = () => Array.from(document.querySelectorAll<HTMLElement>('#root .panel'));
|
|
49
|
-
async function settled() {
|
|
50
|
-
await wait(() => panels().length === config.widgets.length && panels().every(panel => /更新于|查询失败/.test(panel.querySelector('.component-status')?.textContent ?? '')), '组件查询结束');
|
|
51
|
-
}
|
|
52
|
-
async function mount(state = 'normal', chartCount = 8) {
|
|
53
|
-
scenario = state; config = fixtureConfig(layoutSelect.value, chartCount); requests = []; failOnce = state === 'failure';
|
|
54
|
-
flushSync(() => root.render(createElement(App, {key: ++generation})));
|
|
55
|
-
await settled();
|
|
56
|
-
}
|
|
57
|
-
function assertRequests(filtered: boolean) {
|
|
58
|
-
assert(requests.length === config.widgets.length, '每个展示组件应恰好请求一次');
|
|
59
|
-
for (const request of requests) {
|
|
60
|
-
assert(request.reportEntryId === 'fixture-directory', '目录入口丢失');
|
|
61
|
-
const expected = filtered && request.widgetIds[0] !== 'kpi3' ? {区域: ['华东']} : {};
|
|
62
|
-
assert(JSON.stringify(request.refer) === JSON.stringify(expected), '筛选编码或组件作用范围错误');
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
async function painted(count: number) {
|
|
66
|
-
await wait(() => {
|
|
67
|
-
const canvases = Array.from(document.querySelectorAll<HTMLCanvasElement>('#root .chart canvas'));
|
|
68
|
-
return canvases.length === count && canvases.every(canvas => {
|
|
69
|
-
const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
70
|
-
let colored = 0;
|
|
71
|
-
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] && ++colored > 100) return true;
|
|
72
|
-
return false;
|
|
73
|
-
});
|
|
74
|
-
}, '柱/线/饼图实际绘制');
|
|
75
|
-
assert(runtimeErrors.length === 0, runtimeErrors.join('\n'));
|
|
76
|
-
}
|
|
77
|
-
function geometry(even: boolean) {
|
|
78
|
-
assert(document.documentElement.scrollWidth <= innerWidth + 1, '页面存在横向溢出');
|
|
79
|
-
const cards = panels().map(panel => panel.getBoundingClientRect());
|
|
80
|
-
for (let i = 0; i < cards.length; i++) {
|
|
81
|
-
const a = cards[i];
|
|
82
|
-
assert(a.width > 0 && a.left >= -1 && a.right <= innerWidth + 1, '卡片超出视口');
|
|
83
|
-
for (const b of cards.slice(i + 1)) assert(Math.min(a.right,b.right) - Math.max(a.left,b.left) <= 1 || Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top) <= 1, '卡片重叠');
|
|
84
|
-
}
|
|
85
|
-
const grid = document.querySelector<HTMLElement>('#root .grid')!;
|
|
86
|
-
const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
|
|
87
|
-
const regular = Array.from(grid.querySelectorAll<HTMLElement>(':scope > .panel:not(.wide)')).map(el => el.getBoundingClientRect());
|
|
88
|
-
if (even && columns === 2 && regular.length % 2 === 0) for (let i = 0; i < regular.length; i += 2) assert(Math.abs(regular[i].top - regular[i + 1].top) < 2, '偶数组件双列排列出现孤立卡片');
|
|
89
|
-
}
|
|
90
|
-
function button(text: string) {
|
|
91
|
-
const found = Array.from(document.querySelectorAll<HTMLButtonElement>('#root button')).find(el => el.textContent?.trim() === text);
|
|
92
|
-
assert(found, `找不到按钮:${text}`); return found;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function retailNavigation() {
|
|
96
|
-
if (layoutSelect.value !== 'retail') return;
|
|
97
|
-
const nav = document.querySelector<HTMLElement>('#root .retail-nav')!;
|
|
98
|
-
for (const id of ['components', 'data-notes', 'dashboard']) {
|
|
99
|
-
nav.querySelector<HTMLAnchorElement>(`a[href="#${id}"]`)!.click();
|
|
100
|
-
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
101
|
-
const bar = nav.getBoundingClientRect(), target = document.getElementById(id)!.getBoundingClientRect();
|
|
102
|
-
assert(bar.top >= -1 && bar.bottom < innerHeight, '跳转后导航离开视口');
|
|
103
|
-
assert(target.top >= bar.bottom - 1 && target.top < innerHeight, '锚点标题被导航遮住或离开视口');
|
|
104
|
-
}
|
|
105
|
-
assert(document.querySelector('#components h2')?.textContent === '销售表现', '销售分析未定位到区域标题');
|
|
106
|
-
window.scrollTo(0, 0);
|
|
107
|
-
pass('页内导航持续可见,三个锚点标题可见且不被遮挡');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function run() {
|
|
111
|
-
runButton.disabled = true; layoutSelect.disabled = true; checks.replaceChildren(); result.textContent = '运行中';
|
|
112
|
-
const started = performance.now();
|
|
113
|
-
try {
|
|
114
|
-
assert(runtimeErrors.length === 0, '已有运行错误,请修复并刷新测试页:' + runtimeErrors.join('\n'));
|
|
115
|
-
await mount(); await painted(8); assertRequests(false); geometry(true);
|
|
116
|
-
assert(document.querySelector('#root .widget-table')?.textContent?.includes('受限'), '受限值未标记');
|
|
117
|
-
pass('八张图实际绘制,长标题/边界/偶数排列、目录入口和受限值');
|
|
118
|
-
const input = document.querySelector<HTMLInputElement>('#root input[aria-label="区域"]');
|
|
119
|
-
assert(input, '文本筛选尚未加载');
|
|
120
|
-
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, '华东');
|
|
121
|
-
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
122
|
-
await wait(() => document.querySelector('.filter-status')?.textContent === '待应用', '筛选草稿');
|
|
123
|
-
assert(requests.length === config.widgets.length, '编辑草稿触发了查询');
|
|
124
|
-
requests = []; button('应用筛选').click();
|
|
125
|
-
await wait(() => requests.length === config.widgets.length, '应用发起查询'); await settled();
|
|
126
|
-
assertRequests(true);
|
|
127
|
-
assert(document.querySelector('#root .kpi')?.textContent === '120', '应用条件后指标未变化');
|
|
128
|
-
pass('草稿不查询,应用后按真实编码及组件范围请求,显示值变化');
|
|
129
|
-
requests = []; button('恢复全部默认').click();
|
|
130
|
-
await wait(() => requests.length === config.widgets.length, '恢复默认查询'); await settled();
|
|
131
|
-
assertRequests(false); assert(document.querySelector('#root .kpi')?.textContent === '260', '恢复默认后指标未恢复');
|
|
132
|
-
pass('恢复默认省略覆盖,显示值恢复');
|
|
133
|
-
await mount('normal', 7); await painted(7); geometry(false); pass('七张图、奇数组件无溢出与重叠');
|
|
134
|
-
await mount('empty'); assert(document.querySelectorAll('#root .empty').length === config.widgets.length, '空结果未独立显示');
|
|
135
|
-
assert(!document.querySelector('#root canvas'), '空结果保留了旧图'); pass('空结果不保留旧图');
|
|
136
|
-
await mount('partial'); await painted(8);
|
|
137
|
-
assert(document.querySelector('#root .notice')?.textContent?.includes('首屏'), '截断警告缺失');
|
|
138
|
-
assert(document.querySelector('#root .notice')?.textContent?.includes('PARTIAL'), '部分适配标记缺失');
|
|
139
|
-
pass('部分适配与首屏范围警告保持可见');
|
|
140
|
-
await mount('failure'); assert(document.querySelectorAll('#root .component-error').length === 1, '单组件失败未隔离');
|
|
141
|
-
assert(document.querySelector('#root .component-error')?.textContent?.includes('PERMISSION_DENIED'), '权限失败语义错误');
|
|
142
|
-
requests = []; button('重试组件').click(); await wait(() => requests.length === 1, '组件重试'); await settled(); await painted(8);
|
|
143
|
-
assert(requests.length === 1 && requests[0].widgetIds[0] === 'chart1', '重试影响了其他组件'); pass('权限失败独立显示,单项重试只请求失败组件');
|
|
144
|
-
await mount(); await painted(8); geometry(true); await retailNavigation();
|
|
145
|
-
result.textContent = `PASS · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${checks.children.length} 项 · ${((performance.now()-started)/1000).toFixed(1)}s`;
|
|
146
|
-
} catch (error) {result.textContent = `FAIL · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${String(error)}`;}
|
|
147
|
-
finally {runButton.disabled = false; layoutSelect.disabled = false;}
|
|
148
|
-
}
|
|
149
|
-
runButton.addEventListener('click', run);
|
|
1
|
+
import {createElement} from 'react';
|
|
2
|
+
import {createRoot} from 'react-dom/client';
|
|
3
|
+
import {flushSync} from 'react-dom';
|
|
4
|
+
import App from '../../src/App';
|
|
5
|
+
import '../../src/styles.css';
|
|
6
|
+
import {fixtureConfig, nativeFixture} from './fixtures.mjs';
|
|
7
|
+
import './test-page.css';
|
|
8
|
+
|
|
9
|
+
const root = createRoot(document.getElementById('root')!);
|
|
10
|
+
const result = document.getElementById('test-result')!;
|
|
11
|
+
const checks = document.getElementById('test-checks')!;
|
|
12
|
+
const runButton = document.getElementById('test-run') as HTMLButtonElement;
|
|
13
|
+
const layoutSelect = document.getElementById('test-layout') as HTMLSelectElement;
|
|
14
|
+
let config = fixtureConfig(layoutSelect.value), scenario = 'normal', generation = 0, failOnce = false;
|
|
15
|
+
let requests: any[] = [];
|
|
16
|
+
const runtimeErrors: string[] = [];
|
|
17
|
+
for (const level of ['error', 'warn'] as const) {
|
|
18
|
+
const original = console[level].bind(console);
|
|
19
|
+
console[level] = (...args) => {runtimeErrors.push(args.map(String).join(' ')); original(...args);};
|
|
20
|
+
}
|
|
21
|
+
window.addEventListener('error', event => runtimeErrors.push(event.message));
|
|
22
|
+
window.addEventListener('unhandledrejection', event => runtimeErrors.push(String(event.reason)));
|
|
23
|
+
|
|
24
|
+
// This page is a test-only network boundary. Unexpected app requests fail closed.
|
|
25
|
+
window.fetch = async (input, init = {}) => {
|
|
26
|
+
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
27
|
+
if (url.origin !== location.origin) throw new Error('浏览器夹具禁止外部请求');
|
|
28
|
+
if (url.pathname.endsWith('/app-config.json')) return Response.json(config);
|
|
29
|
+
if (url.pathname !== '/fixture-decision/v5/api/dashboard/report/fixture-dashboard/widgets/query') throw new Error(`未配置的夹具请求:${url.pathname}`);
|
|
30
|
+
const body = JSON.parse(String(init.body));
|
|
31
|
+
requests.push(body);
|
|
32
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
33
|
+
if (init.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
34
|
+
const id = body.widgetIds[0];
|
|
35
|
+
if (failOnce && id === 'chart1') {failOnce = false; return Response.json({}, {status: 403});}
|
|
36
|
+
return Response.json({success: true, data: {[id]: nativeFixture(id, body.refer, scenario)}});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function assert(condition: unknown, message: string): asserts condition {if (!condition) throw new Error(message);}
|
|
40
|
+
async function wait(check: () => boolean, label: string) {
|
|
41
|
+
const deadline = performance.now() + 15000;
|
|
42
|
+
while (!check()) {
|
|
43
|
+
assert(performance.now() < deadline, `超时:${label}`);
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function pass(message: string) {const li = document.createElement('li'); li.textContent = `PASS · ${message}`; checks.append(li);}
|
|
48
|
+
const panels = () => Array.from(document.querySelectorAll<HTMLElement>('#root .panel'));
|
|
49
|
+
async function settled() {
|
|
50
|
+
await wait(() => panels().length === config.widgets.length && panels().every(panel => /更新于|查询失败/.test(panel.querySelector('.component-status')?.textContent ?? '')), '组件查询结束');
|
|
51
|
+
}
|
|
52
|
+
async function mount(state = 'normal', chartCount = 8) {
|
|
53
|
+
scenario = state; config = fixtureConfig(layoutSelect.value, chartCount); requests = []; failOnce = state === 'failure';
|
|
54
|
+
flushSync(() => root.render(createElement(App, {key: ++generation})));
|
|
55
|
+
await settled();
|
|
56
|
+
}
|
|
57
|
+
function assertRequests(filtered: boolean) {
|
|
58
|
+
assert(requests.length === config.widgets.length, '每个展示组件应恰好请求一次');
|
|
59
|
+
for (const request of requests) {
|
|
60
|
+
assert(request.reportEntryId === 'fixture-directory', '目录入口丢失');
|
|
61
|
+
const expected = filtered && request.widgetIds[0] !== 'kpi3' ? {区域: ['华东']} : {};
|
|
62
|
+
assert(JSON.stringify(request.refer) === JSON.stringify(expected), '筛选编码或组件作用范围错误');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function painted(count: number) {
|
|
66
|
+
await wait(() => {
|
|
67
|
+
const canvases = Array.from(document.querySelectorAll<HTMLCanvasElement>('#root .chart canvas'));
|
|
68
|
+
return canvases.length === count && canvases.every(canvas => {
|
|
69
|
+
const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
70
|
+
let colored = 0;
|
|
71
|
+
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] && ++colored > 100) return true;
|
|
72
|
+
return false;
|
|
73
|
+
});
|
|
74
|
+
}, '柱/线/饼图实际绘制');
|
|
75
|
+
assert(runtimeErrors.length === 0, runtimeErrors.join('\n'));
|
|
76
|
+
}
|
|
77
|
+
function geometry(even: boolean) {
|
|
78
|
+
assert(document.documentElement.scrollWidth <= innerWidth + 1, '页面存在横向溢出');
|
|
79
|
+
const cards = panels().map(panel => panel.getBoundingClientRect());
|
|
80
|
+
for (let i = 0; i < cards.length; i++) {
|
|
81
|
+
const a = cards[i];
|
|
82
|
+
assert(a.width > 0 && a.left >= -1 && a.right <= innerWidth + 1, '卡片超出视口');
|
|
83
|
+
for (const b of cards.slice(i + 1)) assert(Math.min(a.right,b.right) - Math.max(a.left,b.left) <= 1 || Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top) <= 1, '卡片重叠');
|
|
84
|
+
}
|
|
85
|
+
const grid = document.querySelector<HTMLElement>('#root .grid')!;
|
|
86
|
+
const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
|
|
87
|
+
const regular = Array.from(grid.querySelectorAll<HTMLElement>(':scope > .panel:not(.wide)')).map(el => el.getBoundingClientRect());
|
|
88
|
+
if (even && columns === 2 && regular.length % 2 === 0) for (let i = 0; i < regular.length; i += 2) assert(Math.abs(regular[i].top - regular[i + 1].top) < 2, '偶数组件双列排列出现孤立卡片');
|
|
89
|
+
}
|
|
90
|
+
function button(text: string) {
|
|
91
|
+
const found = Array.from(document.querySelectorAll<HTMLButtonElement>('#root button')).find(el => el.textContent?.trim() === text);
|
|
92
|
+
assert(found, `找不到按钮:${text}`); return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function retailNavigation() {
|
|
96
|
+
if (layoutSelect.value !== 'retail') return;
|
|
97
|
+
const nav = document.querySelector<HTMLElement>('#root .retail-nav')!;
|
|
98
|
+
for (const id of ['components', 'data-notes', 'dashboard']) {
|
|
99
|
+
nav.querySelector<HTMLAnchorElement>(`a[href="#${id}"]`)!.click();
|
|
100
|
+
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
101
|
+
const bar = nav.getBoundingClientRect(), target = document.getElementById(id)!.getBoundingClientRect();
|
|
102
|
+
assert(bar.top >= -1 && bar.bottom < innerHeight, '跳转后导航离开视口');
|
|
103
|
+
assert(target.top >= bar.bottom - 1 && target.top < innerHeight, '锚点标题被导航遮住或离开视口');
|
|
104
|
+
}
|
|
105
|
+
assert(document.querySelector('#components h2')?.textContent === '销售表现', '销售分析未定位到区域标题');
|
|
106
|
+
window.scrollTo(0, 0);
|
|
107
|
+
pass('页内导航持续可见,三个锚点标题可见且不被遮挡');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function run() {
|
|
111
|
+
runButton.disabled = true; layoutSelect.disabled = true; checks.replaceChildren(); result.textContent = '运行中';
|
|
112
|
+
const started = performance.now();
|
|
113
|
+
try {
|
|
114
|
+
assert(runtimeErrors.length === 0, '已有运行错误,请修复并刷新测试页:' + runtimeErrors.join('\n'));
|
|
115
|
+
await mount(); await painted(8); assertRequests(false); geometry(true);
|
|
116
|
+
assert(document.querySelector('#root .widget-table')?.textContent?.includes('受限'), '受限值未标记');
|
|
117
|
+
pass('八张图实际绘制,长标题/边界/偶数排列、目录入口和受限值');
|
|
118
|
+
const input = document.querySelector<HTMLInputElement>('#root input[aria-label="区域"]');
|
|
119
|
+
assert(input, '文本筛选尚未加载');
|
|
120
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, '华东');
|
|
121
|
+
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
122
|
+
await wait(() => document.querySelector('.filter-status')?.textContent === '待应用', '筛选草稿');
|
|
123
|
+
assert(requests.length === config.widgets.length, '编辑草稿触发了查询');
|
|
124
|
+
requests = []; button('应用筛选').click();
|
|
125
|
+
await wait(() => requests.length === config.widgets.length, '应用发起查询'); await settled();
|
|
126
|
+
assertRequests(true);
|
|
127
|
+
assert(document.querySelector('#root .kpi')?.textContent === '120', '应用条件后指标未变化');
|
|
128
|
+
pass('草稿不查询,应用后按真实编码及组件范围请求,显示值变化');
|
|
129
|
+
requests = []; button('恢复全部默认').click();
|
|
130
|
+
await wait(() => requests.length === config.widgets.length, '恢复默认查询'); await settled();
|
|
131
|
+
assertRequests(false); assert(document.querySelector('#root .kpi')?.textContent === '260', '恢复默认后指标未恢复');
|
|
132
|
+
pass('恢复默认省略覆盖,显示值恢复');
|
|
133
|
+
await mount('normal', 7); await painted(7); geometry(false); pass('七张图、奇数组件无溢出与重叠');
|
|
134
|
+
await mount('empty'); assert(document.querySelectorAll('#root .empty').length === config.widgets.length, '空结果未独立显示');
|
|
135
|
+
assert(!document.querySelector('#root canvas'), '空结果保留了旧图'); pass('空结果不保留旧图');
|
|
136
|
+
await mount('partial'); await painted(8);
|
|
137
|
+
assert(document.querySelector('#root .notice')?.textContent?.includes('首屏'), '截断警告缺失');
|
|
138
|
+
assert(document.querySelector('#root .notice')?.textContent?.includes('PARTIAL'), '部分适配标记缺失');
|
|
139
|
+
pass('部分适配与首屏范围警告保持可见');
|
|
140
|
+
await mount('failure'); assert(document.querySelectorAll('#root .component-error').length === 1, '单组件失败未隔离');
|
|
141
|
+
assert(document.querySelector('#root .component-error')?.textContent?.includes('PERMISSION_DENIED'), '权限失败语义错误');
|
|
142
|
+
requests = []; button('重试组件').click(); await wait(() => requests.length === 1, '组件重试'); await settled(); await painted(8);
|
|
143
|
+
assert(requests.length === 1 && requests[0].widgetIds[0] === 'chart1', '重试影响了其他组件'); pass('权限失败独立显示,单项重试只请求失败组件');
|
|
144
|
+
await mount(); await painted(8); geometry(true); await retailNavigation();
|
|
145
|
+
result.textContent = `PASS · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${checks.children.length} 项 · ${((performance.now()-started)/1000).toFixed(1)}s`;
|
|
146
|
+
} catch (error) {result.textContent = `FAIL · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${String(error)}`;}
|
|
147
|
+
finally {runButton.disabled = false; layoutSelect.disabled = false;}
|
|
148
|
+
}
|
|
149
|
+
runButton.addEventListener('click', run);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#browser-checks{padding:20px 24px;border-bottom:2px solid #adb8c5;background:#fff;color:#243042;overflow-wrap:anywhere}
|
|
2
|
-
#browser-checks h1{font-size:19px;margin:0 0 8px}#browser-checks p,#browser-checks li{font-size:12px;line-height:1.7}
|
|
3
|
-
#browser-checks label{display:inline-flex;align-items:center;gap:8px;margin:8px 12px 8px 0}#test-result{display:block;margin:12px 0;font-size:14px}
|
|
4
|
-
#test-checks{padding-left:22px;margin:0}#test-run{background:#243042;color:white}
|
|
1
|
+
#browser-checks{padding:20px 24px;border-bottom:2px solid #adb8c5;background:#fff;color:#243042;overflow-wrap:anywhere}
|
|
2
|
+
#browser-checks h1{font-size:19px;margin:0 0 8px}#browser-checks p,#browser-checks li{font-size:12px;line-height:1.7}
|
|
3
|
+
#browser-checks label{display:inline-flex;align-items:center;gap:8px;margin:8px 12px 8px 0}#test-result{display:block;margin:12px 0;font-size:14px}
|
|
4
|
+
#test-checks{padding-left:22px;margin:0}#test-run{background:#243042;color:white}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {fileURLToPath} from 'node:url';
|
|
2
|
-
import {defineConfig, normalizePath} from 'vite';
|
|
3
|
-
|
|
4
|
-
const project = fileURLToPath(new URL('../../', import.meta.url));
|
|
5
|
-
export default defineConfig({
|
|
6
|
-
root: fileURLToPath(new URL('./', import.meta.url)),
|
|
7
|
-
publicDir: false,
|
|
8
|
-
envDir: false,
|
|
9
|
-
cacheDir: fileURLToPath(new URL('../../temp/vite-browser', import.meta.url)),
|
|
10
|
-
resolve: {alias: [{find: /^date-fns\/locale$/, replacement: fileURLToPath(new URL('../../src/components/calendar-locales.ts', import.meta.url))}]},
|
|
11
|
-
server: {host: '127.0.0.1', port: 4188, strictPort: true, hmr: false,
|
|
12
|
-
fs: {allow: [project], deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', normalizePath(project) + 'temp/**']}},
|
|
13
|
-
});
|
|
1
|
+
import {fileURLToPath} from 'node:url';
|
|
2
|
+
import {defineConfig, normalizePath} from 'vite';
|
|
3
|
+
|
|
4
|
+
const project = fileURLToPath(new URL('../../', import.meta.url));
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: fileURLToPath(new URL('./', import.meta.url)),
|
|
7
|
+
publicDir: false,
|
|
8
|
+
envDir: false,
|
|
9
|
+
cacheDir: fileURLToPath(new URL('../../temp/vite-browser', import.meta.url)),
|
|
10
|
+
resolve: {alias: [{find: /^date-fns\/locale$/, replacement: fileURLToPath(new URL('../../src/components/calendar-locales.ts', import.meta.url))}]},
|
|
11
|
+
server: {host: '127.0.0.1', port: 4188, strictPort: true, hmr: false,
|
|
12
|
+
fs: {allow: [project], deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', normalizePath(project) + 'temp/**']}},
|
|
13
|
+
});
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import {test} from 'node:test';
|
|
3
|
-
import {build} from 'vite';
|
|
4
|
-
|
|
5
|
-
test('production entry defers charts and optional controls and only bundles supported date locales',async()=>{
|
|
6
|
-
const result=await build({logLevel:'silent',build:{write:false}});
|
|
7
|
-
const chunks=result.output.filter(item=>item.type==='chunk');
|
|
8
|
-
const initial=new Set();
|
|
9
|
-
const visit=chunk=>{if(initial.has(chunk))return;initial.add(chunk);for(const name of chunk.imports){const imported=chunks.find(c=>c.fileName===name);if(imported)visit(imported);}};
|
|
10
|
-
chunks.filter(c=>c.isEntry).forEach(visit);
|
|
11
|
-
const initialModules=[...initial].flatMap(c=>Object.keys(c.modules));
|
|
12
|
-
assert.ok(!initialModules.some(id=>/node_modules\/(echarts|zrender)\//.test(id)),'ECharts must not be an entry dependency');
|
|
13
|
-
assert.ok(!initialModules.some(id=>/node_modules\/(antd|@rc-component\/picker)\//.test(id)),'optional filter UI must not be an entry dependency');
|
|
14
|
-
const modules=chunks.flatMap(c=>Object.keys(c.modules));
|
|
15
|
-
assert.ok(!modules.some(id=>id.includes('/tests/browser/')),'browser fixtures and their fetch replacement must not enter production');
|
|
16
|
-
const locales=new Set(modules.flatMap(id=>{const match=id.match(/date-fns\/locale\/([^/]+)/);return match?[match[1]]:[];}));
|
|
17
|
-
assert.deepEqual([...locales].filter(name=>!['en-US','zh-CN','_lib','en-US.js','zh-CN.js'].includes(name)),[],'do not bundle unused calendar languages');
|
|
18
|
-
for(const type of ['Bar','Line','Pie'])assert.ok(chunks.some(c=>c.isDynamicEntry&&Object.keys(c.modules).some(id=>id.endsWith(`/charts/${type}.tsx`))),`${type} has an independent lazy entry`);
|
|
19
|
-
});
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import {build} from 'vite';
|
|
4
|
+
|
|
5
|
+
test('production entry defers charts and optional controls and only bundles supported date locales',async()=>{
|
|
6
|
+
const result=await build({logLevel:'silent',build:{write:false}});
|
|
7
|
+
const chunks=result.output.filter(item=>item.type==='chunk');
|
|
8
|
+
const initial=new Set();
|
|
9
|
+
const visit=chunk=>{if(initial.has(chunk))return;initial.add(chunk);for(const name of chunk.imports){const imported=chunks.find(c=>c.fileName===name);if(imported)visit(imported);}};
|
|
10
|
+
chunks.filter(c=>c.isEntry).forEach(visit);
|
|
11
|
+
const initialModules=[...initial].flatMap(c=>Object.keys(c.modules));
|
|
12
|
+
assert.ok(!initialModules.some(id=>/node_modules\/(echarts|zrender)\//.test(id)),'ECharts must not be an entry dependency');
|
|
13
|
+
assert.ok(!initialModules.some(id=>/node_modules\/(antd|@rc-component\/picker)\//.test(id)),'optional filter UI must not be an entry dependency');
|
|
14
|
+
const modules=chunks.flatMap(c=>Object.keys(c.modules));
|
|
15
|
+
assert.ok(!modules.some(id=>id.includes('/tests/browser/')),'browser fixtures and their fetch replacement must not enter production');
|
|
16
|
+
const locales=new Set(modules.flatMap(id=>{const match=id.match(/date-fns\/locale\/([^/]+)/);return match?[match[1]]:[];}));
|
|
17
|
+
assert.deepEqual([...locales].filter(name=>!['en-US','zh-CN','_lib','en-US.js','zh-CN.js'].includes(name)),[],'do not bundle unused calendar languages');
|
|
18
|
+
for(const type of ['Bar','Line','Pie'])assert.ok(chunks.some(c=>c.isDynamicEntry&&Object.keys(c.modules).some(id=>id.endsWith(`/charts/${type}.tsx`))),`${type} has an independent lazy entry`);
|
|
19
|
+
});
|
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import {after,test} from 'node:test';
|
|
3
|
-
import {createServer} from 'vite';
|
|
4
|
-
import {createElement} from 'react';
|
|
5
|
-
import {renderToStaticMarkup} from 'react-dom/server';
|
|
6
|
-
const server=await createServer({configFile:false,cacheDir:'temp/vite-cockpit-tests',server:{middlewareMode:true,hmr:false}});
|
|
7
|
-
after(()=>server.close());
|
|
8
|
-
const client=await server.ssrLoadModule('/src/finebi/client.ts');
|
|
9
|
-
const data=await server.ssrLoadModule('/src/services/table-data.ts');
|
|
10
|
-
const queries=await server.ssrLoadModule('/src/services/widget-queries.ts');
|
|
11
|
-
const {Widget}=await server.ssrLoadModule('/src/components/Widget.tsx');
|
|
12
|
-
const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'cockpit',dashboardId:'r',directoryEntryId:'directory',maxRows:1000,pageSize:8,
|
|
13
|
-
widgets:[{entryId:'a',name:'A',type:'table',filterNames:['month']},{entryId:'b',name:'B',type:'table',filterNames:[]}],filters:[{name:'month',label:'月份',type:'month'}]};
|
|
14
|
-
const native={previewResult:{resultType:'DETAIL',header:[],items:[],size:10,row:0,hasNext:false}};
|
|
15
|
-
test('retry is only exposed after a settled failure, never while an initial request is pending',()=>{
|
|
16
|
-
const props={binding:config.widgets[0],pageSize:8,onRetry:()=>{}};
|
|
17
|
-
assert.doesNotMatch(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:true}})),/重试组件/);
|
|
18
|
-
assert.match(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:false,error:new Error('fixture')}})),/重试组件/);
|
|
19
|
-
});
|
|
20
|
-
test('directory source and verified per-widget scope survive individual queries, never falling back',async()=>{
|
|
21
|
-
const sent=[];
|
|
22
|
-
const fetcher=async(url,init)=>{sent.push({url,...JSON.parse(init.body)});return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));};
|
|
23
|
-
await client.queryWidget(config,config.widgets[0],{month:'2024-02'},undefined,fetcher);
|
|
24
|
-
await client.queryWidget(config,config.widgets[1],{month:'2024-02'},undefined,fetcher);
|
|
25
|
-
assert.deepEqual(sent.map(s=>s.reportEntryId),['directory','directory']);
|
|
26
|
-
assert.deepEqual(sent.map(s=>s.widgetIds),[['a'],['b']]);
|
|
27
|
-
assert.deepEqual(sent.map(s=>s.refer),[{month:['2024-02']},{}]);
|
|
28
|
-
let calls=0;
|
|
29
|
-
await assert.rejects(client.queryWidget(config,config.widgets[0],{},undefined,async()=>{calls++;return new Response('{}',{status:403});}),e=>e.code==='PERMISSION_DENIED');
|
|
30
|
-
assert.equal(calls,1);
|
|
31
|
-
});
|
|
32
|
-
test('component loader limits concurrency, publishes independent failures and respects cancellation',async()=>{
|
|
33
|
-
const updates=[];let active=0,peak=0;
|
|
34
|
-
const many={...config,widgets:[...config.widgets,...Array.from({length:6},(_,i)=>({entryId:'extra-'+i,name:'Extra',type:'table'}))]};
|
|
35
|
-
await queries.loadWidgets(many,{},new AbortController().signal,(id,state)=>updates.push([id,state]),async(_url,init)=>{
|
|
36
|
-
active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,5));active--;
|
|
37
|
-
const id=JSON.parse(init.body).widgetIds[0];return id==='a'?new Response('{}',{status:500}):new Response(JSON.stringify({success:true,data:{[id]:native}}));
|
|
38
|
-
});
|
|
39
|
-
assert.equal(peak,3);assert.ok(updates.find(([id,s])=>id==='0:a'&&s.error));assert.ok(updates.find(([id,s])=>id==='1:b'&&s.result));
|
|
40
|
-
const controller=new AbortController();controller.abort();let calls=0;
|
|
41
|
-
await queries.loadWidgets(config,{},controller.signal,()=>assert.fail('no stale update'),async()=>{calls++;});assert.equal(calls,0);
|
|
42
|
-
});
|
|
43
|
-
test('same native widget can have separate display scopes; aborted old responses publish nothing',async()=>{
|
|
44
|
-
const duplicate={...config,widgets:[config.widgets[0],{...config.widgets[0],filterNames:[]}]};
|
|
45
|
-
const complete=[];
|
|
46
|
-
await queries.loadWidgets(duplicate,{month:'2024-02'},new AbortController().signal,(key,state)=>{if(state.result)complete.push(key);},async()=>new Response(JSON.stringify({success:true,data:{a:native}})));
|
|
47
|
-
assert.deepEqual(complete.sort(),['0:a','1:a']);
|
|
48
|
-
const controller=new AbortController(),updates=[];
|
|
49
|
-
await queries.loadWidgets(config,{},controller.signal,(_key,state)=>updates.push(state),async()=>{controller.abort();return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));});
|
|
50
|
-
assert.ok(updates.every(s=>s.loading));
|
|
51
|
-
});
|
|
52
|
-
const cell=(value,state='AVAILABLE',display=value)=>({value,display,state,reason:null});
|
|
53
|
-
const dataset={id:'main',returnedRowCount:4,columns:[{fieldId:'x',key:'c0',name:'名字',type:'STRING'},{fieldId:'v',key:'c1',name:'金额',type:'DECIMAL'}],rows:[
|
|
54
|
-
{kind:'DATA',cells:[cell('=HYPERLINK("x")'),cell('9007199254740993.01')]},
|
|
55
|
-
{kind:'DATA',cells:[cell('B'),cell('9007199254740993.02','AVAILABLE','9,007,199,254,740,993.02元')]},
|
|
56
|
-
{kind:'UNKNOWN',cells:[cell('C'),cell(null,'RESTRICTED','***')]},
|
|
57
|
-
{kind:'DATA',cells:[cell('D'),cell(null,'NULL',null)]},
|
|
58
|
-
]};
|
|
59
|
-
test('loaded table sorts decimals exactly, filters and exports matching rows with safe CSV and explicit states',()=>{
|
|
60
|
-
assert.equal(data.compareDecimal('9007199254740993.01','9007199254740993.02'),-1);
|
|
61
|
-
assert.equal(data.compareDecimal('-1.001e3','-1001'),0);
|
|
62
|
-
assert.equal(data.compareDecimal('1e-10','0.00000000009'),1);
|
|
63
|
-
assert.equal(data.compareDecimal('1.20','1.2'),0);
|
|
64
|
-
assert.equal(data.compareDecimal('1200','1.2e3'),0);
|
|
65
|
-
assert.equal(data.compareDecimal('-0.001200','-1.2e-3'),0);
|
|
66
|
-
const rows=data.tableRows(dataset,{search:'',sort:{fieldId:'v',direction:'desc'},filters:{}});
|
|
67
|
-
assert.equal(rows[0].cells[0].value,'B');assert.equal(rows[1].cells[0].value,'=HYPERLINK("x")');
|
|
68
|
-
const filtered=data.tableRows(dataset,{search:'',filters:{x:'B'}});assert.equal(filtered.length,1);
|
|
69
|
-
const csv=data.tableCsv(dataset,rows);
|
|
70
|
-
assert.match(csv,/'=HYPERLINK/);assert.match(csv,/受限/);assert.doesNotMatch(csv,/\*\*\*/);assert.match(csv,/UNKNOWN/);
|
|
71
|
-
assert.match(data.tableCsv(dataset,filtered),/9,007,199/);
|
|
72
|
-
assert.throws(()=>data.tableRows(dataset,{search:'',filters:{missing:'x'}}),/字段/);
|
|
73
|
-
});
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {after,test} from 'node:test';
|
|
3
|
+
import {createServer} from 'vite';
|
|
4
|
+
import {createElement} from 'react';
|
|
5
|
+
import {renderToStaticMarkup} from 'react-dom/server';
|
|
6
|
+
const server=await createServer({configFile:false,cacheDir:'temp/vite-cockpit-tests',server:{middlewareMode:true,hmr:false}});
|
|
7
|
+
after(()=>server.close());
|
|
8
|
+
const client=await server.ssrLoadModule('/src/finebi/client.ts');
|
|
9
|
+
const data=await server.ssrLoadModule('/src/services/table-data.ts');
|
|
10
|
+
const queries=await server.ssrLoadModule('/src/services/widget-queries.ts');
|
|
11
|
+
const {Widget}=await server.ssrLoadModule('/src/components/Widget.tsx');
|
|
12
|
+
const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'cockpit',dashboardId:'r',directoryEntryId:'directory',maxRows:1000,pageSize:8,
|
|
13
|
+
widgets:[{entryId:'a',name:'A',type:'table',filterNames:['month']},{entryId:'b',name:'B',type:'table',filterNames:[]}],filters:[{name:'month',label:'月份',type:'month'}]};
|
|
14
|
+
const native={previewResult:{resultType:'DETAIL',header:[],items:[],size:10,row:0,hasNext:false}};
|
|
15
|
+
test('retry is only exposed after a settled failure, never while an initial request is pending',()=>{
|
|
16
|
+
const props={binding:config.widgets[0],pageSize:8,onRetry:()=>{}};
|
|
17
|
+
assert.doesNotMatch(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:true}})),/重试组件/);
|
|
18
|
+
assert.match(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:false,error:new Error('fixture')}})),/重试组件/);
|
|
19
|
+
});
|
|
20
|
+
test('directory source and verified per-widget scope survive individual queries, never falling back',async()=>{
|
|
21
|
+
const sent=[];
|
|
22
|
+
const fetcher=async(url,init)=>{sent.push({url,...JSON.parse(init.body)});return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));};
|
|
23
|
+
await client.queryWidget(config,config.widgets[0],{month:'2024-02'},undefined,fetcher);
|
|
24
|
+
await client.queryWidget(config,config.widgets[1],{month:'2024-02'},undefined,fetcher);
|
|
25
|
+
assert.deepEqual(sent.map(s=>s.reportEntryId),['directory','directory']);
|
|
26
|
+
assert.deepEqual(sent.map(s=>s.widgetIds),[['a'],['b']]);
|
|
27
|
+
assert.deepEqual(sent.map(s=>s.refer),[{month:['2024-02']},{}]);
|
|
28
|
+
let calls=0;
|
|
29
|
+
await assert.rejects(client.queryWidget(config,config.widgets[0],{},undefined,async()=>{calls++;return new Response('{}',{status:403});}),e=>e.code==='PERMISSION_DENIED');
|
|
30
|
+
assert.equal(calls,1);
|
|
31
|
+
});
|
|
32
|
+
test('component loader limits concurrency, publishes independent failures and respects cancellation',async()=>{
|
|
33
|
+
const updates=[];let active=0,peak=0;
|
|
34
|
+
const many={...config,widgets:[...config.widgets,...Array.from({length:6},(_,i)=>({entryId:'extra-'+i,name:'Extra',type:'table'}))]};
|
|
35
|
+
await queries.loadWidgets(many,{},new AbortController().signal,(id,state)=>updates.push([id,state]),async(_url,init)=>{
|
|
36
|
+
active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,5));active--;
|
|
37
|
+
const id=JSON.parse(init.body).widgetIds[0];return id==='a'?new Response('{}',{status:500}):new Response(JSON.stringify({success:true,data:{[id]:native}}));
|
|
38
|
+
});
|
|
39
|
+
assert.equal(peak,3);assert.ok(updates.find(([id,s])=>id==='0:a'&&s.error));assert.ok(updates.find(([id,s])=>id==='1:b'&&s.result));
|
|
40
|
+
const controller=new AbortController();controller.abort();let calls=0;
|
|
41
|
+
await queries.loadWidgets(config,{},controller.signal,()=>assert.fail('no stale update'),async()=>{calls++;});assert.equal(calls,0);
|
|
42
|
+
});
|
|
43
|
+
test('same native widget can have separate display scopes; aborted old responses publish nothing',async()=>{
|
|
44
|
+
const duplicate={...config,widgets:[config.widgets[0],{...config.widgets[0],filterNames:[]}]};
|
|
45
|
+
const complete=[];
|
|
46
|
+
await queries.loadWidgets(duplicate,{month:'2024-02'},new AbortController().signal,(key,state)=>{if(state.result)complete.push(key);},async()=>new Response(JSON.stringify({success:true,data:{a:native}})));
|
|
47
|
+
assert.deepEqual(complete.sort(),['0:a','1:a']);
|
|
48
|
+
const controller=new AbortController(),updates=[];
|
|
49
|
+
await queries.loadWidgets(config,{},controller.signal,(_key,state)=>updates.push(state),async()=>{controller.abort();return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));});
|
|
50
|
+
assert.ok(updates.every(s=>s.loading));
|
|
51
|
+
});
|
|
52
|
+
const cell=(value,state='AVAILABLE',display=value)=>({value,display,state,reason:null});
|
|
53
|
+
const dataset={id:'main',returnedRowCount:4,columns:[{fieldId:'x',key:'c0',name:'名字',type:'STRING'},{fieldId:'v',key:'c1',name:'金额',type:'DECIMAL'}],rows:[
|
|
54
|
+
{kind:'DATA',cells:[cell('=HYPERLINK("x")'),cell('9007199254740993.01')]},
|
|
55
|
+
{kind:'DATA',cells:[cell('B'),cell('9007199254740993.02','AVAILABLE','9,007,199,254,740,993.02元')]},
|
|
56
|
+
{kind:'UNKNOWN',cells:[cell('C'),cell(null,'RESTRICTED','***')]},
|
|
57
|
+
{kind:'DATA',cells:[cell('D'),cell(null,'NULL',null)]},
|
|
58
|
+
]};
|
|
59
|
+
test('loaded table sorts decimals exactly, filters and exports matching rows with safe CSV and explicit states',()=>{
|
|
60
|
+
assert.equal(data.compareDecimal('9007199254740993.01','9007199254740993.02'),-1);
|
|
61
|
+
assert.equal(data.compareDecimal('-1.001e3','-1001'),0);
|
|
62
|
+
assert.equal(data.compareDecimal('1e-10','0.00000000009'),1);
|
|
63
|
+
assert.equal(data.compareDecimal('1.20','1.2'),0);
|
|
64
|
+
assert.equal(data.compareDecimal('1200','1.2e3'),0);
|
|
65
|
+
assert.equal(data.compareDecimal('-0.001200','-1.2e-3'),0);
|
|
66
|
+
const rows=data.tableRows(dataset,{search:'',sort:{fieldId:'v',direction:'desc'},filters:{}});
|
|
67
|
+
assert.equal(rows[0].cells[0].value,'B');assert.equal(rows[1].cells[0].value,'=HYPERLINK("x")');
|
|
68
|
+
const filtered=data.tableRows(dataset,{search:'',filters:{x:'B'}});assert.equal(filtered.length,1);
|
|
69
|
+
const csv=data.tableCsv(dataset,rows);
|
|
70
|
+
assert.match(csv,/'=HYPERLINK/);assert.match(csv,/受限/);assert.doesNotMatch(csv,/\*\*\*/);assert.match(csv,/UNKNOWN/);
|
|
71
|
+
assert.match(data.tableCsv(dataset,filtered),/9,007,199/);
|
|
72
|
+
assert.throws(()=>data.tableRows(dataset,{search:'',filters:{missing:'x'}}),/字段/);
|
|
73
|
+
});
|