@finebi/finebi-cli 2.0.0 → 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.
Files changed (112) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/skills/finebi-cli-agent/SKILL.md +1 -1
  4. package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
  5. package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
  6. package/skills/finebi-cli-agent/references/web-app.md +4 -1
  7. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  8. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +160 -160
  9. package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +27 -27
  10. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +55 -55
  11. package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
  12. package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
  13. package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +96 -95
  14. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +1 -1
  15. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -80
  16. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -22
  17. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
  18. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/gitignore.template +6 -6
  19. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
  20. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -63
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -17
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -40
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -3
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -58
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -235
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -20
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
  56. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -70
  57. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -31
  58. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -189
  59. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
  60. package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
  61. package/src/app.js +160 -160
  62. package/src/config.js +27 -27
  63. package/src/help.js +55 -55
  64. package/src/install-skill.js +89 -89
  65. package/src/profiles.js +158 -158
  66. package/src/skill-files.js +96 -95
  67. package/templates/web-app/AGENTS.md +1 -1
  68. package/templates/web-app/docs/acceptance.md +80 -80
  69. package/templates/web-app/examples/cockpit.config.json +22 -22
  70. package/templates/web-app/examples/retail.config.json +185 -185
  71. package/templates/web-app/gitignore.template +6 -6
  72. package/templates/web-app/index.html +2 -2
  73. package/templates/web-app/public/app-config.json +11 -11
  74. package/templates/web-app/src/App.tsx +63 -63
  75. package/templates/web-app/src/cockpit.css +3 -3
  76. package/templates/web-app/src/components/Chart.tsx +40 -40
  77. package/templates/web-app/src/components/Insights.tsx +17 -17
  78. package/templates/web-app/src/components/MonthPicker.tsx +32 -32
  79. package/templates/web-app/src/components/OptionFilter.tsx +7 -7
  80. package/templates/web-app/src/components/RangeFilter.tsx +9 -9
  81. package/templates/web-app/src/components/Widget.tsx +40 -40
  82. package/templates/web-app/src/components/calendar-locales.ts +3 -3
  83. package/templates/web-app/src/components/calendar.ts +14 -14
  84. package/templates/web-app/src/components/charts/Bar.tsx +6 -6
  85. package/templates/web-app/src/components/charts/Line.tsx +6 -6
  86. package/templates/web-app/src/components/charts/Pie.tsx +6 -6
  87. package/templates/web-app/src/components/month-picker.css +1 -1
  88. package/templates/web-app/src/finebi/config.d.ts +3 -3
  89. package/templates/web-app/src/finebi/config.js +58 -58
  90. package/templates/web-app/src/finebi/date.d.ts +1 -1
  91. package/templates/web-app/src/finebi/date.js +5 -5
  92. package/templates/web-app/src/finebi/filters.d.ts +4 -4
  93. package/templates/web-app/src/finebi/filters.js +106 -106
  94. package/templates/web-app/src/finebi/normalize.ts +235 -235
  95. package/templates/web-app/src/finebi/types.ts +20 -20
  96. package/templates/web-app/src/main.tsx +5 -5
  97. package/templates/web-app/src/retail.css +12 -12
  98. package/templates/web-app/src/services/presentation.ts +36 -36
  99. package/templates/web-app/src/services/table-data.ts +47 -47
  100. package/templates/web-app/src/services/view-data.ts +18 -18
  101. package/templates/web-app/src/services/widget-queries.ts +18 -18
  102. package/templates/web-app/tests/browser/fixtures.mjs +28 -28
  103. package/templates/web-app/tests/browser/index.html +16 -16
  104. package/templates/web-app/tests/browser/main.tsx +149 -149
  105. package/templates/web-app/tests/browser/test-page.css +4 -4
  106. package/templates/web-app/tests/browser/vite.config.mjs +13 -13
  107. package/templates/web-app/tests/bundle.test.mjs +19 -19
  108. package/templates/web-app/tests/cockpit.test.mjs +73 -73
  109. package/templates/web-app/tests/field-selection.test.mjs +70 -70
  110. package/templates/web-app/tests/retail.test.mjs +31 -31
  111. package/templates/web-app/tests/table-normalize.test.mjs +189 -189
  112. package/templates/web-app/tsconfig.json +4 -4
