| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169 |
- import assert from 'node:assert/strict';
- import test from 'node:test';
- import { VOC_PARSE_CLASSES, VOC_PARSE_SCHEMAS } from '../src/db/parse-rest.schema.js';
- import type {
- CompetitorListingCollectionResult,
- CompetitorListingObservation,
- CompetitorListingSnapshot,
- } from '../src/modules/competitor-listing-monitor/domain.js';
- import {
- decideCompetitorListingSnapshot,
- normalizeCompetitorListingImageUrl,
- normalizeCompetitorListingText,
- } from '../src/modules/competitor-listing-monitor/snapshot-diff.js';
- function observation(overrides: Partial<CompetitorListingObservation> = {}): CompetitorListingObservation {
- return {
- workspaceId: 'demashi',
- platform: 'jd',
- productId: '1001',
- observedAt: '2026-08-26T01:00:00.000Z',
- title: '德玛仕 商用冰箱',
- priceCents: 199_900,
- availability: 'online',
- mainImageUrl: 'https://img.example.test/product.jpg',
- keySpecifications: {
- model: 'DM-100',
- color: '银色',
- specification: '100 L',
- origin: '广东',
- weightKg: '40',
- lengthMm: '600',
- widthMm: '650',
- heightMm: '1800',
- },
- observedFields: ['title', 'price', 'availability', 'main_image', 'key_specifications'],
- collectionStatus: 'succeeded',
- ...overrides,
- };
- }
- function baseline(overrides: Partial<CompetitorListingObservation> = {}): CompetitorListingSnapshot {
- const decision = decideCompetitorListingSnapshot(null, observation(overrides), { snapshotId: 'snapshot-1' });
- assert.equal(decision.status, 'baseline');
- return decision.snapshot;
- }
- test('first successful collection creates only an immutable baseline', () => {
- const result = decideCompetitorListingSnapshot(null, observation(), { snapshotId: 'snapshot-1' });
- assert.equal(result.status, 'baseline');
- assert.equal(result.snapshot.previousSnapshotId, null);
- assert.equal(result.snapshot.collectionStatus, 'succeeded');
- assert.equal(result.change, null);
- });
- test('equivalent normalized content creates neither a duplicate snapshot nor an event', () => {
- const previous = baseline();
- const result = decideCompetitorListingSnapshot(previous, observation({
- observedAt: '2026-08-27T01:00:00.000Z',
- title: ' 德玛仕\u3000商用冰箱 ',
- mainImageUrl: 'http://img.example.test/product.jpg?width=800&utm_source=test',
- observedFields: ['key_specifications', 'main_image', 'availability', 'price', 'title'],
- }), { snapshotId: 'snapshot-2' });
- assert.equal(result.status, 'unchanged');
- assert.equal(result.snapshot, null);
- assert.equal(result.change, null);
- assert.equal(normalizeCompetitorListingText('ABC\u3000 商品'), 'ABC 商品');
- assert.equal(normalizeCompetitorListingImageUrl('//img.example.test/product.jpg?t=123'), 'https://img.example.test/product.jpg');
- });
- test('price, title, main image, availability and individual specifications are detected', () => {
- const previous = baseline();
- const result = decideCompetitorListingSnapshot(previous, observation({
- observedAt: '2026-08-27T01:00:00.000Z',
- title: '德玛仕 商用冷柜',
- priceCents: 189_900,
- availability: 'offline',
- mainImageUrl: 'https://img.example.test/new-product.jpg',
- keySpecifications: {
- ...previous.keySpecifications,
- model: 'DM-200',
- widthMm: '700',
- },
- }), { snapshotId: 'snapshot-2', changeId: 'change-1' });
- assert.equal(result.status, 'changed');
- assert.deepEqual(result.change.changeTypes, [
- 'price', 'availability', 'title', 'main_image', 'key_specifications',
- ]);
- assert.deepEqual(result.change.changes.map((item) => item.field), [
- 'priceCents',
- 'availability',
- 'title',
- 'mainImageUrl',
- 'keySpecifications.model',
- 'keySpecifications.widthMm',
- ]);
- assert.equal(result.snapshot.previousSnapshotId, 'snapshot-1');
- assert.equal(result.change.naturalKey, 'snapshot-1:snapshot-2');
- });
- test('missing and unknown fields change coverage without producing false changes', () => {
- const previous = baseline();
- const result = decideCompetitorListingSnapshot(previous, observation({
- observedAt: '2026-08-27T01:00:00.000Z',
- title: null,
- priceCents: null,
- availability: 'unknown',
- mainImageUrl: null,
- keySpecifications: {
- ...previous.keySpecifications,
- model: null,
- },
- collectionStatus: 'partial',
- }), { snapshotId: 'snapshot-2' });
- assert.equal(result.status, 'unchanged');
- assert.ok(result.snapshot, 'a changed partial observation remains available for persistence');
- assert.deepEqual(result.snapshot.observedFields, ['key_specifications']);
- assert.equal(result.change, null);
- });
- test('unmonitored upstream fields do not participate in hashing or comparison', () => {
- const previous = baseline();
- const withUnknownFields = {
- ...observation({ observedAt: '2026-08-27T01:00:00.000Z' }),
- sellerName: 'a newly returned upstream field',
- observedFields: [
- 'title', 'price', 'availability', 'main_image', 'key_specifications', 'seller_name',
- ],
- } as unknown as CompetitorListingObservation;
- const result = decideCompetitorListingSnapshot(previous, withUnknownFields, { snapshotId: 'snapshot-2' });
- assert.equal(result.status, 'unchanged');
- assert.equal(result.snapshot, null);
- assert.equal(result.change, null);
- });
- test('an unknown specification sub-field is not interpreted as a deletion', () => {
- const previous = baseline();
- const result = decideCompetitorListingSnapshot(previous, observation({
- keySpecifications: { ...previous.keySpecifications, model: null, color: '黑色' },
- }), { snapshotId: 'snapshot-2' });
- assert.equal(result.status, 'changed');
- assert.deepEqual(result.change.changeTypes, ['key_specifications']);
- assert.deepEqual(result.change.changes, [{
- field: 'keySpecifications.color', before: '银色', after: '黑色',
- }]);
- });
- test('collection failure never creates an empty snapshot or an offline event', () => {
- const previous = baseline();
- const failure: CompetitorListingCollectionResult = {
- workspaceId: 'demashi', platform: 'jd', productId: '1001',
- observedAt: '2026-08-27T01:00:00.000Z', collectionStatus: 'failed', errorCode: 'jd_detail_failed',
- };
- const result = decideCompetitorListingSnapshot(previous, failure, { snapshotId: 'snapshot-2' });
- assert.deepEqual(result, {
- status: 'failed', snapshot: null, change: null, errorCode: 'jd_detail_failed',
- });
- });
- test('the three Parse classes required by phase 1 are registered', () => {
- const classNames = new Set(VOC_PARSE_SCHEMAS.map((schema) => schema.className));
- assert.equal(VOC_PARSE_CLASSES.competitorListingSnapshot, 'VocCompetitorListingSnapshot');
- assert.equal(VOC_PARSE_CLASSES.competitorListingChange, 'VocCompetitorListingChange');
- assert.equal(VOC_PARSE_CLASSES.competitorListingRefreshRun, 'VocCompetitorListingRefreshRun');
- assert.equal(classNames.has(VOC_PARSE_CLASSES.competitorListingSnapshot), true);
- assert.equal(classNames.has(VOC_PARSE_CLASSES.competitorListingChange), true);
- assert.equal(classNames.has(VOC_PARSE_CLASSES.competitorListingRefreshRun), true);
- });
|