| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061 |
- 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> = {}): 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: '<p>适用于食堂</p>', mobileHtml: '<p>适用于食堂</p>' },
- 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');
- });
|