@@ -1,70 +1,70 @@
1
- import assert from 'node:assert/strict';
2
- import {after,test} from 'node:test';
3
- import {createServer as createHttpServer} from 'node:http';
4
- import {createServer} from 'vite';
5
- import {configErrors} from '../src/finebi/config.js';
6
- import {createDevMiddleware} from '../scripts/dev-proxy.mjs';
7
- const server=await createServer({configFile:false,cacheDir:'temp/vite-field-selection-tests',server:{middlewareMode:true,hmr:false}});
8
- after(()=>server.close());
9
- const {queryWidget,queryWidgets}=await server.ssrLoadModule('/src/finebi/client.ts');
10
- const binding={entryId:'w',name:'Table',type:'table'};
11
- const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'overview',dashboardId:'r',maxRows:10,pageSize:5,widgets:[binding],filters:[]};
12
- const response=()=>new Response(JSON.stringify({success:true,data:{w:{previewResult:{resultType:'DETAIL',header:[],items:[],row:0}}}}));
13
-
14
- test('optional dimensionIds validates lists without confusing omission and empty selection',()=>{
15
- for(const ids of [undefined,[],['city'],['sales'],['city','sales']]) {
16
- const widget=ids===undefined?binding:{...binding,dimensionIds:ids};
17
- assert.deepEqual(configErrors({...config,widgets:[widget]}),[]);
18
- }
19
- for(const ids of [null,'sales',[1],[''],[' '],[null],['sales','sales']]) {
20
- assert.ok(configErrors({...config,widgets:[{...binding,dimensionIds:ids}]}).some(e=>e.includes('dimensionIds')));
21
- }
22
- });
23
-
24
- test('binding selection reaches ordinary and published queries unchanged, including omission',async()=>{
25
- for(const directoryEntryId of [undefined,'directory']) {
26
- const widgets=[binding,...[[],['city'],['sales'],['city','sales']].map(dimensionIds=>({...binding,dimensionIds}))];
27
- const app={...config,...(directoryEntryId?{directoryEntryId}:{}),widgets};
28
- for(const widget of widgets) {
29
- await queryWidget(app,widget,{},undefined,async(_url,init)=>{
30
- const body=JSON.parse(init.body);
31
- assert.deepEqual(body.dimensionSelections,widget.dimensionIds===undefined?undefined:{w:widget.dimensionIds});
32
- assert.equal(Object.hasOwn(body,'dimensionSelections'),widget.dimensionIds!==undefined);
33
- assert.equal(body.reportEntryId,directoryEntryId);
34
- return response();
35
- });
36
- }
37
- await assert.rejects(queryWidgets(app,{},undefined,()=>assert.fail('conflicting bindings must not fetch')),/selection|选择/);
38
- }
39
- });
40
-
41
- test('invalid selection fails before browser HTTP and matching batch selections can share an instance',async()=>{
42
- await assert.rejects(queryWidgets({...config,widgets:[{...binding,dimensionIds:null}]},{},undefined,()=>assert.fail('invalid config must not fetch')),/dimensionIds/);
43
- const widgets=[{...binding,dimensionIds:['city','sales']},{...binding,dimensionIds:['sales','city']}];
44
- await queryWidgets({...config,widgets},{},undefined,async(_url,init)=>{
45
- const body=JSON.parse(init.body);assert.deepEqual(body.widgetIds,['w']);
46
- assert.deepEqual(body.dimensionSelections,{w:['city','sales']});return response();
47
- });
48
- });
49
-
50
- test('proxy preserves configured choices and rejects malformed or unconfigured selections before upstream',async()=>{
51
- const sent=[];
52
- const widgets=[binding,...[[],['sales'],['city','sales']].map(dimensionIds=>({...binding,dimensionIds}))];
53
- const middleware=createDevMiddleware({...config,directoryEntryId:'directory',widgets},
54
- {FINEBI_DEV_BASE_URL:'http://bi.example/bi',FINEBI_DEV_TOKEN:'secret'},async(_url,init)=>{sent.push(JSON.parse(init.body));return response();});
55
- const http=createHttpServer((req,res)=>middleware(req,res,()=>{}));
56
- await new Promise(resolve=>http.listen(0,'127.0.0.1',resolve));
57
- const send=extra=>fetch(`http://127.0.0.1:${http.address().port}/bi/v5/api/dashboard/report/r/widgets/query`,{
58
- method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10,reportEntryId:'directory',...extra})});
59
- try {
60
- for(const selections of [null,[],{other:['sales']},{w:null},{w:['']},{w:['sales','sales']},{w:['unknown']},{w:['city']}]) {
61
- assert.equal((await send({dimensionSelections:selections})).status,400);
62
- }
63
- assert.equal(sent.length,0);
64
- for(const selections of [undefined,{w:[]},{w:['sales']},{w:['city','sales']}]) {
65
- assert.equal((await send(selections===undefined?{}:{dimensionSelections:selections})).status,200);
66
- assert.deepEqual(sent.at(-1).dimensionSelections,selections);
67
- assert.equal(sent.at(-1).reportEntryId,'directory');
68
- }
69
- } finally {middleware.dispose?.();await new Promise(resolve=>http.close(resolve));}
70
- });
1
+ import assert from 'node:assert/strict';
2
+ import {after,test} from 'node:test';
3
+ import {createServer as createHttpServer} from 'node:http';
4
+ import {createServer} from 'vite';
5
+ import {configErrors} from '../src/finebi/config.js';
6
+ import {createDevMiddleware} from '../scripts/dev-proxy.mjs';
7
+ const server=await createServer({configFile:false,cacheDir:'temp/vite-field-selection-tests',server:{middlewareMode:true,hmr:false}});
8
+ after(()=>server.close());
9
+ const {queryWidget,queryWidgets}=await server.ssrLoadModule('/src/finebi/client.ts');
10
+ const binding={entryId:'w',name:'Table',type:'table'};
11
+ const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'overview',dashboardId:'r',maxRows:10,pageSize:5,widgets:[binding],filters:[]};
12
+ const response=()=>new Response(JSON.stringify({success:true,data:{w:{previewResult:{resultType:'DETAIL',header:[],items:[],row:0}}}}));
13
+
14
+ test('optional dimensionIds validates lists without confusing omission and empty selection',()=>{
15
+ for(const ids of [undefined,[],['city'],['sales'],['city','sales']]) {
16
+ const widget=ids===undefined?binding:{...binding,dimensionIds:ids};
17
+ assert.deepEqual(configErrors({...config,widgets:[widget]}),[]);
18
+ }
19
+ for(const ids of [null,'sales',[1],[''],[' '],[null],['sales','sales']]) {
20
+ assert.ok(configErrors({...config,widgets:[{...binding,dimensionIds:ids}]}).some(e=>e.includes('dimensionIds')));
21
+ }
22
+ });
23
+
24
+ test('binding selection reaches ordinary and published queries unchanged, including omission',async()=>{
25
+ for(const directoryEntryId of [undefined,'directory']) {
26
+ const widgets=[binding,...[[],['city'],['sales'],['city','sales']].map(dimensionIds=>({...binding,dimensionIds}))];
27
+ const app={...config,...(directoryEntryId?{directoryEntryId}:{}),widgets};
28
+ for(const widget of widgets) {
29
+ await queryWidget(app,widget,{},undefined,async(_url,init)=>{
30
+ const body=JSON.parse(init.body);
31
+ assert.deepEqual(body.dimensionSelections,widget.dimensionIds===undefined?undefined:{w:widget.dimensionIds});
32
+ assert.equal(Object.hasOwn(body,'dimensionSelections'),widget.dimensionIds!==undefined);
33
+ assert.equal(body.reportEntryId,directoryEntryId);
34
+ return response();
35
+ });
36
+ }
37
+ await assert.rejects(queryWidgets(app,{},undefined,()=>assert.fail('conflicting bindings must not fetch')),/selection|选择/);
38
+ }
39
+ });
40
+
41
+ test('invalid selection fails before browser HTTP and matching batch selections can share an instance',async()=>{
42
+ await assert.rejects(queryWidgets({...config,widgets:[{...binding,dimensionIds:null}]},{},undefined,()=>assert.fail('invalid config must not fetch')),/dimensionIds/);
43
+ const widgets=[{...binding,dimensionIds:['city','sales']},{...binding,dimensionIds:['sales','city']}];
44
+ await queryWidgets({...config,widgets},{},undefined,async(_url,init)=>{
45
+ const body=JSON.parse(init.body);assert.deepEqual(body.widgetIds,['w']);
46
+ assert.deepEqual(body.dimensionSelections,{w:['city','sales']});return response();
47
+ });
48
+ });
49
+
50
+ test('proxy preserves configured choices and rejects malformed or unconfigured selections before upstream',async()=>{
51
+ const sent=[];
52
+ const widgets=[binding,...[[],['sales'],['city','sales']].map(dimensionIds=>({...binding,dimensionIds}))];
53
+ const middleware=createDevMiddleware({...config,directoryEntryId:'directory',widgets},
54
+ {FINEBI_DEV_BASE_URL:'http://bi.example/bi',FINEBI_DEV_TOKEN:'secret'},async(_url,init)=>{sent.push(JSON.parse(init.body));return response();});
55
+ const http=createHttpServer((req,res)=>middleware(req,res,()=>{}));
56
+ await new Promise(resolve=>http.listen(0,'127.0.0.1',resolve));
57
+ const send=extra=>fetch(`http://127.0.0.1:${http.address().port}/bi/v5/api/dashboard/report/r/widgets/query`,{
58
+ method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10,reportEntryId:'directory',...extra})});
59
+ try {
60
+ for(const selections of [null,[],{other:['sales']},{w:null},{w:['']},{w:['sales','sales']},{w:['unknown']},{w:['city']}]) {
61
+ assert.equal((await send({dimensionSelections:selections})).status,400);
62
+ }
63
+ assert.equal(sent.length,0);
64
+ for(const selections of [undefined,{w:[]},{w:['sales']},{w:['city','sales']}]) {
65
+ assert.equal((await send(selections===undefined?{}:{dimensionSelections:selections})).status,200);
66
+ assert.deepEqual(sent.at(-1).dimensionSelections,selections);
67
+ assert.equal(sent.at(-1).reportEntryId,'directory');
68
+ }
69
+ } finally {middleware.dispose?.();await new Promise(resolve=>http.close(resolve));}
70
+ });
@@ -1,31 +1,31 @@
1
- import assert from 'node:assert/strict';
2
- import {after,test} from 'node:test';
3
- import {createServer} from 'vite';
4
- const server=await createServer({configFile:false,cacheDir:'temp/vite-retail-tests',server:{middlewareMode:true,hmr:false}});
5
- after(()=>server.close());
6
- const {formatMetric,formatCategory}=await server.ssrLoadModule('/src/services/presentation.ts');
7
- const {configErrors}=await import('../src/finebi/config.js');
8
- const cell=(value,state='AVAILABLE')=>({value,display:null,state,reason:null});
9
- test('KPI display preserves decimal precision and formats ratios only by explicit configuration',()=>{
10
- assert.equal(formatMetric(cell('9007199254740993.01'),'number'),'9,007,199,254,740,993.01');
11
- assert.equal(formatMetric(cell('1.2345678e7'),'number'),'12,345,678');
12
- assert.equal(formatMetric(cell('0.123456789'),'percent'),'12.35%');
13
- assert.equal(formatMetric(cell('-0.00005'),'percent'),'-0.01%');
14
- assert.equal(formatMetric(cell('42.0'),'number'),'42');
15
- assert.equal(formatMetric(cell('100','RESTRICTED'),'number'),'受限');
16
- assert.equal(formatMetric(cell(null,'NULL'),'percent'),'—');
17
- assert.equal(formatMetric(cell('0.5')),'0.5');
18
- });
19
- test('month formatting is explicit, timezone-aware and preserves invalid or restricted values',()=>{
20
- const binding={categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'};
21
- const timestamp=String(Date.UTC(2024,1,29,16));
22
- assert.equal(formatCategory(cell(timestamp),binding),'2024-03');
23
- assert.equal(formatCategory(cell(timestamp),{}),timestamp);
24
- assert.equal(formatCategory(cell('not-a-date'),binding),'not-a-date');
25
- assert.equal(formatCategory(cell(timestamp,'RESTRICTED'),binding),'受限');
26
- });
27
- test('display configuration rejects implicit date timezone and invalid formats',()=>{
28
- const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'retail',dashboardId:'r',maxRows:1000,pageSize:8,filters:[],widgets:[{entryId:'w',name:'Trend',type:'line',categoryFieldId:'x',valueFieldId:'y',categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'}]};
29
- assert.deepEqual(configErrors(config),[]);
30
- for(const patch of [{categoryTimeZone:undefined},{categoryTimeZone:'Bad/Timezone'},{categoryFormat:'guess'},{valueFormat:'guess'}])assert.ok(configErrors({...config,widgets:[{...config.widgets[0],...patch}]}).length);
31
- });
1
+ import assert from 'node:assert/strict';
2
+ import {after,test} from 'node:test';
3
+ import {createServer} from 'vite';
4
+ const server=await createServer({configFile:false,cacheDir:'temp/vite-retail-tests',server:{middlewareMode:true,hmr:false}});
5
+ after(()=>server.close());
6
+ const {formatMetric,formatCategory}=await server.ssrLoadModule('/src/services/presentation.ts');
7
+ const {configErrors}=await import('../src/finebi/config.js');
8
+ const cell=(value,state='AVAILABLE')=>({value,display:null,state,reason:null});
9
+ test('KPI display preserves decimal precision and formats ratios only by explicit configuration',()=>{
10
+ assert.equal(formatMetric(cell('9007199254740993.01'),'number'),'9,007,199,254,740,993.01');
11
+ assert.equal(formatMetric(cell('1.2345678e7'),'number'),'12,345,678');
12
+ assert.equal(formatMetric(cell('0.123456789'),'percent'),'12.35%');
13
+ assert.equal(formatMetric(cell('-0.00005'),'percent'),'-0.01%');
14
+ assert.equal(formatMetric(cell('42.0'),'number'),'42');
15
+ assert.equal(formatMetric(cell('100','RESTRICTED'),'number'),'受限');
16
+ assert.equal(formatMetric(cell(null,'NULL'),'percent'),'—');
17
+ assert.equal(formatMetric(cell('0.5')),'0.5');
18
+ });
19
+ test('month formatting is explicit, timezone-aware and preserves invalid or restricted values',()=>{
20
+ const binding={categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'};
21
+ const timestamp=String(Date.UTC(2024,1,29,16));
22
+ assert.equal(formatCategory(cell(timestamp),binding),'2024-03');
23
+ assert.equal(formatCategory(cell(timestamp),{}),timestamp);
24
+ assert.equal(formatCategory(cell('not-a-date'),binding),'not-a-date');
25
+ assert.equal(formatCategory(cell(timestamp,'RESTRICTED'),binding),'受限');
26
+ });
27
+ test('display configuration rejects implicit date timezone and invalid formats',()=>{
28
+ const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'retail',dashboardId:'r',maxRows:1000,pageSize:8,filters:[],widgets:[{entryId:'w',name:'Trend',type:'line',categoryFieldId:'x',valueFieldId:'y',categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'}]};
29
+ assert.deepEqual(configErrors(config),[]);
30
+ for(const patch of [{categoryTimeZone:undefined},{categoryTimeZone:'Bad/Timezone'},{categoryFormat:'guess'},{valueFormat:'guess'}])assert.ok(configErrors({...config,widgets:[{...config.widgets[0],...patch}]}).length);
31
+ });
@@ -1,189 +1,189 @@
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-table-tests', server: {middlewareMode: true, hmr: false}});
7
- after(() => server.close());
8
- const {normalize} = await server.ssrLoadModule('/src/finebi/normalize.ts');
9
- const {DataTable} = await server.ssrLoadModule('/src/components/DataTable.tsx');
10
- const {tableCsv} = await server.ssrLoadModule('/src/services/table-data.ts');
11
- const metric = (text, rowSumInfo = {totalSum: false, innerSum: false}, extra = {}) => ({dId: 'amount', text, authority: true, rowSumInfo, ...extra});
12
- const group = items => ({previewResult: {resultType: 'BIGROUP', header: [{dId: 'region', text: '地区'}, {dId: 'city', text: '城市'}, {dId: 'amount', text: '金额'}], items, hasNext: true, hasPrev: false}, widgetBean: {dimensions: {region: {type: 1}, city: {type: 1}}, settings: {tableStyle: {rowCounts: 10}}}});
13
- const detailInfo = {totalSum: false, innerSum: false};
14
- const totalInfo = {totalSum: true, innerSum: false};
15
- const node = (dId, text, children = null, values = null) => ({dId, text, formatText: text, authority: true, children, values});
16
- const crossMetric = (text, colSumInfo = detailInfo, rowSumInfo = detailInfo, dId = 'amount') => ({dId, text, authority: true, colSumInfo, rowSumInfo});
17
- const cross = (top, left) => ({previewResult: {resultType: 'BICROSS', top: {header: [{dId: 'year', text: '年'}], items: [node(null, null, top)]}, left: {header: [{dId: 'region', text: '地区'}], items: [node(null, null, left)]}, hasXNext: true, hasXPrev: false, hasYNext: false, hasYPrev: false}});
18
-
19
- test('cross display removes only repeated metric captions and DATA summary carriers', () => {
20
- const source = cross([node('region', '金额', [node('target-name', '金额', null, [node('amount', '金额')])])], [
21
- node('region', '合计', [node('__summary_dim_id__', '合计', null, [crossMetric('1')])]),
22
- node('__summary_dim_id__', '合计', null, [crossMetric('2', detailInfo, totalInfo)]),
23
- node('__summary_dim_id__', '合计', null, [crossMetric('3', detailInfo, {totalSum: false, innerSum: true})]),
24
- node('__summary_dim_id__', '合计', null, [{...crossMetric('4'), rowSumInfo: null}]),
25
- ]);
26
- const data = normalize(source).datasets[0];
27
- assert.equal(data.columns[1].name, '金额 / 金额', 'real repeated grouping labels must remain');
28
- assert.equal(data.columns[1].path.length, 3, 'source path metadata must remain intact');
29
- assert.equal(data.rows[0].cells[0].display, '合计', 'business dimension named 合计 must remain');
30
- assert.equal(data.rows[0].path.length, 2);
31
- assert.deepEqual(data.rows.slice(1).map(row => [row.kind, row.cells[0].display]), [['GRAND_TOTAL', '合计'], ['SUBTOTAL', '合计'], ['UNKNOWN', '合计']]);
32
- });
33
-
34
- test('cross metric rows deduplicate only the verified trailing metric caption', () => {
35
- const rows = [
36
- node('region', '金额', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('7')])])]),
37
- node('region', '金额', [node('other-dimension', '金额', null, [crossMetric('8')])]),
38
- node('target-name', '金额', [node('amount', '别名', null, [crossMetric('9')])]),
39
- node('target-name', '金额', [node('amount', '金额', null, [crossMetric('10', detailInfo, detailInfo, 'count')])]),
40
- ];
41
- const data = normalize(cross([node('year', '2024', null, [{}])], rows)).datasets[0];
42
- assert.deepEqual(data.rows.map(r => r.cells[0].display), ['金额 / 金额', '金额 / 金额', '金额 / 别名', '金额 / 金额']);
43
- assert.equal(data.rows[0].path.length, 3);
44
- assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
45
- const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
46
- assert.doesNotMatch(html, /金额 \/ 金额 \/ 金额/);
47
- assert.doesNotMatch(tableCsv(data, data.rows), /金额 \/ 金额 \/ 金额/);
48
- });
49
-
50
- test('cross real null renders as dash while placeholders, missing payload and permissions stay distinct', () => {
51
- const empty = {...crossMetric(null), value: null, formatText: null};
52
- const cells = [empty, {...empty, dId: null}, {...empty, authority: false}, {...empty, authority: undefined}, {dId: 'amount', authority: true}, {...empty, formatText: '仅展示'}];
53
- const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
54
- assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.state), ['NULL', 'UNAVAILABLE', 'RESTRICTED', 'UNAVAILABLE', 'UNAVAILABLE', 'UNAVAILABLE']);
55
- assert.equal(data.rows[0].cells[1].reason, null);
56
- const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
57
- assert.match(html, /<td>—<\/td><td>不可用<\/td><td>受限<\/td>/);
58
- assert.match(tableCsv(data, data.rows), /"—","不可用","受限"/);
59
- });
60
-
61
- test('cross placeholders without valid metric ids never become available numeric values', () => {
62
- const cells = [null, undefined, '', ' ', 12].map(dId => ({...crossMetric('12'), dId, value: 12}));
63
- const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
64
- for (const cell of data.rows[0].cells.slice(1)) {
65
- assert.equal(cell.state, 'UNAVAILABLE');
66
- assert.equal(cell.value, null);
67
- assert.equal(cell.sourceFieldId, null);
68
- }
69
- });
70
-
71
- test('cross table aligns repeated metric ids by column slots and preserves both axes and totals', () => {
72
- for (const totalFirst of [false, true]) {
73
- const columns = [node('year', '2024', null, [node('amount', '金额')]), node('year', '2025', null, [node('amount', '金额')])];
74
- const summary = node('__summary_dim_id__', '合计', [node('amount', '金额', null, [{}])]);
75
- columns.splice(totalFirst ? 0 : columns.length, 0, summary);
76
- const values = [crossMetric('9007199254740993.12'), crossMetric('2.50')];
77
- values.splice(totalFirst ? 0 : values.length, 0, crossMetric('9007199254740995.62', totalInfo));
78
- const source = cross(columns, [node('region', '东部', [node('__summary_dim_id__', null, null, values)]), node('__summary_dim_id__', '合计', [node('amount', '金额', null, values.map(v => ({...v, rowSumInfo: totalInfo})))])]);
79
- const result = normalize(source), data = result.datasets[0];
80
- assert.equal(result.resultType, 'CROSS_TABLE'); assert.equal(result.normalization, 'COMPLETE');
81
- assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.value), values.map(v => v.text));
82
- assert.equal(new Set(data.columns.map(c => c.fieldId)).size, 4);
83
- assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
84
- assert.deepEqual(data.rows.map(r => r.kind), ['DATA', 'GRAND_TOTAL']);
85
- assert.equal(data.columns[totalFirst ? 1 : 3].kind, 'GRAND_TOTAL');
86
- assert.equal(result.range.mode, 'INITIAL_VIEW'); assert.equal(result.range.page, null);
87
- assert.deepEqual(result.range.axes, {horizontal: {hasNext: true, hasPrevious: false}, vertical: {hasNext: false, hasPrevious: false}});
88
- assert.equal(result.range.hasNext, true); assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
89
- const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
90
- assert.match(html, /2024/); assert.match(html, /2025/); assert.match(html, /总计/);
91
- assert.match(tableCsv(data, data.rows), /9007199254740993.12/);
92
- assert.equal(source.previewResult.top.items[0].children.length, 3);
93
- }
94
- });
95
-
96
- test('cross table supports metrics on rows and multi-level columns without id joins', () => {
97
- const source = cross([node('year', '2024', [node('quarter', 'Q1', null, []), node('quarter', 'Q2', null, [])])], [
98
- node('region', '东部', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('12'), crossMetric('18')])]), node('target-name', '数量', [node('count', '数量', null, [crossMetric('3', detailInfo, detailInfo, 'count'), crossMetric('4', detailInfo, detailInfo, 'count')])])]),
99
- ]);
100
- const data = normalize(source).datasets[0];
101
- assert.equal(data.columns.length, 3); assert.equal(data.rows.length, 2);
102
- assert.match(data.columns[1].name, /2024.*Q1/);
103
- assert.match(data.rows[1].cells[0].display, /东部.*数量/);
104
- assert.deepEqual(data.rows[1].cells.slice(1).map(c => c.sourceFieldId), ['count', 'count']);
105
- });
106
-
107
- test('cross table never exposes restricted axis labels and retains missing/ambiguous value states', () => {
108
- const secret = node('year', 'secret column', null, [{}]); secret.authority = false;
109
- const row = node('region', 'secret row', null, [crossMetric('123')]); row.authority = false;
110
- const result = normalize(cross([secret], [row]));
111
- assert.doesNotMatch(JSON.stringify(result), /secret column|secret row/);
112
- assert.equal(result.datasets[0].rows[0].cells[0].state, 'RESTRICTED');
113
- assert.equal(result.datasets[0].rows[0].cells[1].state, 'RESTRICTED');
114
- const source = cross([node('year', 'A', null, [{}, {}])], [node('region', 'X', null, [{...crossMetric('1万'), formatText: '1万'}])]);
115
- const incomplete = normalize(source);
116
- assert.equal(incomplete.normalization, 'PARTIAL');
117
- assert.equal(incomplete.datasets[0].rows[0].cells[1].state, 'UNAVAILABLE');
118
- assert.equal(incomplete.datasets[0].rows[0].cells[2].reason, 'MISSING_CELL');
119
- source.previewResult.left.items[0].children[0].values.push(crossMetric('2'), crossMetric('3'));
120
- assert.equal(normalize(source).normalization, 'UNSUPPORTED', 'extra values must not be silently dropped');
121
- });
122
-
123
- test('cross table distinguishes empty views, missing axes and unsupported mixed node topology', () => {
124
- assert.equal(normalize(cross([], [])).normalization, 'COMPLETE');
125
- assert.equal(normalize(cross([], [])).datasets[0].returnedRowCount, 0);
126
- assert.equal(normalize({previewResult: {resultType: 'BICROSS', left: {header: [], items: []}}}).normalization, 'UNSUPPORTED');
127
- assert.equal(normalize(cross([node('x', 'A', [node('x', 'B')], [{}])], [])).normalization, 'UNSUPPORTED');
128
- });
129
-
130
- test('cross table handles no column dimensions and preserves metric-name intersection placeholders', () => {
131
- const source = cross([], [node('target', '金额', null, [crossMetric('7'), {dId: null, text: null, value: null, authority: true}])]);
132
- source.previewResult.top = {header: [], items: []};
133
- const result = normalize(source);
134
- assert.equal(result.normalization, 'COMPLETE');
135
- assert.deepEqual(result.datasets[0].rows[0].cells.slice(1).map(c => c.state), ['AVAILABLE', 'UNAVAILABLE']);
136
- assert.equal(result.datasets[0].rows[0].cells[2].value, null);
137
- const swapped = cross([node('year', '2024', null, [node('amount', '金额')])], [node('region', '东部', null, [crossMetric('1', detailInfo, detailInfo, 'count')])]);
138
- assert.equal(normalize(swapped).normalization, 'PARTIAL');
139
- assert.equal(normalize(swapped).datasets[0].rows[0].cells[1].reason, 'AMBIGUOUS_FIELD');
140
- });
141
-
142
- test('group preserves parent keys, totals, metric precision and the first group page', () => {
143
- const source = group([{dId: 'region', text: '东部', authority: true, values: [metric('9007199254740993.12', {totalSum: false, innerSum: true})], children: [
144
- {dId: 'city', text: '甲城', authority: true, values: [metric('12.50', undefined, {formatText: '12.50元'})]},
145
- ]}, {values: [metric('9007199254741005.62', {totalSum: true, innerSum: false})]}]);
146
- const result = normalize(source), data = result.datasets[0];
147
- assert.equal(result.resultType, 'GROUP_TABLE');
148
- assert.equal(result.normalization, 'COMPLETE');
149
- assert.deepEqual(data.rows.map(r => r.kind), ['SUBTOTAL', 'DATA', 'GRAND_TOTAL']);
150
- assert.deepEqual(data.rows[1].cells.map(c => c.value), ['东部', '甲城', '12.50']);
151
- assert.equal(data.rows[0].cells[2].value, '9007199254740993.12');
152
- assert.equal(data.returnedRowCount, 3);
153
- assert.deepEqual(result.range, {mode: 'PAGE', page: 1, pageSize: 10, pageUnit: 'GROUP', hasNext: true, hasPrevious: false, total: {value: null, unit: 'GROUP'}});
154
- assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
155
- const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
156
- assert.match(html, /小计/); assert.match(html, /总计/);
157
- assert.match(tableCsv(data, data.rows), /GRAND_TOTAL/);
158
- });
159
-
160
- test('group keeps restricted and ambiguous cells unavailable and does not infer numeric group keys', () => {
161
- const source = group([{dId: 'region', text: '42', authority: true, values: [metric('12万', undefined, {formatText: '12万'})], children: [
162
- {dId: 'city', text: '秘密', authority: false, values: [metric('999', undefined, {authority: false})]},
163
- {dId: 'city', text: '乙城', authority: true, values: [metric('1'), metric('2')]},
164
- {dId: 'city', text: '丙城', authority: true, values: [metric(null, null)]},
165
- ]}]);
166
- source.widgetBean.dimensions.region.type = 2;
167
- const result = normalize(source), rows = result.datasets[0].rows;
168
- assert.equal(rows[0].cells[0].reason, 'GROUP_KEY_SEMANTICS_UNKNOWN');
169
- assert.equal(rows[0].cells[2].state, 'UNAVAILABLE');
170
- assert.equal(rows[1].cells[1].state, 'RESTRICTED');
171
- assert.equal(rows[1].cells[2].state, 'RESTRICTED');
172
- assert.equal(rows[2].cells[2].reason, 'AMBIGUOUS_FIELD');
173
- assert.equal(rows[3].cells[2].state, 'UNAVAILABLE');
174
- assert.equal(rows[3].kind, 'UNKNOWN');
175
- assert.equal(result.normalization, 'PARTIAL');
176
- });
177
-
178
- test('group header expansion reports an initial view and malformed groups are not empty successes', () => {
179
- const source = group([]);
180
- source.widgetBean.settings.tableStyle.showRowHeaderIcon = true;
181
- const result = normalize(source);
182
- assert.equal(result.range.mode, 'INITIAL_VIEW');
183
- assert.equal(result.range.page, null);
184
- assert.equal(result.range.hasNext, null);
185
- assert.equal(result.datasets[0].returnedRowCount, 0);
186
- assert.equal(normalize({previewResult: {resultType: 'BIGROUP'}}).normalization, 'UNSUPPORTED');
187
- source.previewResult.items = [null, {dId: 'unknown', values: [metric('1')]}];
188
- assert.equal(normalize(source).normalization, 'PARTIAL');
189
- });
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-table-tests', server: {middlewareMode: true, hmr: false}});
7
+ after(() => server.close());
8
+ const {normalize} = await server.ssrLoadModule('/src/finebi/normalize.ts');
9
+ const {DataTable} = await server.ssrLoadModule('/src/components/DataTable.tsx');
10
+ const {tableCsv} = await server.ssrLoadModule('/src/services/table-data.ts');
11
+ const metric = (text, rowSumInfo = {totalSum: false, innerSum: false}, extra = {}) => ({dId: 'amount', text, authority: true, rowSumInfo, ...extra});
12
+ const group = items => ({previewResult: {resultType: 'BIGROUP', header: [{dId: 'region', text: '地区'}, {dId: 'city', text: '城市'}, {dId: 'amount', text: '金额'}], items, hasNext: true, hasPrev: false}, widgetBean: {dimensions: {region: {type: 1}, city: {type: 1}}, settings: {tableStyle: {rowCounts: 10}}}});
13
+ const detailInfo = {totalSum: false, innerSum: false};
14
+ const totalInfo = {totalSum: true, innerSum: false};
15
+ const node = (dId, text, children = null, values = null) => ({dId, text, formatText: text, authority: true, children, values});
16
+ const crossMetric = (text, colSumInfo = detailInfo, rowSumInfo = detailInfo, dId = 'amount') => ({dId, text, authority: true, colSumInfo, rowSumInfo});
17
+ const cross = (top, left) => ({previewResult: {resultType: 'BICROSS', top: {header: [{dId: 'year', text: '年'}], items: [node(null, null, top)]}, left: {header: [{dId: 'region', text: '地区'}], items: [node(null, null, left)]}, hasXNext: true, hasXPrev: false, hasYNext: false, hasYPrev: false}});
18
+
19
+ test('cross display removes only repeated metric captions and DATA summary carriers', () => {
20
+ const source = cross([node('region', '金额', [node('target-name', '金额', null, [node('amount', '金额')])])], [
21
+ node('region', '合计', [node('__summary_dim_id__', '合计', null, [crossMetric('1')])]),
22
+ node('__summary_dim_id__', '合计', null, [crossMetric('2', detailInfo, totalInfo)]),
23
+ node('__summary_dim_id__', '合计', null, [crossMetric('3', detailInfo, {totalSum: false, innerSum: true})]),
24
+ node('__summary_dim_id__', '合计', null, [{...crossMetric('4'), rowSumInfo: null}]),
25
+ ]);
26
+ const data = normalize(source).datasets[0];
27
+ assert.equal(data.columns[1].name, '金额 / 金额', 'real repeated grouping labels must remain');
28
+ assert.equal(data.columns[1].path.length, 3, 'source path metadata must remain intact');
29
+ assert.equal(data.rows[0].cells[0].display, '合计', 'business dimension named 合计 must remain');
30
+ assert.equal(data.rows[0].path.length, 2);
31
+ assert.deepEqual(data.rows.slice(1).map(row => [row.kind, row.cells[0].display]), [['GRAND_TOTAL', '合计'], ['SUBTOTAL', '合计'], ['UNKNOWN', '合计']]);
32
+ });
33
+
34
+ test('cross metric rows deduplicate only the verified trailing metric caption', () => {
35
+ const rows = [
36
+ node('region', '金额', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('7')])])]),
37
+ node('region', '金额', [node('other-dimension', '金额', null, [crossMetric('8')])]),
38
+ node('target-name', '金额', [node('amount', '别名', null, [crossMetric('9')])]),
39
+ node('target-name', '金额', [node('amount', '金额', null, [crossMetric('10', detailInfo, detailInfo, 'count')])]),
40
+ ];
41
+ const data = normalize(cross([node('year', '2024', null, [{}])], rows)).datasets[0];
42
+ assert.deepEqual(data.rows.map(r => r.cells[0].display), ['金额 / 金额', '金额 / 金额', '金额 / 别名', '金额 / 金额']);
43
+ assert.equal(data.rows[0].path.length, 3);
44
+ assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
45
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
46
+ assert.doesNotMatch(html, /金额 \/ 金额 \/ 金额/);
47
+ assert.doesNotMatch(tableCsv(data, data.rows), /金额 \/ 金额 \/ 金额/);
48
+ });
49
+
50
+ test('cross real null renders as dash while placeholders, missing payload and permissions stay distinct', () => {
51
+ const empty = {...crossMetric(null), value: null, formatText: null};
52
+ const cells = [empty, {...empty, dId: null}, {...empty, authority: false}, {...empty, authority: undefined}, {dId: 'amount', authority: true}, {...empty, formatText: '仅展示'}];
53
+ const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
54
+ assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.state), ['NULL', 'UNAVAILABLE', 'RESTRICTED', 'UNAVAILABLE', 'UNAVAILABLE', 'UNAVAILABLE']);
55
+ assert.equal(data.rows[0].cells[1].reason, null);
56
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
57
+ assert.match(html, /<td>—<\/td><td>不可用<\/td><td>受限<\/td>/);
58
+ assert.match(tableCsv(data, data.rows), /"—","不可用","受限"/);
59
+ });
60
+
61
+ test('cross placeholders without valid metric ids never become available numeric values', () => {
62
+ const cells = [null, undefined, '', ' ', 12].map(dId => ({...crossMetric('12'), dId, value: 12}));
63
+ const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
64
+ for (const cell of data.rows[0].cells.slice(1)) {
65
+ assert.equal(cell.state, 'UNAVAILABLE');
66
+ assert.equal(cell.value, null);
67
+ assert.equal(cell.sourceFieldId, null);
68
+ }
69
+ });
70
+
71
+ test('cross table aligns repeated metric ids by column slots and preserves both axes and totals', () => {
72
+ for (const totalFirst of [false, true]) {
73
+ const columns = [node('year', '2024', null, [node('amount', '金额')]), node('year', '2025', null, [node('amount', '金额')])];
74
+ const summary = node('__summary_dim_id__', '合计', [node('amount', '金额', null, [{}])]);
75
+ columns.splice(totalFirst ? 0 : columns.length, 0, summary);
76
+ const values = [crossMetric('9007199254740993.12'), crossMetric('2.50')];
77
+ values.splice(totalFirst ? 0 : values.length, 0, crossMetric('9007199254740995.62', totalInfo));
78
+ const source = cross(columns, [node('region', '东部', [node('__summary_dim_id__', null, null, values)]), node('__summary_dim_id__', '合计', [node('amount', '金额', null, values.map(v => ({...v, rowSumInfo: totalInfo})))])]);
79
+ const result = normalize(source), data = result.datasets[0];
80
+ assert.equal(result.resultType, 'CROSS_TABLE'); assert.equal(result.normalization, 'COMPLETE');
81
+ assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.value), values.map(v => v.text));
82
+ assert.equal(new Set(data.columns.map(c => c.fieldId)).size, 4);
83
+ assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
84
+ assert.deepEqual(data.rows.map(r => r.kind), ['DATA', 'GRAND_TOTAL']);
85
+ assert.equal(data.columns[totalFirst ? 1 : 3].kind, 'GRAND_TOTAL');
86
+ assert.equal(result.range.mode, 'INITIAL_VIEW'); assert.equal(result.range.page, null);
87
+ assert.deepEqual(result.range.axes, {horizontal: {hasNext: true, hasPrevious: false}, vertical: {hasNext: false, hasPrevious: false}});
88
+ assert.equal(result.range.hasNext, true); assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
89
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
90
+ assert.match(html, /2024/); assert.match(html, /2025/); assert.match(html, /总计/);
91
+ assert.match(tableCsv(data, data.rows), /9007199254740993.12/);
92
+ assert.equal(source.previewResult.top.items[0].children.length, 3);
93
+ }
94
+ });
95
+
96
+ test('cross table supports metrics on rows and multi-level columns without id joins', () => {
97
+ const source = cross([node('year', '2024', [node('quarter', 'Q1', null, []), node('quarter', 'Q2', null, [])])], [
98
+ node('region', '东部', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('12'), crossMetric('18')])]), node('target-name', '数量', [node('count', '数量', null, [crossMetric('3', detailInfo, detailInfo, 'count'), crossMetric('4', detailInfo, detailInfo, 'count')])])]),
99
+ ]);
100
+ const data = normalize(source).datasets[0];
101
+ assert.equal(data.columns.length, 3); assert.equal(data.rows.length, 2);
102
+ assert.match(data.columns[1].name, /2024.*Q1/);
103
+ assert.match(data.rows[1].cells[0].display, /东部.*数量/);
104
+ assert.deepEqual(data.rows[1].cells.slice(1).map(c => c.sourceFieldId), ['count', 'count']);
105
+ });
106
+
107
+ test('cross table never exposes restricted axis labels and retains missing/ambiguous value states', () => {
108
+ const secret = node('year', 'secret column', null, [{}]); secret.authority = false;
109
+ const row = node('region', 'secret row', null, [crossMetric('123')]); row.authority = false;
110
+ const result = normalize(cross([secret], [row]));
111
+ assert.doesNotMatch(JSON.stringify(result), /secret column|secret row/);
112
+ assert.equal(result.datasets[0].rows[0].cells[0].state, 'RESTRICTED');
113
+ assert.equal(result.datasets[0].rows[0].cells[1].state, 'RESTRICTED');
114
+ const source = cross([node('year', 'A', null, [{}, {}])], [node('region', 'X', null, [{...crossMetric('1万'), formatText: '1万'}])]);
115
+ const incomplete = normalize(source);
116
+ assert.equal(incomplete.normalization, 'PARTIAL');
117
+ assert.equal(incomplete.datasets[0].rows[0].cells[1].state, 'UNAVAILABLE');
118
+ assert.equal(incomplete.datasets[0].rows[0].cells[2].reason, 'MISSING_CELL');
119
+ source.previewResult.left.items[0].children[0].values.push(crossMetric('2'), crossMetric('3'));
120
+ assert.equal(normalize(source).normalization, 'UNSUPPORTED', 'extra values must not be silently dropped');
121
+ });
122
+
123
+ test('cross table distinguishes empty views, missing axes and unsupported mixed node topology', () => {
124
+ assert.equal(normalize(cross([], [])).normalization, 'COMPLETE');
125
+ assert.equal(normalize(cross([], [])).datasets[0].returnedRowCount, 0);
126
+ assert.equal(normalize({previewResult: {resultType: 'BICROSS', left: {header: [], items: []}}}).normalization, 'UNSUPPORTED');
127
+ assert.equal(normalize(cross([node('x', 'A', [node('x', 'B')], [{}])], [])).normalization, 'UNSUPPORTED');
128
+ });
129
+
130
+ test('cross table handles no column dimensions and preserves metric-name intersection placeholders', () => {
131
+ const source = cross([], [node('target', '金额', null, [crossMetric('7'), {dId: null, text: null, value: null, authority: true}])]);
132
+ source.previewResult.top = {header: [], items: []};
133
+ const result = normalize(source);
134
+ assert.equal(result.normalization, 'COMPLETE');
135
+ assert.deepEqual(result.datasets[0].rows[0].cells.slice(1).map(c => c.state), ['AVAILABLE', 'UNAVAILABLE']);
136
+ assert.equal(result.datasets[0].rows[0].cells[2].value, null);
137
+ const swapped = cross([node('year', '2024', null, [node('amount', '金额')])], [node('region', '东部', null, [crossMetric('1', detailInfo, detailInfo, 'count')])]);
138
+ assert.equal(normalize(swapped).normalization, 'PARTIAL');
139
+ assert.equal(normalize(swapped).datasets[0].rows[0].cells[1].reason, 'AMBIGUOUS_FIELD');
140
+ });
141
+
142
+ test('group preserves parent keys, totals, metric precision and the first group page', () => {
143
+ const source = group([{dId: 'region', text: '东部', authority: true, values: [metric('9007199254740993.12', {totalSum: false, innerSum: true})], children: [
144
+ {dId: 'city', text: '甲城', authority: true, values: [metric('12.50', undefined, {formatText: '12.50元'})]},
145
+ ]}, {values: [metric('9007199254741005.62', {totalSum: true, innerSum: false})]}]);
146
+ const result = normalize(source), data = result.datasets[0];
147
+ assert.equal(result.resultType, 'GROUP_TABLE');
148
+ assert.equal(result.normalization, 'COMPLETE');
149
+ assert.deepEqual(data.rows.map(r => r.kind), ['SUBTOTAL', 'DATA', 'GRAND_TOTAL']);
150
+ assert.deepEqual(data.rows[1].cells.map(c => c.value), ['东部', '甲城', '12.50']);
151
+ assert.equal(data.rows[0].cells[2].value, '9007199254740993.12');
152
+ assert.equal(data.returnedRowCount, 3);
153
+ assert.deepEqual(result.range, {mode: 'PAGE', page: 1, pageSize: 10, pageUnit: 'GROUP', hasNext: true, hasPrevious: false, total: {value: null, unit: 'GROUP'}});
154
+ assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
155
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
156
+ assert.match(html, /小计/); assert.match(html, /总计/);
157
+ assert.match(tableCsv(data, data.rows), /GRAND_TOTAL/);
158
+ });
159
+
160
+ test('group keeps restricted and ambiguous cells unavailable and does not infer numeric group keys', () => {
161
+ const source = group([{dId: 'region', text: '42', authority: true, values: [metric('12万', undefined, {formatText: '12万'})], children: [
162
+ {dId: 'city', text: '秘密', authority: false, values: [metric('999', undefined, {authority: false})]},
163
+ {dId: 'city', text: '乙城', authority: true, values: [metric('1'), metric('2')]},
164
+ {dId: 'city', text: '丙城', authority: true, values: [metric(null, null)]},
165
+ ]}]);
166
+ source.widgetBean.dimensions.region.type = 2;
167
+ const result = normalize(source), rows = result.datasets[0].rows;
168
+ assert.equal(rows[0].cells[0].reason, 'GROUP_KEY_SEMANTICS_UNKNOWN');
169
+ assert.equal(rows[0].cells[2].state, 'UNAVAILABLE');
170
+ assert.equal(rows[1].cells[1].state, 'RESTRICTED');
171
+ assert.equal(rows[1].cells[2].state, 'RESTRICTED');
172
+ assert.equal(rows[2].cells[2].reason, 'AMBIGUOUS_FIELD');
173
+ assert.equal(rows[3].cells[2].state, 'UNAVAILABLE');
174
+ assert.equal(rows[3].kind, 'UNKNOWN');
175
+ assert.equal(result.normalization, 'PARTIAL');
176
+ });
177
+
178
+ test('group header expansion reports an initial view and malformed groups are not empty successes', () => {
179
+ const source = group([]);
180
+ source.widgetBean.settings.tableStyle.showRowHeaderIcon = true;
181
+ const result = normalize(source);
182
+ assert.equal(result.range.mode, 'INITIAL_VIEW');
183
+ assert.equal(result.range.page, null);
184
+ assert.equal(result.range.hasNext, null);
185
+ assert.equal(result.datasets[0].returnedRowCount, 0);
186
+ assert.equal(normalize({previewResult: {resultType: 'BIGROUP'}}).normalization, 'UNSUPPORTED');
187
+ source.previewResult.items = [null, {dId: 'unknown', values: [metric('1')]}];
188
+ assert.equal(normalize(source).normalization, 'PARTIAL');
189
+ });
@@ -1,4 +1,4 @@
1
- {
2
- "compilerOptions": {"target":"ES2020","lib":["ES2020","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"esModuleInterop":true,"resolveJsonModule":true,"isolatedModules":true,"noEmit":true},
3
- "include":["src","vite.config.ts"]
4
- }
1
+ {
2
+ "compilerOptions": {"target":"ES2020","lib":["ES2020","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"esModuleInterop":true,"resolveJsonModule":true,"isolatedModules":true,"noEmit":true},
3
+ "include":["src","vite.config.ts"]
4
+ }