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);
});