import assert from 'node:assert/strict'; import test from 'node:test'; import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js'; import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js'; import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js'; import { ApiError } from '../src/http/api-error.js'; import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js'; function source(overrides: Partial = {}): ListingSourceSnapshot { return { id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '10020722928820', sourceHash: 'a'.repeat(64), title: '德玛仕 商用削皮机 304不锈钢 自动削皮', titleBrandName: '德玛仕', brand: { id: '1', name: '德玛仕' }, categoryIds: ['food'], categoryContext: { names: ['削皮机'], coreTerms: ['削皮机'], requiredSpecificationNames: ['功率'], qualificationNames: [], ruleVersion: 'peeler-v1' }, itemStatus: 'on_shelf', price: { jd: 1000, cost: 800 }, descriptions: { desktopHtml: '

适用于食堂

', mobileHtml: '

适用于食堂

' }, descriptionStructure: { observed: true, imageCount: 1, videoCount: 0, headingCount: 0, faqCandidateCount: 0 }, features: [{ key: 'power', value: '500W' }], attributes: [{ id: '1', name: '功率', values: ['500W'] }], images: [{ url: 'https://img.test/1.jpg', order: 1, isPrimary: true, gptFlag: false }], imageAssets: { defaultImages: [], skuImages: [], whiteBackgroundImages: [] }, skus: [], dimensions: { length: 10, width: 10, height: 10, weight: 5 }, logistics: {}, afterService: {}, marketing: { adword: '自动削皮', skuShortTitles: [], sellingPoints: [{ value: '自动削皮', source: 'product_adword', fieldPath: 'adword', skuId: null }] }, sourceModifiedAt: null, syncedAt: '2026-09-04T08:00:00.000Z', detailStatus: 'available', ...overrides, }; } test('jd-voc rule engine emits six dimensions, stable execution identity, and separated VOC sources', () => { const input = source(); const first = scoreJdVocRules(input, { productReviews: [{ id: 'r1', source: 'product-review', text: '削皮速度可以' }] }, { id: 'fixed', now: '2026-09-04T08:00:00.000Z' }); const second = scoreJdVocRules(input, { productReviews: [{ id: 'r1', source: 'product-review', text: '削皮速度可以' }] }, { id: 'fixed', now: '2026-09-04T08:00:00.000Z' }); assert.deepEqual(first, second); assert.equal(first.rubricVersion, 'jd-voc-v0.5'); assert.equal(first.scoreKind, 'jd_voc_rules'); assert.deepEqual(first.dimensions.map((item) => item.key), ['search', 'voc', 'selling', 'facts', 'competitive', 'media']); assert.equal(first.voc.productReviewCount, 1); assert.equal(first.voc.categoryVocEvidenceCount, 0); assert.equal(first.executionKey, 'jd-voc-v0.5:' + first.inputFingerprint); }); test('missing VOC and competitors remain unknown/partial instead of zero', () => { const result = scoreJdVocRules(source({ detailStatus: 'empty', title: null, features: [], attributes: [], images: [] })); assert.equal(result.dimensions.find((item) => item.key === 'voc')?.score, null); assert.equal(result.dimensions.find((item) => item.key === 'competitive')?.score, null); assert.equal(result.coverage.status, 'blocked'); }); test('service persists JD-VOC result idempotently in its dedicated slot', async () => { const repository = new InMemoryListingAiRepository([source()]); const service = new ListingAiService(repository, undefined, () => new Date('2026-09-04T08:00:00.000Z')); const first = await service.scoreJdVocRules({ workspaceId: 'demashi', platform: 'jd', productId: '10020722928820' }); const second = await service.scoreJdVocRules({ workspaceId: 'demashi', platform: 'jd', productId: '10020722928820' }); assert.deepEqual(second, first); assert.equal((await repository.getJdVocCurrentScore('demashi', '10020722928820', 'jd_voc_rules'))?.executionKey, first.executionKey); assert.equal((await repository.getCurrentScore('demashi', '10020722928820')), null); }); test('JD_VOC_ENABLED=false blocks new score writes while persisted reads remain available', async () => { const repository = new InMemoryListingAiRepository([source()]); const existing = scoreJdVocRules(source(), {}, { id:'existing', now:'2026-09-05T00:00:00.000Z' }); await repository.upsertJdVocCurrentScore(existing); const service = new ListingAiService(repository, undefined, () => new Date(), 1, 10, undefined, undefined, false, false); assert.equal((await repository.getJdVocCurrentScore('demashi', '10020722928820'))?.id, 'existing'); await assert.rejects(service.scoreJdVocRules({ workspaceId:'demashi', platform:'jd', productId:'10020722928820' }), (error:unknown)=>error instanceof ApiError&&error.code==='jd_voc_disabled'); });