Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

192 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the 懂车帝 (Dongchedi) adapter.
*
* Every command parses `__NEXT_DATA__` JSON from an SSR page, so the pure
* parsers are exercised against frozen real-data fixtures captured from
* dongchedi.com (series 5273 = 宝马X5). No network, no browser.
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
import {
SEARCH_COLUMNS,
SERIES_COLUMNS,
MODELS_COLUMNS,
SPECS_COLUMNS,
SCORE_COLUMNS,
KOUBEI_COLUMNS,
extractPageProps,
isFallbackShell,
parseScore,
normalizeSeriesId,
requireLimit,
clean,
snippet,
} from './utils.js';
import { parseSearchRows } from './search.js';
import { parseSeries } from './series.js';
import { parseModels } from './models.js';
import { parseSpecs } from './specs.js';
import { parseScoreBreakdown } from './score.js';
import { parseKoubei } from './koubei.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fx = (name) => JSON.parse(readFileSync(join(__dirname, '__fixtures__', name), 'utf8'));
const SEARCH = fx('search.json');
const DETAIL = fx('series-detail.json');
const SCORE = fx('series-score.json');
describe('dongchedi adapter — registration', () => {
const names = ['search', 'series', 'models', 'specs', 'score', 'koubei'];
it('registers all commands as PUBLIC (no browser)', () => {
for (const n of names) {
const cmd = getRegistry().get(`dongchedi/${n}`);
expect(cmd, n).toBeTruthy();
expect(cmd.strategy, n).toBe(Strategy.PUBLIC);
expect(cmd.browser, n).toBe(false);
expect(cmd.access, n).toBe('read');
}
});
it('declares the expected columns', () => {
expect(getRegistry().get('dongchedi/search').columns).toEqual(SEARCH_COLUMNS);
expect(getRegistry().get('dongchedi/series').columns).toEqual(SERIES_COLUMNS);
expect(getRegistry().get('dongchedi/models').columns).toEqual(MODELS_COLUMNS);
expect(getRegistry().get('dongchedi/specs').columns).toEqual(SPECS_COLUMNS);
expect(getRegistry().get('dongchedi/score').columns).toEqual(SCORE_COLUMNS);
expect(getRegistry().get('dongchedi/koubei').columns).toEqual(KOUBEI_COLUMNS);
});
});
describe('dongchedi adapter — utils', () => {
it('extractPageProps pulls pageProps from a __NEXT_DATA__ blob', () => {
const html = `<html><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { hello: 'world' } } })}</script></body></html>`;
expect(extractPageProps(html)).toEqual({ hello: 'world' });
});
it('extractPageProps returns null on missing/invalid blob', () => {
expect(extractPageProps('<html>no data</html>')).toBeNull();
expect(extractPageProps('<script id="__NEXT_DATA__">{not json}</script>')).toBeNull();
});
it('isFallbackShell flags the empty city-gateway shell', () => {
expect(isFallbackShell({ __hasUrlCity: true, is_gray: false, clientIp: '1.2.3.4' })).toBe(true);
expect(isFallbackShell({ seriesHomeHead: {} })).toBe(false);
expect(isFallbackShell(null)).toBe(true);
});
it('parseScore rescales x100 ints to /5 floats', () => {
expect(parseScore(422)).toBe(4.22);
expect(parseScore(500)).toBe(5);
expect(parseScore(0)).toBeNull();
expect(parseScore(undefined)).toBeNull();
});
it('normalizeSeriesId accepts numbers and URLs, rejects junk', () => {
expect(normalizeSeriesId('5273')).toBe('5273');
expect(normalizeSeriesId('https://www.dongchedi.com/auto/series/5273')).toBe('5273');
expect(() => normalizeSeriesId('宝马X5')).toThrow();
expect(() => normalizeSeriesId('')).toThrow();
});
it('requireLimit enforces [1,max]', () => {
expect(requireLimit(undefined, 10, 30)).toBe(10);
expect(requireLimit(5, 10, 30)).toBe(5);
expect(() => requireLimit(0, 10, 30)).toThrow();
expect(() => requireLimit(31, 10, 30)).toThrow();
});
it('clean/snippet normalize whitespace and truncate', () => {
expect(clean(' a\n b ')).toBe('a b');
expect(snippet('x'.repeat(200), 10)).toBe(`${'x'.repeat(10)}…`);
expect(snippet('short', 10)).toBe('short');
});
});
describe('dongchedi adapter — parsers against frozen fixtures', () => {
it('parseSearchRows keeps only car series and respects limit', () => {
const rows = parseSearchRows(SEARCH, 3);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(3);
const r = rows[0];
expect(Object.keys(r).sort()).toEqual([...SEARCH_COLUMNS].sort());
expect(r.series_id).toBeTruthy();
expect(r.name).toBeTruthy();
expect(r.url).toContain('/auto/series/');
// every row must be a real series (no zero ids leaked in)
expect(rows.every((x) => x.series_id && x.series_id !== '0')).toBe(true);
});
it('parseSeries builds a complete field/value sheet', () => {
const rows = parseSeries(DETAIL, '5273');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
expect(map.name).toBe('宝马X5');
expect(map.brand).toBe('宝马');
expect(map.official_price).toMatch(/万/);
expect(typeof map.score).toBe('number');
expect(map.score).toBeGreaterThan(0);
expect(map.score).toBeLessThanOrEqual(5);
expect(map.url).toContain('/auto/series/5273');
});
it('parseModels returns on-sale trims with real car_ids', () => {
const rows = parseModels(DETAIL.carModelsData, 'online');
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) {
expect(Object.keys(r).sort()).toEqual([...MODELS_COLUMNS].sort());
expect(r.car_id).toMatch(/^\d+$/);
expect(r.name).toBeTruthy();
}
});
it('parseSpecs surfaces dimensions and powertrain', () => {
const rows = parseSpecs(DETAIL.overviewData, '5273');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
expect(map.dimensions).toMatch(/\d+ × \d+ × \d+ mm/);
expect(map.wheelbase).toMatch(/mm/);
expect(map.power).toBeTruthy();
});
it('parseScoreBreakdown maps the 8 axes with same-level averages', () => {
const sameLevel = (DETAIL.reviewData.same_level_review || []).find((r) => String(r.series_id) === '0')
|| DETAIL.reviewData.average_series_review;
const rows = parseScoreBreakdown(DETAIL.scoreSimpleInfo, sameLevel);
expect(rows.length).toBe(8);
const overall = rows.find((r) => r.dimension === '综合');
expect(overall.score).toBeGreaterThan(0);
expect(overall.same_level_avg).toBeGreaterThan(0);
for (const r of rows) expect(Object.keys(r).sort()).toEqual([...SCORE_COLUMNS].sort());
});
it('parseKoubei returns owner reviews with body + url', () => {
const rows = parseKoubei(SCORE.reviewListData, 3);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(3);
const r = rows[0];
expect(Object.keys(r).sort()).toEqual([...KOUBEI_COLUMNS].sort());
expect(r.user).toBeTruthy();
expect(r.content).toBeTruthy();
expect(typeof r.likes).toBe('number');
expect(r.url).toContain('/ugc/article/');
});
it('parsers fail closed on malformed source payloads', () => {
expect(() => parseSearchRows({}, 5)).toThrow(/unexpected payload shape/);
expect(() => parseSearchRows(null, 5)).toThrow(/unexpected payload shape/);
expect(() => parseModels({}, 'online')).toThrow(/unexpected payload shape/);
expect(() => parseKoubei({}, 5)).toThrow(/unexpected payload shape/);
expect(() => parseSpecs(null, '1')).toThrow(/unexpected payload shape/);
expect(() => parseSeries({}, '1')).toThrow(/unexpected payload shape/);
});
it('row parsers reject malformed identities and required text', () => {
expect(() => parseSearchRows({ data: [{ cell_type: 26, series_id: 0, display: { series_name: '坏行' } }] }, 5))
.toThrow(/stable numeric id/);
expect(() => parseSearchRows({ data: [{ cell_type: 26, series_id: 1, display: {} }] }, 5))
.toThrow(/stable text value/);
expect(() => parseModels({ tab_list: [{ tab_key: 'online_all', data: [{ info: { car_id: 1 } }] }] }, 'online'))
.toThrow(/stable text value/);
expect(() => parseKoubei({ review_list: [{ user_info: { name: 'u' }, content: 'body' }] }, 5))
.toThrow(/stable numeric id/);
});
});