industry-planner.test.js 4.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
  1. import { test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { PLAN_GROUPS, TENTATIVE, choosePlanValue, categoryOptions, planProgress, innovationPrompt, plannerMaterial } from '../src/core/industry-planner.js';
  4. const overview = { facets: { category: ['banmian', 'soup_noodle'] }, taxonomy: { categories: [] } };
  5. const label = v => ({ banmian: '拌面', soup_noodle: '汤面' })[v] || v;
  6. function complete() { return Object.fromEntries(Object.keys(PLAN_GROUPS).map(key => [key, [key === 'category' ? 'banmian' : TENTATIVE]])); }
  7. function data() {
  8. return { generation: 'g1', rulesVersion: 'r1', scope: { products: 3, reviewedProducts: 1, reviewRows: 2, distinctReadablePairs: 1, eligibleRows: 0 }, notes: [], opportunities: [],
  9. directionOptions: [{ id: 'seasoning', title: '控制调味', status: '需要补证', evidenceIds: ['jd:1:r1'], support: [{ reviewId: 'jd:1:r1', productId: 'jd:1', sourceRecordId: 'source1', associationIds: ['jd:2:r1'] }], benefits: [] }],
  10. themeFacets: [{ id: 'seasoning', units: 1, qualifiedUnits: 0, examples: [{ reviewId: 'jd:1:r1' }] }, { id: 'health', units: 1, qualifiedUnits: 0, examples: [{ reviewId: 'jd:3:r2' }] }],
  11. representativeProducts: [{ productId: 'jd:1', title: '真实商品' }], claims: [], prices: { groups: [{ examples: [{ recordId: 'price1', productId: 'jd:1' }] }] } };
  12. }
  13. test('single choices replace each other; tentative excludes concrete multi choices in both directions', () => {
  14. let p = choosePlanValue({}, 'format', 'cup');
  15. p = choosePlanValue(p, 'format', 'bag'); assert.deepEqual(p.format, ['bag']);
  16. p = choosePlanValue(p, 'audience', TENTATIVE);
  17. p = choosePlanValue(p, 'audience', 'office'); p = choosePlanValue(p, 'audience', 'student');
  18. assert.deepEqual(p.audience, ['office', 'student']);
  19. p = choosePlanValue(p, 'audience', TENTATIVE); assert.deepEqual(p.audience, [TENTATIVE]);
  20. p = choosePlanValue(p, 'audience', 'office'); p = choosePlanValue(p, 'audience', 'office'); assert.deepEqual(p.audience, []);
  21. assert.throws(() => choosePlanValue(p, 'madeup', 'value'), /INVALID_PLAN_GROUP/);
  22. });
  23. test('every group must be explicit, invalid saved choices and multiple radio values are not complete', () => {
  24. const cats = categoryOptions(overview, label), p = complete();
  25. assert.deepEqual(planProgress(p, cats), { completed: 9, total: 9, firstIncomplete: 4, ready: true });
  26. for (const [key, values] of [['audience', []], ['category', ['stale']], ['format', ['bag', 'cup']], ['goal', [TENTATIVE, 'new']], ['scenario', ['night', 'night']]]) {
  27. assert.equal(planProgress({ ...p, [key]: values }, cats).ready, false, key);
  28. }
  29. assert.equal(planProgress({ category: ['banmian'] }, cats).firstIncomplete, 0);
  30. });
  31. test('prompt uses exact source IDs and real counts, separates assumptions and rejects incomplete or mismatched data', () => {
  32. const d = data(), p = { ...complete(), directions: ['seasoning'] }, filters = { category: 'banmian' };
  33. const before = structuredClone(d), text = innovationPrompt(d, { plan: p, filters, overview, label });
  34. for (const x of ['研究品类:拌面', '目标人群:暂定', 'generation:g1', '符合当前分析规则0条', '不是已经证实', 'jd:1:r1', 'source1', 'price1', '完整商品/评价/价格ID', '不自动发起收费采集']) assert.ok(text.includes(x), x);
  35. const refs = text.split('\n').filter(s => s.startsWith('{')).map(JSON.parse);
  36. assert.equal(refs.find(r => r.id === 'jd:1:r1').source_record_id, 'source1');
  37. assert.ok(!Object.hasOwn(refs.find(r => r.id === 'jd:2:r1'), 'source_record_id'));
  38. assert.equal(refs.filter(r => r.id === 'jd:1:r1').length, 1);
  39. assert.ok(!text.includes('jd:3:r2')); assert.deepEqual(d, before);
  40. assert.throws(() => innovationPrompt(d, { plan: {}, filters, overview }), /PLAN_INCOMPLETE/);
  41. assert.throws(() => innovationPrompt(d, { plan: p, filters: { category: 'soup_noodle' }, overview }), /PLAN_SCOPE_CHANGED/);
  42. assert.throws(() => innovationPrompt({ ...d, generation: '' }, { plan: p, filters, overview }), /PLAN_DATA_UNVERSIONED/);
  43. assert.throws(() => innovationPrompt({ ...d, scope: { products: 3 } }, { plan: p, filters, overview }), /PLAN_DATA_INCOMPLETE/);
  44. });
  45. test('exploratory themes retain real examples without borrowing unrelated hypothesis evidence; tentative keeps broad scope explicit', () => {
  46. const d = data(), p = { ...complete(), directions: ['health'] };
  47. const m = plannerMaterial(d, p, { category: 'banmian' });
  48. assert.ok(m.records.some(r => r.id === 'jd:3:r2'));
  49. assert.ok(!m.records.some(r => r.id === 'jd:1:r1'));
  50. const text = innovationPrompt(d, { plan: { ...p, category: [TENTATIVE] }, filters: {}, overview, label });
  51. assert.ok(text.includes('研究品类:暂定')); assert.ok(text.includes('当前没有已形成的方向假设'));
  52. assert.ok(text.includes('商品范围 filters:{}'));
  53. assert.ok(!text.includes('undefined')); assert.ok(!text.includes('null'));
  54. });