listing-ai.rule-engine.test.ts 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
  1. import assert from 'node:assert/strict';
  2. import test from 'node:test';
  3. import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  4. import { canonicalHash, scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js';
  5. function source(overrides: Partial<ListingSourceSnapshot> = {}): ListingSourceSnapshot {
  6. return {
  7. id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop-1', productId: '1001', sourceHash: 'a'.repeat(64),
  8. title: '星星 299L 一级能效风冷无霜商用冷藏展示柜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['10', '20'], itemStatus: 'on_shelf',
  9. price: { jd: 1049, cost: 800 }, descriptions: { desktopHtml: `<p>${'完整商品详情与使用场景。'.repeat(30)}</p>`, mobileHtml: `<p>${'移动端商品详情。'.repeat(30)}</p>` },
  10. features: [
  11. { key: 'capacity', value: '299L 大容量' }, { key: 'efficiency', value: '一级能效' }, { key: 'cooling', value: '风冷无霜' },
  12. ],
  13. attributes: [
  14. { id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '制冷方式', values: ['风冷'] }, { id: '3', name: '能效', values: ['一级'] },
  15. ],
  16. images: Array.from({ length: 5 }, (_, index) => ({ url: `https://img.test/${index}.jpg`, order: index + 1, isPrimary: index === 0, gptFlag: false })),
  17. skus: [{ skuId: 'sku-1', name: '299L', price: 1049, stock: 20, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }],
  18. dimensions: { length: 600, width: 620, height: 1900, weight: 60 }, logistics: { delivery: '京东物流' }, afterService: { return7Days: true },
  19. sourceModifiedAt: '2026-08-21T00:00:00.000Z', syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available', ...overrides,
  20. };
  21. }
  22. test('rule engine is deterministic for the same source and rubric', () => {
  23. const first = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
  24. const second = scoreListing(source(), { id: 'same', now: '2026-08-21T00:00:00.000Z' });
  25. assert.deepEqual(first, second);
  26. assert.equal(first.overallScore, 100);
  27. assert.equal(first.coverage.status, 'eligible');
  28. assert.equal(first.dimensions.length, 5);
  29. });
  30. test('missing detail is blocked instead of receiving a zero quality score', () => {
  31. const result = scoreListing(source({
  32. title: null, features: [], images: [], attributes: [], skus: [], descriptions: { desktopHtml: null, mobileHtml: null }, detailStatus: 'empty',
  33. }));
  34. assert.equal(result.coverage.status, 'blocked');
  35. assert.equal(result.overallScore, null);
  36. assert.ok(result.dimensions.every((dimension) => dimension.score === null));
  37. });
  38. test('JD transport flags are not misclassified as duplicate selling points', () => {
  39. const result = scoreListing(source({
  40. features: [
  41. { key: 'isPayFirst', value: '1' },
  42. { key: 'is7ToReturn', value: '1' },
  43. { key: 'popsfkc', value: '0' },
  44. { key: 'nameWithoutBrand', value: '299L 一级能效风冷无霜展示柜' },
  45. { key: 'model', value: 'BC-299' },
  46. ],
  47. }));
  48. const sellingPoints = result.dimensions.find((item) => item.dimension === 'selling_points');
  49. const duplicateRule = sellingPoints?.evidence.find((item) => item.ruleId === 'selling_points.unique');
  50. assert.equal(result.rubricVersion, 'listing-jd-v2');
  51. assert.equal(duplicateRule?.outcome, 'unknown');
  52. assert.equal(duplicateRule?.delta, 0);
  53. });
  54. test('canonical source hash ignores object key insertion order', () => {
  55. assert.equal(canonicalHash({ a: 1, b: { c: 2 } }), canonicalHash({ b: { c: 2 }, a: 1 }));
  56. });