import assert from 'node:assert/strict'; import test from 'node:test'; import type { ListingDimension, ListingOverviewQuery, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js'; import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js'; import { LISTING_SIMULATION_MODEL, queryListingOverview } from '../src/modules/listing-ai/query/listing-overview.query.js'; import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js'; import { listingOverviewQuerySchema, listingOverviewResponseSchema } from '../src/modules/listing-ai/schemas.js'; import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js'; import { LISTING_DIMENSION_MAX, LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js'; import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js'; const WORKSPACE_ID = 'overview-test'; const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications']; function source(productId: string, categoryId: string, syncedAt = '2026-08-26T00:00:00.000Z'): ListingSourceSnapshot { return { id: `source-${productId}`, workspaceId: WORKSPACE_ID, platform: 'jd', shopId: 'shop', productId, sourceHash: productId.padStart(64, '0'), title: `测试商品 ${productId}`, titleBrandName: '测试品牌', brand: { id: 'brand', name: '测试品牌' }, categoryIds: ['root', categoryId], categoryContext: { names: ['商用设备', `类目 ${categoryId}`], categoryId, pathNames: ['商用设备', `类目 ${categoryId}`], displayName: `类目 ${categoryId}`, coreTerms: [], requiredSpecificationNames: [], qualificationNames: [], ruleVersion: 'test' }, itemStatus: '1', price: { jd: 100, cost: null }, descriptions: { desktopHtml: '

详情

', mobileHtml: null }, features: [], attributes: [], images: [{ url: `https://img.test/${productId}.jpg`, order: 1, isPrimary: true, gptFlag: null }], skus: [], dimensions: { length: null, width: null, height: null, weight: null }, logistics: {}, afterService: {}, sourceModifiedAt: null, syncedAt, detailStatus: 'available', }; } function score(input: { source: ListingSourceSnapshot; overall: number; values: Record; nature: 'simulation' | 'formal_ai' | 'rule_precheck'; }): ListingScoreResult { const hybrid = input.nature !== 'rule_precheck'; return { id: `score-${input.source.productId}-${input.nature}`, workspaceId: WORKSPACE_ID, productId: input.source.productId, sourceHash: input.source.sourceHash, rubricVersion: hybrid ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION, overallScore: input.overall, coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions: DIMENSIONS.map((dimension) => ({ dimension, score: input.values[dimension], maxScore: LISTING_DIMENSION_MAX[dimension], coverage: 100, status: 'scored', evidence: [], suggestions: [] })), aiStatus: hybrid ? 'completed' : 'not_requested', aiSuggestions: [], aiCandidate: null, model: input.nature === 'simulation' ? LISTING_SIMULATION_MODEL : input.nature === 'formal_ai' ? 'production-model' : null, promptVersion: hybrid ? 'test-prompt' : null, scoreKind: hybrid ? 'hybrid_ai' : 'rules', createdAt: `2026-08-26T0${input.source.productId}:00:00.000Z`, }; } const sources = [source('1', 'cat-a'), source('2', 'cat-a'), source('3', 'cat-b'), source('4', 'cat-c')]; const scores = [ score({ source: sources[0]!, overall: 74, nature: 'simulation', values: { title: 24, selling_points: 20, images: 10, description: 12, specifications: 8 } }), score({ source: sources[1]!, overall: 90, nature: 'formal_ai', values: { title: 27, selling_points: 22.5, images: 18, description: 13.5, specifications: 9 } }), score({ source: sources[2]!, overall: 60, nature: 'rule_precheck', values: { title: 18, selling_points: 15, images: 12, description: 9, specifications: 6 } }), ]; function query(patch: Partial = {}): ListingOverviewQuery { return { workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'improvementPotential', direction: 'desc', limit: 25, cursor: null, ...patch }; } test('overview rows calculate normalized dimensions, weakest dimension, improvement potential, and score nature', () => { const result = queryListingOverview({ sources, scores, query: query(), generatedAt: '2026-08-26T12:00:00.000Z' }); const simulation = result.items.find((item) => item.productId === '1')!; assert.equal(simulation.scoreNature, 'simulation'); assert.equal(simulation.scoreNatureLabel, '模拟评分'); assert.equal(simulation.overallRate, 0.74); assert.deepEqual(simulation.dimensions.images, { score: 10, maxScore: 20, rate: 0.5, gap: 10 }); assert.equal(simulation.weakestDimension, 'images', 'weakest dimension uses rate instead of raw points'); assert.equal(simulation.improvementPotential, 26); assert.equal(simulation.categoryId, 'cat-a'); assert.deepEqual(simulation.categoryPath, ['商用设备', '类目 cat-a']); const unscored = result.items.find((item) => item.productId === '4')!; assert.equal(unscored.scoreNature, 'unscored'); assert.equal(unscored.overallScore, null); assert.equal(unscored.dimensions.title.score, null); assert.equal(unscored.weakestDimension, null); assert.equal(unscored.improvementPotential, null, 'missing dimensions are not treated as zero points'); const unscoredOnly = queryListingOverview({ sources, scores, query: query({ scoreNature: 'unscored' }), generatedAt: '2026-08-26T12:00:00.000Z' }); assert.equal(unscoredOnly.summary.dimensionStats.title.totalGap, null); }); test('overview aggregates the complete filtered set before pagination and returns distribution, health, and facets', () => { const result = queryListingOverview({ sources, scores, query: query({ limit: 2 }), generatedAt: '2026-08-26T12:00:00.000Z' }); assert.equal(result.items.length, 2); assert.ok(result.nextCursor); assert.equal(result.summary.sourceTotal, 4); assert.equal(result.summary.matchedTotal, 4); assert.equal(result.summary.scoredTotal, 3); assert.equal(result.summary.simulationTotal, 1); assert.equal(result.summary.averageScore, 74.67); assert.equal(result.summary.medianScore, 74); assert.deepEqual(result.summary.scoreDistribution.map((bucket) => bucket.count), [0, 1, 1, 0, 1]); assert.deepEqual(result.summary.dimensionStats.title, { key: 'title', label: '商品标题', maxScore: 30, scoredCount: 3, averageScore: 23, averageRate: 0.7667, medianScore: 24, totalGap: 21, }); assert.deepEqual(result.summary.categoryFacets.map((facet) => [facet.categoryId, facet.count]), [['cat-a', 2], ['cat-b', 1], ['cat-c', 1]]); assert.deepEqual(result.summary.scoreNatureFacets.map((facet) => [facet.value, facet.count]), [['simulation', 1], ['formal_ai', 1], ['rule_precheck', 1], ['unscored', 1]]); assert.equal(result.summary.generatedAt, '2026-08-26T12:00:00.000Z'); assert.ok(result.summary.snapshotId); listingOverviewResponseSchema.parse(result); }); test('overview applies category, nature, total, dimension, and weakest-dimension filters globally', () => { const result = queryListingOverview({ sources, scores, query: query({ categoryIds: ['cat-a', 'missing'], scoreNature: 'simulation', minScore: 70, maxScore: 80, imagesMin: 9, imagesMax: 11, weakestDimension: 'images' }), generatedAt: '2026-08-26T12:00:00.000Z', }); assert.equal(result.summary.sourceTotal, 4); assert.equal(result.summary.matchedTotal, 1); assert.deepEqual(result.items.map((item) => item.productId), ['1']); assert.equal(result.summary.averageScore, 74); assert.equal(result.summary.dimensionStats.images.totalGap, 10); }); test('overview reuses stable cursors and rejects a changed snapshot', () => { const first = queryListingOverview({ sources, scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1 }), generatedAt: '2026-08-26T12:00:00.000Z' }); assert.deepEqual(first.items.map((item) => item.productId), ['2']); const second = queryListingOverview({ sources, scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1, cursor: first.nextCursor }), generatedAt: '2026-08-26T12:00:01.000Z' }); assert.deepEqual(second.items.map((item) => item.productId), ['1']); assert.throws( () => queryListingOverview({ sources: [{ ...sources[0]!, syncedAt: '2026-08-26T13:00:00.000Z' }, ...sources.slice(1)], scores, query: query({ sort: 'overallScore', direction: 'desc', limit: 1, cursor: first.nextCursor }), generatedAt: '2026-08-26T12:00:01.000Z' }), (error: unknown) => error instanceof Error && 'code' in error && error.code === 'listing_overview_cursor_stale', ); }); test('overview query schema normalizes category IDs, applies defaults, and rejects inverted ranges', () => { const parsed = listingOverviewQuerySchema.parse({ categoryIds: ['cat-a,cat-b', 'cat-a'] }); assert.deepEqual(parsed.categoryIds, ['cat-a', 'cat-b']); assert.equal(parsed.sort, 'improvementPotential'); assert.equal(parsed.direction, 'desc'); assert.equal(parsed.limit, 25); assert.throws(() => listingOverviewQuerySchema.parse({ minScore: 90, maxScore: 80 })); assert.throws(() => listingOverviewQuerySchema.parse({ titleMax: 31 })); }); test('overview isolates JD-VOC scores by rubricVersion and scoreKind', () => { const jdVoc = scoreJdVocRules(sources[1]!, {}, { id: 'jd-voc-overview', now: '2026-08-26T12:00:00.000Z' }); const result = queryListingOverview({ sources, scores, jdVocScores: [jdVoc], query: query({ rubricVersion: 'jd-voc-v0.5', scoreKind: 'jd_voc_rules' }), generatedAt: '2026-08-26T12:00:00.000Z' }); assert.deepEqual(result.items.map((item) => item.productId), ['2']); assert.equal(result.items[0]?.rubricVersion, 'jd-voc-v0.5'); assert.equal(result.items[0]?.scoreKind, 'jd_voc_rules'); assert.equal(result.items[0]?.jdVocDimensions?.search.maxScore, 25); assert.ok(result.items[0]?.weakestDimension); assert.equal(result.summary.jdVocDimensionStats?.media.maxScore, 5); }); test('overview service performs one source read and one current-score read without per-product queries', async () => { class CountingRepository extends InMemoryListingAiRepository { sourceReads = 0; scoreReads = 0; override async listAllSources(workspaceId: string, platform: 'jd') { this.sourceReads += 1; return super.listAllSources(workspaceId, platform); } override async listCurrentScores(workspaceId: string) { this.scoreReads += 1; return super.listCurrentScores(workspaceId); } } const repository = new CountingRepository(sources); for (const currentScore of scores) await repository.upsertCurrentScore(currentScore); const service = new ListingAiService(repository, undefined, () => new Date('2026-08-26T12:00:00.000Z')); const result = await service.overview(query()); assert.equal(result.summary.sourceTotal, 4); assert.equal(repository.sourceReads, 1); assert.equal(repository.scoreReads, 1); });