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