@bytebrand/export-cars 1.26.27 → 1.26.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/dist/configs/config.d.ts +2 -1
- package/dist/configs/config.js +1 -0
- package/dist/configs/config.js.map +1 -1
- package/dist/configs/google.config.js +16 -0
- package/dist/configs/google.config.js.map +1 -1
- package/dist/configs/index.d.ts +1 -0
- package/dist/configs/index.js +1 -0
- package/dist/configs/index.js.map +1 -1
- package/dist/configs/openai-ads.config.d.ts +22 -0
- package/dist/configs/openai-ads.config.js +56 -0
- package/dist/configs/openai-ads.config.js.map +1 -0
- package/dist/enums/export-type.enum.d.ts +2 -1
- package/dist/enums/export-type.enum.js +1 -0
- package/dist/enums/export-type.enum.js.map +1 -1
- package/dist/enums/image-type.enum.d.ts +2 -1
- package/dist/enums/image-type.enum.js +1 -0
- package/dist/enums/image-type.enum.js.map +1 -1
- package/dist/export.schema.d.ts +6 -2
- package/dist/export.schema.js +4 -0
- package/dist/export.schema.js.map +1 -1
- package/dist/exporters/google.exporter.js +12 -2
- package/dist/exporters/google.exporter.js.map +1 -1
- package/dist/exporters/index.d.ts +1 -0
- package/dist/exporters/index.js +1 -0
- package/dist/exporters/index.js.map +1 -1
- package/dist/exporters/openai-ads.exporter.d.ts +13 -0
- package/dist/exporters/openai-ads.exporter.js +91 -0
- package/dist/exporters/openai-ads.exporter.js.map +1 -0
- package/dist/formats/openai-ads-csv.builder.d.ts +15 -0
- package/dist/formats/openai-ads-csv.builder.js +48 -0
- package/dist/formats/openai-ads-csv.builder.js.map +1 -0
- package/dist/interfaces/openai-ads.interface.d.ts +35 -0
- package/dist/interfaces/openai-ads.interface.js +37 -0
- package/dist/interfaces/openai-ads.interface.js.map +1 -0
- package/dist/interfaces/params.interface.d.ts +20 -0
- package/dist/services/feed-guard.d.ts +44 -0
- package/dist/services/feed-guard.js +156 -0
- package/dist/services/feed-guard.js.map +1 -0
- package/dist/services/image-url-validator.service.d.ts +45 -0
- package/dist/services/image-url-validator.service.js +141 -0
- package/dist/services/image-url-validator.service.js.map +1 -0
- package/dist/services/images.service.d.ts +12 -0
- package/dist/services/images.service.js +46 -1
- package/dist/services/images.service.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/openai-ads-feed-run.d.ts +74 -0
- package/dist/services/openai-ads-feed-run.js +151 -0
- package/dist/services/openai-ads-feed-run.js.map +1 -0
- package/dist/services/openai-ads-ledger.d.ts +19 -0
- package/dist/services/openai-ads-ledger.js +72 -0
- package/dist/services/openai-ads-ledger.js.map +1 -0
- package/dist/services/publication-gate.d.ts +15 -0
- package/dist/services/publication-gate.js +107 -0
- package/dist/services/publication-gate.js.map +1 -0
- package/dist/transformers/google-feed-envkv.d.ts +2 -0
- package/dist/transformers/google-feed-envkv.js +62 -31
- package/dist/transformers/google-feed-envkv.js.map +1 -1
- package/dist/transformers/google-feed-item-row.d.ts +6 -1
- package/dist/transformers/google-feed-item-row.js +3 -1
- package/dist/transformers/google-feed-item-row.js.map +1 -1
- package/dist/transformers/openai-ads.transformer.d.ts +28 -0
- package/dist/transformers/openai-ads.transformer.js +144 -0
- package/dist/transformers/openai-ads.transformer.js.map +1 -0
- package/dist/transformers/openai-image-fields.d.ts +16 -0
- package/dist/transformers/openai-image-fields.js +34 -0
- package/dist/transformers/openai-image-fields.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/openai-ads/AUDIT.md +235 -0
- package/docs/openai-ads/RUNBOOK.md +94 -0
- package/package.json +64 -64
- package/schema/feed-items.json +134 -122
- package/src/configs/config.ts +99 -97
- package/src/configs/google.config.ts +41 -26
- package/src/configs/index.ts +1 -0
- package/src/configs/openai-ads.config.ts +70 -0
- package/src/enums/export-type.enum.ts +2 -0
- package/src/enums/image-type.enum.ts +5 -0
- package/src/export.command.ts +38 -38
- package/src/export.schema.ts +6 -0
- package/src/exporters/google.exporter.ts +67 -56
- package/src/exporters/index.ts +1 -0
- package/src/exporters/openai-ads.exporter.ts +93 -0
- package/src/formats/openai-ads-csv.builder.ts +55 -0
- package/src/interfaces/openai-ads.interface.ts +64 -0
- package/src/interfaces/params.interface.ts +56 -29
- package/src/services/feed-guard.ts +219 -0
- package/src/services/feed-items-bigquery.writer.ts +101 -101
- package/src/services/image-url-validator.service.ts +181 -0
- package/src/services/images.service.ts +72 -0
- package/src/services/index.ts +1 -0
- package/src/services/openai-ads-feed-run.ts +226 -0
- package/src/services/openai-ads-ledger.ts +116 -0
- package/src/services/publication-gate.ts +119 -0
- package/src/transformers/google-feed-envkv.ts +81 -33
- package/src/transformers/google-feed-item-row.ts +78 -71
- package/src/transformers/openai-ads.transformer.ts +211 -0
- package/src/transformers/openai-image-fields.ts +60 -0
- package/test/clean-images.test.ts +86 -0
- package/test/fixtures/images-service.harness.ts +32 -0
- package/test/fixtures/images.golden.json +218 -0
- package/test/fixtures/imbo-images.fixture.ts +77 -0
- package/test/fixtures/openai-ads.fixture.ts +89 -0
- package/test/google-feed-item-row.test.ts +135 -121
- package/test/image-url-validator.test.ts +110 -0
- package/test/images-regression.test.ts +23 -0
- package/test/openai-ads-feed-run.test.ts +278 -0
- package/test/openai-ads-mapping.test.ts +136 -0
- package/test/openai-image-fields.test.ts +99 -0
- package/test/publication-gate.test.ts +86 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
// Integration: real files in a temp dir (base export snapshot, output, state), real CSV writer,
|
|
3
|
+
// guard and ledger, real ADV-336 clean-image selection; only HTTP (image check) and UPA are faked.
|
|
4
|
+
import * as assert from 'node:assert/strict';
|
|
5
|
+
import { promises as fs } from 'fs';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
import { FeedGuard, FeedGuardError, FeedGuardOptions } from '../src/services/feed-guard';
|
|
10
|
+
import { DealerStatus, OpenAiAdsFeedRun, SourceCar } from '../src/services/openai-ads-feed-run';
|
|
11
|
+
import { selectOpenAiImageFields } from '../src/transformers/openai-image-fields';
|
|
12
|
+
import { buildImagesService } from './fixtures/images-service.harness';
|
|
13
|
+
import { NOW, car, csvObjects, images, translate } from './fixtures/openai-ads.fixture';
|
|
14
|
+
|
|
15
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
16
|
+
const imagesService = buildImagesService();
|
|
17
|
+
|
|
18
|
+
interface World {
|
|
19
|
+
root: string;
|
|
20
|
+
source: string;
|
|
21
|
+
output: string;
|
|
22
|
+
state: string;
|
|
23
|
+
// image URLs answering 404
|
|
24
|
+
brokenImages: Set<string>;
|
|
25
|
+
dealers: Record<string, DealerStatus>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const newWorld = async (): Promise<World> => {
|
|
29
|
+
const root = await fs.mkdtemp(join(tmpdir(), 'openai-ads-'));
|
|
30
|
+
return {
|
|
31
|
+
root,
|
|
32
|
+
source: join(root, 'cars-export'),
|
|
33
|
+
output: join(root, 'openai_ads-dryrun', 'api_export_cars_openai_ads.csv'),
|
|
34
|
+
state: join(root, 'openai_ads-state', 'dryrun'),
|
|
35
|
+
brokenImages: new Set(),
|
|
36
|
+
dealers: {},
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Writes the base export snapshot like base-cars-export: cars.<n>.json chunks of [{car, images}].
|
|
41
|
+
const writeSource = async (world: World, cars: SourceCar[], chunkSize = 2, mtime = NOW) => {
|
|
42
|
+
await fs.rm(world.source, { recursive: true, force: true });
|
|
43
|
+
await fs.mkdir(world.source, { recursive: true });
|
|
44
|
+
for (let i = 0; i * chunkSize < cars.length; i++) {
|
|
45
|
+
const file = join(world.source, `cars.${i}.json`);
|
|
46
|
+
await fs.writeFile(file, JSON.stringify(cars.slice(i * chunkSize, (i + 1) * chunkSize)));
|
|
47
|
+
await fs.utimes(file, mtime, mtime);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const guardOptions = (world: World, extra: Partial<FeedGuardOptions> = {}): FeedGuardOptions => ({
|
|
52
|
+
stateDir: world.state,
|
|
53
|
+
minSourceItems: 1,
|
|
54
|
+
minPublishedItems: 1,
|
|
55
|
+
maxDropPercent: 20,
|
|
56
|
+
maxSourceAgeHours: 26,
|
|
57
|
+
maxStaleHours: 48,
|
|
58
|
+
acceptDrop: false,
|
|
59
|
+
...extra,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const run = async (world: World, at: Date = NOW, extra: Partial<FeedGuardOptions> = {}) => {
|
|
63
|
+
const forEachChunk = async (fn: (cars: SourceCar[]) => Promise<void>) => {
|
|
64
|
+
const names = (await fs.readdir(world.source)).sort((a, b) => Number(a.split('.')[1]) - Number(b.split('.')[1]));
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
await fn(JSON.parse(await fs.readFile(join(world.source, name), 'utf8')));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const feed = new OpenAiAdsFeedRun(
|
|
70
|
+
{
|
|
71
|
+
forEachChunk,
|
|
72
|
+
filterCar: ({ car: c }) => (c?.metaData?.imagesCount ?? 0) > 0,
|
|
73
|
+
dealerStatus: async ownerId => world.dealers[ownerId] ?? 'active',
|
|
74
|
+
resolveImages: async ({ car: c, images: list }) => {
|
|
75
|
+
const clean = imagesService.getCleanImages(c._id, list);
|
|
76
|
+
return selectOpenAiImageFields(clean, clean.map(i => ({ url: i.url, ok: !world.brokenImages.has(i.url) })));
|
|
77
|
+
},
|
|
78
|
+
imageStats: () => ({}),
|
|
79
|
+
guard: new FeedGuard(guardOptions(world, extra)),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
runId: 'test',
|
|
83
|
+
dryRun: true,
|
|
84
|
+
sourceDirectory: world.source,
|
|
85
|
+
sourceBaseFileName: 'cars',
|
|
86
|
+
outputFile: world.output,
|
|
87
|
+
stateDir: world.state,
|
|
88
|
+
retentionDays: 15,
|
|
89
|
+
mapping: { domain: 'auto.de', sellerName: 'auto.de', translate },
|
|
90
|
+
now: () => at,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
// the run prints its report as one JSON line; keep the test output readable
|
|
94
|
+
const log = console.log;
|
|
95
|
+
console.log = () => undefined;
|
|
96
|
+
try {
|
|
97
|
+
return await feed.run();
|
|
98
|
+
} finally {
|
|
99
|
+
console.log = log;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const published = async (world: World) => csvObjects(await fs.readFile(world.output, 'utf8'));
|
|
104
|
+
const byId = (rows: Record<string, string>[]) => Object.fromEntries(rows.map(r => [r.item_id, r]));
|
|
105
|
+
const src = (id: string, overrides: Record<string, any> = {}): SourceCar => ({ car: car(id, overrides), images: images(id) });
|
|
106
|
+
const fleet = (n: number) => Array.from({ length: n }, (_, i) => src(`car-${i}`));
|
|
107
|
+
|
|
108
|
+
const cases: Array<[string, () => Promise<void>]> = [
|
|
109
|
+
['first run: in_stock rows, hero image first, exclusions counted, report written', async () => {
|
|
110
|
+
const w = await newWorld();
|
|
111
|
+
w.dealers['dealer-off'] = 'inactive';
|
|
112
|
+
await writeSource(w, [
|
|
113
|
+
...fleet(3),
|
|
114
|
+
src('car-1'), // duplicate id (offset paging over a live collection)
|
|
115
|
+
src('no-photo', { 'metaData.imagesCount': 0 }),
|
|
116
|
+
src('price-0', { 'price.currentSalesPrice': 0 }),
|
|
117
|
+
src('off-dealer', { 'metaData.ownerId': 'dealer-off' }),
|
|
118
|
+
{ car: car('interior-only'), images: [{ imageIdentifier: 'i1', imageCodePredicted: 'int_f_ipanel1' }] },
|
|
119
|
+
src('reserved-new', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' }),
|
|
120
|
+
]);
|
|
121
|
+
const report = await run(w);
|
|
122
|
+
const rows = await published(w);
|
|
123
|
+
assert.deepEqual(rows.map(r => r.item_id), ['car-0', 'car-1', 'car-2']);
|
|
124
|
+
assert.equal(rows[0].image_url, 'https://images.auto.de/carimage/car-0/car-0Right/large-cached.jpeg');
|
|
125
|
+
assert.equal(rows[0].additional_image_urls.split(',').length, 2);
|
|
126
|
+
assert.ok(rows.every(r => r.availability === 'in_stock' && r.price === '17990.00 EUR'));
|
|
127
|
+
assert.deepEqual(report.excluded, { duplicate_id: 1, filtered: 1, invalid_price: 1, dealer_inactive: 1, no_image: 1 });
|
|
128
|
+
assert.equal(report.reservedNeverSent, 1);
|
|
129
|
+
assert.equal(report.status, 'published');
|
|
130
|
+
assert.equal(report.ledger.after, 3);
|
|
131
|
+
const saved = JSON.parse(await fs.readFile(join(w.state, 'last-run-report.json'), 'utf8'));
|
|
132
|
+
assert.equal(saved.rows.inStock, 3);
|
|
133
|
+
await assert.rejects(fs.stat(`${w.output}.tmp`));
|
|
134
|
+
}],
|
|
135
|
+
['sold / deleted car stays as out_of_stock with the same id; reserved car switches off; reinstated car returns', async () => {
|
|
136
|
+
const w = await newWorld();
|
|
137
|
+
await writeSource(w, fleet(5));
|
|
138
|
+
await run(w);
|
|
139
|
+
// car-1 sold (offline -> gone from snapshot), car-2 reserved, car-3 loses its price
|
|
140
|
+
await writeSource(w, [
|
|
141
|
+
src('car-0'),
|
|
142
|
+
src('car-2', { 'offer.offerAvailability': 'selector_offerAvailability_reserved', 'price.currentSalesPrice': 18500 }),
|
|
143
|
+
src('car-3', { 'price.currentSalesPrice': null }),
|
|
144
|
+
src('car-4'),
|
|
145
|
+
], 2, new Date(NOW.getTime() + DAY));
|
|
146
|
+
const report = await run(w, new Date(NOW.getTime() + DAY), { maxDropPercent: 60 });
|
|
147
|
+
const rows = byId(await published(w));
|
|
148
|
+
assert.equal(rows['car-0'].availability, 'in_stock');
|
|
149
|
+
assert.equal(rows['car-1'].availability, 'out_of_stock');
|
|
150
|
+
assert.equal(rows['car-1'].is_ads_eligible, 'false');
|
|
151
|
+
assert.equal(rows['car-1'].price, '17990.00 EUR');
|
|
152
|
+
assert.equal(rows['car-2'].availability, 'out_of_stock');
|
|
153
|
+
assert.equal(rows['car-2'].price, '18500.00 EUR', 'reserved row carries the current data');
|
|
154
|
+
assert.equal(rows['car-3'].availability, 'out_of_stock');
|
|
155
|
+
assert.deepEqual(report.outOfStock, { not_in_snapshot: 2, reserved: 1 });
|
|
156
|
+
// car-1 is back online two days later
|
|
157
|
+
await writeSource(w, fleet(5), 2, new Date(NOW.getTime() + 2 * DAY));
|
|
158
|
+
await run(w, new Date(NOW.getTime() + 2 * DAY));
|
|
159
|
+
const back = byId(await published(w));
|
|
160
|
+
assert.equal(back['car-1'].availability, 'in_stock');
|
|
161
|
+
assert.equal(Object.keys(back).length, 5);
|
|
162
|
+
}],
|
|
163
|
+
['out_of_stock rows stop after the retention window', async () => {
|
|
164
|
+
const w = await newWorld();
|
|
165
|
+
await writeSource(w, fleet(5));
|
|
166
|
+
await run(w);
|
|
167
|
+
const later = new Date(NOW.getTime() + 16 * DAY);
|
|
168
|
+
await writeSource(w, fleet(4), 2, later);
|
|
169
|
+
const report = await run(w, later);
|
|
170
|
+
const rows = await published(w);
|
|
171
|
+
assert.equal(rows.length, 4);
|
|
172
|
+
assert.equal(report.ledger.expired, 1);
|
|
173
|
+
}],
|
|
174
|
+
['source outage: empty snapshot is blocked, published file and ledger untouched', async () => {
|
|
175
|
+
const w = await newWorld();
|
|
176
|
+
await writeSource(w, fleet(5));
|
|
177
|
+
await run(w);
|
|
178
|
+
const before = await fs.readFile(w.output, 'utf8');
|
|
179
|
+
const ledgerBefore = await fs.readFile(join(w.state, 'ledger.json'), 'utf8');
|
|
180
|
+
await writeSource(w, []);
|
|
181
|
+
await assert.rejects(run(w), (e: FeedGuardError) => e.code === 'source_empty');
|
|
182
|
+
assert.equal(await fs.readFile(w.output, 'utf8'), before);
|
|
183
|
+
assert.equal(await fs.readFile(join(w.state, 'ledger.json'), 'utf8'), ledgerBefore);
|
|
184
|
+
const report = JSON.parse(await fs.readFile(join(w.state, 'last-run-report.json'), 'utf8'));
|
|
185
|
+
assert.equal(report.status, 'blocked');
|
|
186
|
+
await assert.rejects(fs.stat(join(w.state, 'run.lock')));
|
|
187
|
+
}],
|
|
188
|
+
['partial snapshot: a missing chunk file is blocked', async () => {
|
|
189
|
+
const w = await newWorld();
|
|
190
|
+
await writeSource(w, fleet(6));
|
|
191
|
+
await fs.rm(join(w.source, 'cars.1.json'));
|
|
192
|
+
await assert.rejects(run(w), (e: FeedGuardError) => e.code === 'source_partial');
|
|
193
|
+
await assert.rejects(fs.stat(w.output));
|
|
194
|
+
}],
|
|
195
|
+
['partial snapshot: base export died after the first pages -> count drop blocked, then accepted on purpose', async () => {
|
|
196
|
+
const w = await newWorld();
|
|
197
|
+
await writeSource(w, fleet(10));
|
|
198
|
+
await run(w);
|
|
199
|
+
const before = await fs.readFile(w.output, 'utf8');
|
|
200
|
+
await writeSource(w, fleet(4));
|
|
201
|
+
await assert.rejects(run(w), (e: FeedGuardError) => e.code === 'count_drop');
|
|
202
|
+
assert.equal(await fs.readFile(w.output, 'utf8'), before);
|
|
203
|
+
assert.ok((await fs.readFile(`${w.output}.rejected`, 'utf8')).length > 0);
|
|
204
|
+
// legitimate drop confirmed by a human: one run with acceptDrop
|
|
205
|
+
const report = await run(w, NOW, { acceptDrop: true });
|
|
206
|
+
assert.equal(report.status, 'published');
|
|
207
|
+
assert.equal((await published(w)).filter(r => r.availability === 'out_of_stock').length, 6);
|
|
208
|
+
assert.equal(await fs.readFile(`${w.output}.prev`, 'utf8'), before);
|
|
209
|
+
}],
|
|
210
|
+
['legitimate small stock change passes the guard; recovery after a blocked run', async () => {
|
|
211
|
+
const w = await newWorld();
|
|
212
|
+
await writeSource(w, fleet(10));
|
|
213
|
+
await run(w);
|
|
214
|
+
await writeSource(w, []);
|
|
215
|
+
await assert.rejects(run(w));
|
|
216
|
+
await writeSource(w, fleet(9));
|
|
217
|
+
const report = await run(w);
|
|
218
|
+
assert.equal(report.status, 'published');
|
|
219
|
+
assert.equal(report.rows.inStock, 9);
|
|
220
|
+
}],
|
|
221
|
+
['stale snapshot: chunk files older than maxSourceAgeHours are blocked', async () => {
|
|
222
|
+
const w = await newWorld();
|
|
223
|
+
await writeSource(w, fleet(3), 2, new Date(NOW.getTime() - 27 * 60 * 60 * 1000));
|
|
224
|
+
await assert.rejects(run(w), (e: FeedGuardError) => e.code === 'source_stale');
|
|
225
|
+
}],
|
|
226
|
+
['minimums: tiny source is blocked even without a last good run', async () => {
|
|
227
|
+
const w = await newWorld();
|
|
228
|
+
await writeSource(w, fleet(3));
|
|
229
|
+
await assert.rejects(run(w, NOW, { minSourceItems: 100 }), (e: FeedGuardError) => e.code === 'source_too_small');
|
|
230
|
+
}],
|
|
231
|
+
['lock: a second run while one holds the lock is refused; a stale lock is taken over', async () => {
|
|
232
|
+
const w = await newWorld();
|
|
233
|
+
await writeSource(w, fleet(3));
|
|
234
|
+
await fs.mkdir(w.state, { recursive: true });
|
|
235
|
+
await fs.writeFile(join(w.state, 'run.lock'), '{}');
|
|
236
|
+
await fs.utimes(join(w.state, 'run.lock'), NOW, NOW);
|
|
237
|
+
await assert.rejects(run(w), (e: FeedGuardError) => e.code === 'locked');
|
|
238
|
+
const old = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
|
|
239
|
+
await fs.utimes(join(w.state, 'run.lock'), old, old);
|
|
240
|
+
assert.equal((await run(w)).status, 'published');
|
|
241
|
+
}],
|
|
242
|
+
['broken hero image: next 3/4 view used; no valid 3/4 view -> car not sent', async () => {
|
|
243
|
+
const w = await newWorld();
|
|
244
|
+
const withLeft = { car: car('car-l'), images: [...images('car-l'), { imageIdentifier: 'car-lLeft', imageCodePredicted: 'ext_f_left' }] };
|
|
245
|
+
await writeSource(w, [withLeft, src('car-x')]);
|
|
246
|
+
w.brokenImages.add('https://images.auto.de/carimage/car-l/car-lRight/large-cached.jpeg');
|
|
247
|
+
w.brokenImages.add('https://images.auto.de/carimage/car-x/car-xRight/large-cached.jpeg');
|
|
248
|
+
const report = await run(w);
|
|
249
|
+
const rows = await published(w);
|
|
250
|
+
assert.deepEqual(rows.map(r => r.item_id), ['car-l']);
|
|
251
|
+
assert.equal(rows[0].image_url, 'https://images.auto.de/carimage/car-l/car-lLeft/large-cached.jpeg');
|
|
252
|
+
assert.equal(report.excluded.no_image, 1);
|
|
253
|
+
}],
|
|
254
|
+
['CSV file: umlauts as UTF-8 bytes, no BOM, one header', async () => {
|
|
255
|
+
const w = await newWorld();
|
|
256
|
+
await writeSource(w, [src('car-u', { 'mainData.make': 'Škoda', 'mainData.subModel': 'Größe, "Edition"\nNeu' })]);
|
|
257
|
+
await run(w);
|
|
258
|
+
const bytes = await fs.readFile(w.output);
|
|
259
|
+
assert.notEqual(bytes[0], 0xef, 'no UTF-8 BOM');
|
|
260
|
+
assert.ok(bytes.includes(Buffer.from('Größe', 'utf8')));
|
|
261
|
+
const [row] = await published(w);
|
|
262
|
+
assert.equal(row.title, 'Škoda Golf Größe, "Edition" Neu');
|
|
263
|
+
}],
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
(async () => {
|
|
267
|
+
let failed = 0;
|
|
268
|
+
for (const [name, fn] of cases) {
|
|
269
|
+
try {
|
|
270
|
+
await fn();
|
|
271
|
+
console.log(`ok ${name}`);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
failed += 1;
|
|
274
|
+
console.error(`FAIL ${name}\n ${e.stack || e.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (failed) process.exit(1);
|
|
278
|
+
})();
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
import * as assert from 'node:assert/strict';
|
|
3
|
+
import { OPENAI_ADS_COLUMNS, OpenAiAdsExclusion as X, OpenAiAdsOutOfStockReason as O } from '../src/interfaces/openai-ads.interface';
|
|
4
|
+
import { openAiAdsCsvHeader, openAiAdsCsvRow, toCsvCell } from '../src/formats/openai-ads-csv.builder';
|
|
5
|
+
import {
|
|
6
|
+
asOutOfStock,
|
|
7
|
+
formatOpenAiPrice,
|
|
8
|
+
mapOpenAiAdsVehicle,
|
|
9
|
+
toOpenAiMinorUnits,
|
|
10
|
+
withOpenAiImages,
|
|
11
|
+
} from '../src/transformers/openai-ads.transformer';
|
|
12
|
+
import { NOW, car, csvObjects, translate } from './fixtures/openai-ads.fixture';
|
|
13
|
+
|
|
14
|
+
const ctx = { domain: 'auto.de', sellerName: 'auto.de', translate, now: NOW };
|
|
15
|
+
const map = (c: any) => mapOpenAiAdsVehicle(c, ctx);
|
|
16
|
+
const okRow = (c: any) => {
|
|
17
|
+
const result = map(c);
|
|
18
|
+
assert.equal(result.ok, true, `expected ok, got ${JSON.stringify(result)}`);
|
|
19
|
+
return (result as any).row;
|
|
20
|
+
};
|
|
21
|
+
const reason = (c: any) => {
|
|
22
|
+
const result = map(c);
|
|
23
|
+
assert.equal(result.ok, false);
|
|
24
|
+
return (result as any).reason;
|
|
25
|
+
};
|
|
26
|
+
const imageFields = {
|
|
27
|
+
image_url: 'https://images.auto.de/carimage/id/right/large-cached.jpeg',
|
|
28
|
+
additional_image_urls: ['https://images.auto.de/carimage/id/back/large-cached.jpeg', 'https://x.test/a,b.jpeg'],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const cases: Array<[string, () => void]> = [
|
|
32
|
+
['price: purchase price in major units with two decimals, never the rate', () => {
|
|
33
|
+
assert.equal(formatOpenAiPrice(17990), '17990.00 EUR');
|
|
34
|
+
assert.equal(formatOpenAiPrice(17990.5), '17990.50 EUR');
|
|
35
|
+
assert.equal(formatOpenAiPrice('24999'), '24999.00 EUR');
|
|
36
|
+
assert.equal(formatOpenAiPrice(123456.789), '123456.79 EUR');
|
|
37
|
+
for (const bad of [0, -1, null, undefined, '', 'abc', NaN, Infinity, true]) {
|
|
38
|
+
assert.equal(formatOpenAiPrice(bad), null, String(bad));
|
|
39
|
+
}
|
|
40
|
+
assert.equal(okRow(car('a')).price, '17990.00 EUR');
|
|
41
|
+
assert.equal(okRow(car('a', { 'rate.monthlyInstallment': 99 })).price, '17990.00 EUR');
|
|
42
|
+
}],
|
|
43
|
+
['price: zero / missing / text price excludes the car', () => {
|
|
44
|
+
assert.equal(reason(car('a', { 'price.currentSalesPrice': 0 })), X.invalidPrice);
|
|
45
|
+
assert.equal(reason(car('a', { 'price.currentSalesPrice': undefined })), X.invalidPrice);
|
|
46
|
+
assert.equal(reason(car('a', { 'price.currentSalesPrice': 'auf Anfrage' })), X.invalidPrice);
|
|
47
|
+
}],
|
|
48
|
+
['delta price: integer minor units', () => {
|
|
49
|
+
assert.equal(toOpenAiMinorUnits(17990), 1799000);
|
|
50
|
+
assert.equal(toOpenAiMinorUnits(17990.5), 1799050);
|
|
51
|
+
assert.equal(toOpenAiMinorUnits(0), null);
|
|
52
|
+
}],
|
|
53
|
+
['identity: item_id = car._id, url = VDP of the same id, brand = real make', () => {
|
|
54
|
+
const row = okRow(car('2ce1d71f-851f-4123-ae16-187ddd7923c5', { 'mainData.make': 'Mercedes-Benz' }));
|
|
55
|
+
assert.equal(row.item_id, '2ce1d71f-851f-4123-ae16-187ddd7923c5');
|
|
56
|
+
assert.equal(row.url, 'https://www.auto.de/search/vehicle/2ce1d71f-851f-4123-ae16-187ddd7923c5');
|
|
57
|
+
assert.equal(row.brand, 'Mercedes-Benz');
|
|
58
|
+
assert.equal(row.seller_name, 'auto.de');
|
|
59
|
+
assert.equal(reason(car('')), X.noId);
|
|
60
|
+
}],
|
|
61
|
+
['brand: placeholder make SONSTIGE / empty is excluded', () => {
|
|
62
|
+
assert.equal(reason(car('a', { 'mainData.make': 'SONSTIGE' })), X.noBrand);
|
|
63
|
+
assert.equal(reason(car('a', { 'mainData.make': '' })), X.noBrand);
|
|
64
|
+
}],
|
|
65
|
+
['title: make model variant, junk punctuation removed, umlauts kept, max 150', () => {
|
|
66
|
+
assert.equal(okRow(car('a')).title, 'Volkswagen Golf 1.5 TSI Life');
|
|
67
|
+
const row = okRow(car('a', { 'mainData.make': 'Škoda', 'mainData.model': 'Octavia', 'mainData.subModel': '*AHK*Größe "L"* &Navi#' }));
|
|
68
|
+
assert.equal(row.title, 'Škoda Octavia AHK Größe "L" Navi');
|
|
69
|
+
const long = okRow(car('a', { 'mainData.subModel': 'Ausstattung '.repeat(30) })).title;
|
|
70
|
+
assert.ok(long.length <= 150 && !long.endsWith(' '), long);
|
|
71
|
+
}],
|
|
72
|
+
['condition: new/used; unknown only used with a past first registration', () => {
|
|
73
|
+
assert.equal(okRow(car('a', { 'mainData.condition': 'selector_condition_new' })).condition, 'new');
|
|
74
|
+
assert.equal(okRow(car('a')).condition, 'used');
|
|
75
|
+
assert.equal(okRow(car('a', { 'mainData.condition': 'selector_unknown' })).condition, 'used');
|
|
76
|
+
assert.equal(reason(car('a', { 'mainData.condition': 'selector_unknown', 'mainData.firstRegistration': undefined })), X.conditionUnknown);
|
|
77
|
+
assert.equal(reason(car('a', { 'mainData.condition': undefined, 'mainData.firstRegistration': NOW.getTime() / 1000 + 86400 })), X.conditionUnknown);
|
|
78
|
+
}],
|
|
79
|
+
['description: plain German facts, no rate, no price, real mileage', () => {
|
|
80
|
+
const { description } = okRow(car('a', { 'mainData.mileage': 56 }));
|
|
81
|
+
assert.equal(
|
|
82
|
+
description,
|
|
83
|
+
'Gebrauchtwagen: Volkswagen Golf 1.5 TSI Life. Erstzulassung: 03/2021 · Kilometerstand: 56 km · Kraftstoff: Benzin · ' +
|
|
84
|
+
'Getriebe: Manuell · Leistung: 110 kW (150 PS) · Karosserie: Limousine · Außenfarbe: Schwarz',
|
|
85
|
+
);
|
|
86
|
+
assert.ok(!/€|EUR|Rate|monat/i.test(description));
|
|
87
|
+
const sparse = okRow(car('a', {
|
|
88
|
+
'mainData.condition': 'selector_condition_new', 'mainData.firstRegistration': undefined, 'mainData.mileage': 0,
|
|
89
|
+
'consumption.fuel': 'selector_fuel_unmapped', 'exterior.exteriorColor': 'selector_unknown',
|
|
90
|
+
})).description;
|
|
91
|
+
assert.ok(sparse.startsWith('Neuwagen: Volkswagen Golf 1.5 TSI Life.'), sparse);
|
|
92
|
+
assert.ok(!/selector_|Unbekannt|Erstzulassung|Kilometerstand|Außenfarbe/.test(sparse), sparse);
|
|
93
|
+
}],
|
|
94
|
+
['reserved / sold flag on an online car -> out_of_stock signal, row still mapped', () => {
|
|
95
|
+
assert.equal((map(car('a', { 'offer.offerAvailability': 'selector_offerAvailability_reserved' })) as any).outOfStockReason, O.reserved);
|
|
96
|
+
assert.equal((map(car('a', { 'offer.offerAvailability': 'selector_offerAvailability_sold' })) as any).outOfStockReason, O.soldFlag);
|
|
97
|
+
assert.equal((map(car('a', { 'offer.offerAvailability': 'selector_offerAvailability_arrived' })) as any).outOfStockReason, undefined);
|
|
98
|
+
}],
|
|
99
|
+
['CSV cells: RFC 4180 quoting, booleans lowercase, umlauts untouched', () => {
|
|
100
|
+
assert.equal(toCsvCell('plain'), 'plain');
|
|
101
|
+
assert.equal(toCsvCell('a,b'), '"a,b"');
|
|
102
|
+
assert.equal(toCsvCell('Zoll 18"'), '"Zoll 18"""');
|
|
103
|
+
assert.equal(toCsvCell('line1\nline2'), '"line1\nline2"');
|
|
104
|
+
assert.equal(toCsvCell('Größe Ä'), 'Größe Ä');
|
|
105
|
+
assert.equal(toCsvCell(true), 'true');
|
|
106
|
+
assert.equal(toCsvCell(false), 'false');
|
|
107
|
+
assert.equal(toCsvCell(undefined), '');
|
|
108
|
+
}],
|
|
109
|
+
['CSV row: fixed header, round-trips through a CSV parser, out_of_stock row', () => {
|
|
110
|
+
assert.equal(openAiAdsCsvHeader(), `${OPENAI_ADS_COLUMNS.join(',')}\n`);
|
|
111
|
+
assert.ok(!/gtin|mpn|vin|target_countries|store_country|image_link/.test(openAiAdsCsvHeader()));
|
|
112
|
+
const row = withOpenAiImages(okRow(car('a', { 'mainData.subModel': 'Life, "Edition" Größe' })), imageFields);
|
|
113
|
+
const text = openAiAdsCsvHeader() + openAiAdsCsvRow(row) + openAiAdsCsvRow(asOutOfStock(row));
|
|
114
|
+
const [inStock, outOfStock] = csvObjects(text);
|
|
115
|
+
assert.equal(inStock.title, 'Volkswagen Golf Life, "Edition" Größe');
|
|
116
|
+
assert.equal(inStock.additional_image_urls, 'https://images.auto.de/carimage/id/back/large-cached.jpeg,https://x.test/a%2Cb.jpeg');
|
|
117
|
+
assert.equal(inStock.availability, 'in_stock');
|
|
118
|
+
assert.equal(inStock.is_ads_eligible, 'true');
|
|
119
|
+
assert.equal(outOfStock.availability, 'out_of_stock');
|
|
120
|
+
assert.equal(outOfStock.is_ads_eligible, 'false');
|
|
121
|
+
assert.equal(outOfStock.item_id, inStock.item_id);
|
|
122
|
+
assert.equal(Object.keys(inStock).length, OPENAI_ADS_COLUMNS.length);
|
|
123
|
+
}],
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
let failed = 0;
|
|
127
|
+
for (const [name, fn] of cases) {
|
|
128
|
+
try {
|
|
129
|
+
fn();
|
|
130
|
+
console.log(`ok ${name}`);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
failed += 1;
|
|
133
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
import * as assert from 'node:assert/strict';
|
|
3
|
+
import { CleanImage } from '../src/services/images.service';
|
|
4
|
+
import { ImageUrlCheck, ImageUrlRejectReason as R } from '../src/services/image-url-validator.service';
|
|
5
|
+
import {
|
|
6
|
+
encodeCommasInUrl,
|
|
7
|
+
selectOpenAiImageFields,
|
|
8
|
+
serializeAdditionalImageUrlsForCsv,
|
|
9
|
+
serializeAdditionalImageUrlsForJsonl,
|
|
10
|
+
} from '../src/transformers/openai-image-fields';
|
|
11
|
+
|
|
12
|
+
const image = (id: string, imageCode: string, heroCandidate = false): CleanImage => ({
|
|
13
|
+
url: `https://images.auto.de/carimage/car/${id}/large-cached.jpeg`,
|
|
14
|
+
imageIdentifier: id,
|
|
15
|
+
imageCode,
|
|
16
|
+
heroCandidate,
|
|
17
|
+
});
|
|
18
|
+
const ok = (i: CleanImage): ImageUrlCheck => ({ url: i.url, ok: true, status: 200 });
|
|
19
|
+
const bad = (i: CleanImage, reason = R.httpStatus): ImageUrlCheck => ({ url: i.url, ok: false, reason, status: 404 });
|
|
20
|
+
|
|
21
|
+
const right = image('right', 'ext_f_right', true);
|
|
22
|
+
const left = image('left', 'ext_f_left', true);
|
|
23
|
+
const back = image('back', 'ext_b_main');
|
|
24
|
+
const dash = image('dash', 'int_f_ipanel1');
|
|
25
|
+
|
|
26
|
+
// Minimal RFC 4180 reader, enough to prove a real CSV parser sees ONE cell.
|
|
27
|
+
const parseCsvLine = (line: string): string[] => {
|
|
28
|
+
const cells: string[] = [];
|
|
29
|
+
let cell = '';
|
|
30
|
+
let quoted = false;
|
|
31
|
+
for (let i = 0; i < line.length; i++) {
|
|
32
|
+
const c = line[i];
|
|
33
|
+
if (quoted) {
|
|
34
|
+
if (c === '"' && line[i + 1] === '"') { cell += '"'; i++; } else if (c === '"') { quoted = false; } else { cell += c; }
|
|
35
|
+
} else if (c === '"') { quoted = true; } else if (c === ',') { cells.push(cell); cell = ''; } else { cell += c; }
|
|
36
|
+
}
|
|
37
|
+
cells.push(cell);
|
|
38
|
+
return cells;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const cases: Array<[string, () => void]> = [
|
|
42
|
+
['image_url = hero, additional_image_urls = all other valid images in order, no count limit', () => {
|
|
43
|
+
const many = Array.from({ length: 40 }, (_, n) => image(`int${n}`, 'int_f_ipanel1'));
|
|
44
|
+
const images = [right, left, back, dash, ...many];
|
|
45
|
+
const { fields, rejected } = selectOpenAiImageFields(images, images.map(ok));
|
|
46
|
+
assert.equal(fields.image_url, right.url);
|
|
47
|
+
assert.deepEqual(fields.additional_image_urls, [left, back, dash, ...many].map(i => i.url));
|
|
48
|
+
assert.deepEqual(rejected, []);
|
|
49
|
+
}],
|
|
50
|
+
['invalid optional images are dropped and reported', () => {
|
|
51
|
+
const { fields, rejected } = selectOpenAiImageFields([right, back, dash], [ok(right), bad(back), ok(dash)]);
|
|
52
|
+
assert.deepEqual(fields.additional_image_urls, [dash.url]);
|
|
53
|
+
assert.deepEqual(rejected.map(r => r.url), [back.url]);
|
|
54
|
+
}],
|
|
55
|
+
['invalid hero: next valid 3/4 candidate becomes image_url', () => {
|
|
56
|
+
const { fields } = selectOpenAiImageFields([right, left, back], [bad(right, R.httpStatus), ok(left), ok(back)]);
|
|
57
|
+
assert.equal(fields.image_url, left.url);
|
|
58
|
+
assert.deepEqual(fields.additional_image_urls, [back.url]);
|
|
59
|
+
}],
|
|
60
|
+
['no valid hero: no fields (car skipped), a non-hero view is never promoted', () => {
|
|
61
|
+
const selection = selectOpenAiImageFields([right, back, dash], [bad(right), ok(back), ok(dash)]);
|
|
62
|
+
assert.equal(selection.fields, null);
|
|
63
|
+
assert.equal(selection.rejected.length, 1);
|
|
64
|
+
assert.equal(selectOpenAiImageFields([], []).fields, null);
|
|
65
|
+
}],
|
|
66
|
+
['unchecked images count as invalid', () => {
|
|
67
|
+
assert.equal(selectOpenAiImageFields([right], []).fields, null);
|
|
68
|
+
}],
|
|
69
|
+
['CSV: one quoted cell, comma-separated, commas inside URLs as %2C', () => {
|
|
70
|
+
const withComma = 'https://images.auto.de/carimage/car/a,b/large-cached.jpeg';
|
|
71
|
+
const cell = serializeAdditionalImageUrlsForCsv([back.url, withComma]);
|
|
72
|
+
assert.equal(cell, `"${back.url},https://images.auto.de/carimage/car/a%2Cb/large-cached.jpeg"`);
|
|
73
|
+
assert.ok(!cell.includes(';') && !cell.includes(' '));
|
|
74
|
+
|
|
75
|
+
const row = parseCsvLine(`car-1,${right.url},${cell},49990.00 EUR`);
|
|
76
|
+
assert.equal(row.length, 4);
|
|
77
|
+
assert.deepEqual(row[2].split(','), [back.url, 'https://images.auto.de/carimage/car/a%2Cb/large-cached.jpeg']);
|
|
78
|
+
}],
|
|
79
|
+
['CSV: quotes inside a value are doubled; empty list is an empty cell', () => {
|
|
80
|
+
assert.equal(serializeAdditionalImageUrlsForCsv(['https://x.de/a"b.jpg']), '"https://x.de/a""b.jpg"');
|
|
81
|
+
assert.equal(serializeAdditionalImageUrlsForCsv([]), '""');
|
|
82
|
+
}],
|
|
83
|
+
['JSONL keeps an array (commas still encoded)', () => {
|
|
84
|
+
assert.deepEqual(serializeAdditionalImageUrlsForJsonl([back.url, 'https://x.de/a,b.jpg']), [back.url, 'https://x.de/a%2Cb.jpg']);
|
|
85
|
+
assert.equal(encodeCommasInUrl(right.url), right.url);
|
|
86
|
+
}],
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
let failed = 0;
|
|
90
|
+
for (const [name, fn] of cases) {
|
|
91
|
+
try {
|
|
92
|
+
fn();
|
|
93
|
+
console.log(`ok ${name}`);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
failed += 1;
|
|
96
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
import * as assert from 'node:assert/strict';
|
|
3
|
+
import { PublicationGate } from '../src/services/publication-gate';
|
|
4
|
+
|
|
5
|
+
const icev = (extra: Record<string, unknown> = {}): any => ({
|
|
6
|
+
_id: 'car-1',
|
|
7
|
+
consumption: { fuel: 'selector_fuel_petrol', wltpCombined: 6.1, consumptionCombined: 6.4, ...(extra.consumption as object) },
|
|
8
|
+
engineData: {},
|
|
9
|
+
battery: {},
|
|
10
|
+
environmentEmissions: { wltpCo2: 139, co2: 145, ...(extra.environmentEmissions as object) },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const plugIn = (): any => ({
|
|
14
|
+
_id: 'car-2',
|
|
15
|
+
consumption: { fuel: 'selector_fuel_hybridElectricalPetrol', wltpWeightedCombined: 0.8, wltpCombined: 6.6, wltpWeightedPowerCombined: 15.6 },
|
|
16
|
+
engineData: { hybridPlugin: true },
|
|
17
|
+
battery: { wltpRange: 101 },
|
|
18
|
+
environmentEmissions: { wltpCo2: 18 },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const gate = (mode: 'audit' | 'enforce', entries: [string, string][]) => new PublicationGate(mode, new Map(entries));
|
|
22
|
+
|
|
23
|
+
const cases: Array<[string, () => void]> = [
|
|
24
|
+
['off: never estimated, never touches the item', () => {
|
|
25
|
+
const off = PublicationGate.off();
|
|
26
|
+
assert.deepEqual(off.estimatedAttributes(icev()), []);
|
|
27
|
+
const item = { fuel_consumption: '6.1l/100km' };
|
|
28
|
+
assert.deepEqual(off.apply(item, ['fuel_consumption']), { fuel_consumption: '6.1l/100km' });
|
|
29
|
+
}],
|
|
30
|
+
['the estimate counts only for the field the attribute was taken from', () => {
|
|
31
|
+
// wltpCombined is sent; an estimate on the legacy fallback does not matter
|
|
32
|
+
assert.deepEqual(gate('audit', [['car-1|consumption.consumptionCombined', '6.4']]).estimatedAttributes(icev()), []);
|
|
33
|
+
assert.deepEqual(gate('audit', [['car-1|consumption.wltpCombined', '6.1']]).estimatedAttributes(icev()), ['fuel_consumption']);
|
|
34
|
+
}],
|
|
35
|
+
['a later delivered value (stored value differs from the logged estimate) is not an estimate', () => {
|
|
36
|
+
assert.deepEqual(gate('audit', [['car-1|environmentEmissions.wltpCo2', '150']]).estimatedAttributes(icev()), []);
|
|
37
|
+
assert.deepEqual(gate('audit', [['car-1|environmentEmissions.wltpCo2', '139.0']]).estimatedAttributes(icev()), ['co2_emissions']);
|
|
38
|
+
}],
|
|
39
|
+
['fallback field: wltpCombined missing, consumptionCombined sent and estimated', () => {
|
|
40
|
+
const car = icev({ consumption: { wltpCombined: null } });
|
|
41
|
+
assert.deepEqual(gate('audit', [['car-1|consumption.consumptionCombined', '6.4']]).estimatedAttributes(car), ['fuel_consumption']);
|
|
42
|
+
}],
|
|
43
|
+
['other cars are not affected', () => {
|
|
44
|
+
assert.deepEqual(gate('audit', [['car-9|consumption.wltpCombined', '6.1']]).estimatedAttributes(icev()), []);
|
|
45
|
+
}],
|
|
46
|
+
['confirmed plug-in: weighted values estimated, delivered range and CO2 kept', () => {
|
|
47
|
+
const g = gate('enforce', [
|
|
48
|
+
['car-2|consumption.wltpWeightedCombined', '0.8'],
|
|
49
|
+
['car-2|consumption.wltpWeightedPowerCombined', '15.6'],
|
|
50
|
+
]);
|
|
51
|
+
const attributes = g.estimatedAttributes(plugIn());
|
|
52
|
+
assert.deepEqual(attributes, ['fuel_consumption', 'energy_consumption']);
|
|
53
|
+
const item = {
|
|
54
|
+
fuel_consumption: '0.8l/100km',
|
|
55
|
+
fuel_consumption_discharged_battery: '6.6l/100km',
|
|
56
|
+
energy_consumption: '15.6kWh/100km',
|
|
57
|
+
electric_range: '101km',
|
|
58
|
+
co2_emissions: '18g/km',
|
|
59
|
+
};
|
|
60
|
+
assert.deepEqual(g.apply(item, attributes), {
|
|
61
|
+
fuel_consumption_discharged_battery: '6.6l/100km',
|
|
62
|
+
electric_range: '101km',
|
|
63
|
+
co2_emissions: '18g/km',
|
|
64
|
+
});
|
|
65
|
+
}],
|
|
66
|
+
['audit records but does not remove', () => {
|
|
67
|
+
const item = { fuel_consumption: '6.1l/100km' };
|
|
68
|
+
assert.deepEqual(gate('audit', []).apply(item, ['fuel_consumption']), { fuel_consumption: '6.1l/100km' });
|
|
69
|
+
}],
|
|
70
|
+
['BEV CO2 0 g/km has no field behind it and is never gated', () => {
|
|
71
|
+
const bev: any = { _id: 'car-3', consumption: { fuel: 'selector_fuel_electrical', wltpPowerCombined: 16.8 }, engineData: {}, battery: {}, environmentEmissions: { wltpCo2: 0 } };
|
|
72
|
+
assert.deepEqual(gate('audit', [['car-3|environmentEmissions.wltpCo2', '0'], ['car-3|consumption.wltpPowerCombined', '16.8']]).estimatedAttributes(bev), ['energy_consumption']);
|
|
73
|
+
}],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
let failed = 0;
|
|
77
|
+
for (const [name, fn] of cases) {
|
|
78
|
+
try {
|
|
79
|
+
fn();
|
|
80
|
+
console.log(`ok ${name}`);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
failed += 1;
|
|
83
|
+
console.error(`FAIL ${name}\n ${e.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (failed) process.exit(1);
|