sample-flow.test.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. import { test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { readFileSync } from 'node:fs';
  4. import { buildSampleModel, filterSample, sampleDetail, validateExtension } from '../src/core/sample.js';
  5. import { snapshot } from '../src/core/showcase.js';
  6. import { createSampleService, SAMPLE_STORAGE_KEY } from '../src/services/sample-data.js';
  7. const seed = JSON.parse(readFileSync(new URL('../data/preview-seed.json', import.meta.url), 'utf8'));
  8. const extension = JSON.parse(readFileSync(new URL('../src/assets/showcase/demo-extension.json', import.meta.url), 'utf8'));
  9. const model = buildSampleModel(seed, {}, extension);
  10. const storage = () => { const values = new Map(); return { getItem: (k) => values.get(k) ?? null, setItem: (k, v) => values.set(k, v), removeItem: (k) => values.delete(k), values }; };
  11. const transport = async (url) => ({ ok: true, json: async () => String(url).includes('preview-seed') ? structuredClone(seed) : structuredClone(extension) });
  12. test('334 baseline records remain separate from 16446 rich synthetic extension records', () => {
  13. assert.equal(model.baselineEntityCount, 334);
  14. assert.equal(Object.values(model.extensionCounts).reduce((n, x) => n + x, 0), 16446);
  15. assert.equal(new Set(extension.voices.map((v) => v.rawText)).size, 12000);
  16. assert.equal(model.evidence.length + extension.citations.length, 360);
  17. assert.equal(extension.prices.length, extension.observations.length * 4);
  18. assert.ok(extension.prices.some((p) => p.unitPricePer100G == null));
  19. assert.ok(extension.researchTasks.some((v) => v.state === 'failed' && v.failureReason));
  20. for (const p of extension.prices.filter((p) => p.unitPricePer100G != null)) assert.ok(Math.abs(p.unitPricePer100G - p.pricePaid / p.packCount / p.netWeightG * 100) <= .0051);
  21. });
  22. test('extension validation rejects live data and unrelated references', () => {
  23. const ext = structuredClone(extension);
  24. ext.voices[0].dataMode = 'live';
  25. assert.throws(() => validateExtension(ext, snapshot(seed)), /非模拟/);
  26. ext.voices[0].dataMode = 'sample'; ext.citations[0].quote = '不在原文里';
  27. assert.throws(() => validateExtension(ext, snapshot(seed)), /逐字/);
  28. });
  29. test('member, group and project filters agree across projects, reports, sources and cards', () => {
  30. const member = 'DEMO-M31';
  31. const f = filterSample(model, { member });
  32. assert.ok(f.projects.some((p) => p.id === 'DEMO-P10'), 'cross-group collaborator participates');
  33. const ids = new Set(f.projects.map((p) => p.id));
  34. assert.ok(f.reports.every((r) => ids.has(r.projectId)));
  35. assert.ok(f.opportunities.every((o) => ids.has(o.linkedProjectId)));
  36. const g = filterSample(model, { group: 'DEMO-G2' });
  37. assert.equal(g.projects.length, 2); assert.equal(g.opportunities.length, 4); assert.equal(g.voices.length, 2000);
  38. assert.ok(g.reports.every((r) => r.groupId === 'DEMO-G2'));
  39. assert.equal(filterSample(model, { group: 'DEMO-G1', project: 'DEMO-P03' }).projects.length, 0);
  40. assert.equal(filterSample(model, { search: '完全无此关键词XYZ' }).voices.length, 0);
  41. });
  42. test('details preserve report, question and concept fields, with no live fallback', () => {
  43. const p = sampleDetail(model, 'project', 'DEMO-P01');
  44. assert.equal(p.questions.length, 6); assert.equal(p.concepts.length, 2); assert.equal(p.reports.length, 3);
  45. assert.ok(p.questions.every((q) => q.question && q.requiredMaterial && q.ownerMemberId));
  46. assert.equal(sampleDetail(model, 'project', 'DEMO-P10').concepts.length, 0);
  47. assert.equal(sampleDetail(model, 'source', 'UNKNOWN'), null);
  48. assert.ok(sampleDetail(model, 'source', extension.voices[0].id).row.rawText);
  49. });
  50. test('state, score replacement, comments and refresh remain in a separate namespace', async () => {
  51. const store = storage(); store.setItem('kf-board:me', 'unchanged');
  52. const service = createSampleService({ storage: store, transport, now: () => '2026-11-03T08:00:00Z' });
  53. await service.load();
  54. service.mutate('status', { id: 'DEMO-O01', status: 'candidate', user: '模拟甲' });
  55. service.mutate('score', { id: 'DEMO-O01', user: '甲', score: 80 });
  56. service.mutate('score', { id: 'DEMO-O01', user: '乙', score: 60 });
  57. service.mutate('score', { id: 'DEMO-O01', user: '甲', score: 100 });
  58. assert.equal(service.model.opportunitiesById['DEMO-O01'].stats.avg, 80);
  59. assert.equal(service.model.opportunitiesById['DEMO-O01'].stats.n, 2);
  60. service.mutate('comment', { id: 'DEMO-P01', user: '甲', body: '先核复水条件' });
  61. const refreshed = createSampleService({ storage: store, transport }); await refreshed.load();
  62. assert.equal(refreshed.model.opportunitiesById['DEMO-O01'].status, 'candidate');
  63. assert.equal(refreshed.model.comments['DEMO-P01'][0].body, '先核复水条件');
  64. service.reset();
  65. assert.equal(service.model.opportunitiesById['DEMO-O01'].status, 'approved');
  66. assert.equal(store.getItem('kf-board:me'), 'unchanged'); assert.equal(store.getItem(SAMPLE_STORAGE_KEY), null);
  67. });
  68. test('stale revisions, invalid scores and failed storage writes never report success', async () => {
  69. const store = storage();
  70. const a = createSampleService({ storage: store, transport }), b = createSampleService({ storage: store, transport });
  71. await Promise.all([a.load(), b.load()]);
  72. a.mutate('score', { id: 'DEMO-O01', user: '甲', score: 80 });
  73. assert.throws(() => b.mutate('score', { id: 'DEMO-O01', user: '乙', score: 20 }, 0), /其他演示窗口/);
  74. assert.equal(b.model.revision, 1);
  75. assert.throws(() => a.mutate('score', { id: 'DEMO-O01', user: '甲', score: '' }), /0—100/);
  76. assert.throws(() => a.mutate('status', { id: 'QE9hjtCj8E', status: 'approved', user: '甲' }), /模拟机会卡不存在/);
  77. const failed = createSampleService({ storage: { ...store, setItem() { throw new Error('quota full'); } }, transport }); await failed.load();
  78. assert.throws(() => failed.mutate('score', { id: 'DEMO-O01', user: '甲', score: 30 }), /quota full/);
  79. assert.equal(failed.model.opportunitiesById['DEMO-O01'].stats.avg, 80);
  80. });