@bytebrand/export-cars 1.26.24 → 1.26.26

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.
Files changed (33) hide show
  1. package/README.md +30 -0
  2. package/dist/configs/config.d.ts +3 -1
  3. package/dist/configs/config.js +2 -0
  4. package/dist/configs/config.js.map +1 -1
  5. package/dist/configs/google.config.js +9 -0
  6. package/dist/configs/google.config.js.map +1 -1
  7. package/dist/export.command.js +1 -0
  8. package/dist/export.command.js.map +1 -1
  9. package/dist/exporters/google.exporter.js +9 -1
  10. package/dist/exporters/google.exporter.js.map +1 -1
  11. package/dist/interfaces/params.interface.d.ts +8 -0
  12. package/dist/services/feed-items-bigquery.writer.d.ts +17 -0
  13. package/dist/services/feed-items-bigquery.writer.js +96 -0
  14. package/dist/services/feed-items-bigquery.writer.js.map +1 -0
  15. package/dist/transformers/google-feed-envkv.d.ts +1 -0
  16. package/dist/transformers/google-feed-envkv.js +28 -18
  17. package/dist/transformers/google-feed-envkv.js.map +1 -1
  18. package/dist/transformers/google-feed-item-row.d.ts +25 -0
  19. package/dist/transformers/google-feed-item-row.js +37 -0
  20. package/dist/transformers/google-feed-item-row.js.map +1 -0
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/package.json +3 -2
  23. package/schema/feed-items.json +122 -0
  24. package/src/configs/config.ts +97 -93
  25. package/src/configs/google.config.ts +26 -16
  26. package/src/export.command.ts +38 -37
  27. package/src/exporters/google.exporter.ts +56 -46
  28. package/src/interfaces/params.interface.ts +29 -17
  29. package/src/services/feed-items-bigquery.writer.ts +101 -0
  30. package/src/transformers/google-feed-envkv.ts +40 -20
  31. package/src/transformers/google-feed-item-row.ts +71 -0
  32. package/test/google-feed-envkv.test.ts +13 -0
  33. package/test/google-feed-item-row.test.ts +121 -0
@@ -0,0 +1,101 @@
1
+ import { join } from 'path';
2
+ import { BigQueryLogger } from '@bytebrand/bigquery-logger';
3
+ import { FeedItemsBigQueryTarget } from '../interfaces/params.interface';
4
+ import { FeedItemRow } from '../transformers/google-feed-item-row';
5
+
6
+ const BATCH_SIZE = 500;
7
+ // After this many failed batches the writer gives up for the run (missing table, bad credentials).
8
+ const MAX_FAILED_BATCHES = 3;
9
+ const CLOSE_TIMEOUT_MS = 60000;
10
+
11
+ class FeedItemsBigQueryLogger extends BigQueryLogger {
12
+ async insertRows(rows: FeedItemRow[]): Promise<void> {
13
+ await this.client
14
+ .dataset(this.options.BigQueryDataset)
15
+ .table(this.options.BigQueryLogTable)
16
+ .insert(rows, { skipInvalidRows: true, ignoreUnknownValues: true });
17
+ }
18
+ }
19
+
20
+ // Audit trail of the feed items: rows are buffered and inserted in batches of 500 in the background,
21
+ // one batch at a time. Errors are logged and swallowed — the feed export must never fail because of
22
+ // BigQuery.
23
+ export class FeedItemsBigQueryWriter {
24
+ private logger: FeedItemsBigQueryLogger;
25
+ private buffer: FeedItemRow[] = [];
26
+ private queue: Promise<void> = Promise.resolve();
27
+ private failedBatches = 0;
28
+ private insertedRows = 0;
29
+
30
+ constructor(private target: FeedItemsBigQueryTarget) {
31
+ try {
32
+ const credentials = require(join(process.cwd(), 'credentials/google-cloud-project-id.json'));
33
+ this.logger = new FeedItemsBigQueryLogger(credentials, {
34
+ BigQueryProject: target.project,
35
+ BigQueryDataset: target.dataset,
36
+ BigQueryLogTable: target.table,
37
+ });
38
+ } catch (e) {
39
+ // First line only: a missing credentials file comes with the whole require stack.
40
+ console.log(`Feed items BigQuery disabled: ${String(e?.message || e).split('\n')[0]}`);
41
+ }
42
+ }
43
+
44
+ add(row: FeedItemRow): void {
45
+ if (!this.enabled) return;
46
+ this.buffer.push(row);
47
+ if (this.buffer.length >= BATCH_SIZE) this.flush();
48
+ }
49
+
50
+ // Waits for the pending batches, but never longer than CLOSE_TIMEOUT_MS: a hanging BigQuery
51
+ // must not hold up the feed export (the XML is already written at this point).
52
+ async close(): Promise<void> {
53
+ this.flush();
54
+ let timer: NodeJS.Timeout | undefined;
55
+ const timeout = new Promise<'timeout'>((resolve) => {
56
+ timer = setTimeout(() => resolve('timeout'), CLOSE_TIMEOUT_MS);
57
+ });
58
+ const outcome = await Promise.race([this.queue.then(() => 'done' as const), timeout]);
59
+ if (timer) clearTimeout(timer);
60
+ if (outcome === 'timeout') console.error(`Feed items BigQuery: gave up waiting after ${CLOSE_TIMEOUT_MS} ms`);
61
+ if (this.logger) {
62
+ console.log(`Feed items BigQuery: ${this.insertedRows} rows to ${this.tableName}, ${this.failedBatches} failed batches`);
63
+ }
64
+ }
65
+
66
+ private get enabled(): boolean {
67
+ return !!this.logger && this.failedBatches < MAX_FAILED_BATCHES;
68
+ }
69
+
70
+ private get tableName(): string {
71
+ return `${this.target.project}.${this.target.dataset}.${this.target.table}`;
72
+ }
73
+
74
+ private flush(): void {
75
+ if (!this.buffer.length) return;
76
+ const rows = this.buffer;
77
+ this.buffer = [];
78
+ this.queue = this.queue.then(() => this.insert(rows));
79
+ }
80
+
81
+ private async insert(rows: FeedItemRow[]): Promise<void> {
82
+ if (!this.enabled) return;
83
+ try {
84
+ await this.logger.insertRows(rows);
85
+ this.insertedRows += rows.length;
86
+ } catch (e) {
87
+ // PartialFailureError: the valid rows went in (skipInvalidRows), `errors` lists the rejected
88
+ // ones; the first is enough to see the cause.
89
+ const rejected: any[] = e?.name === 'PartialFailureError' && Array.isArray(e.errors) ? e.errors : null;
90
+ const firstRowError = rejected?.[0]?.errors?.[0];
91
+ const detail = firstRowError ? ` (${firstRowError.reason}: ${firstRowError.message})` : '';
92
+ if (rejected) {
93
+ this.insertedRows += rows.length - rejected.length;
94
+ console.log(`Feed items BigQuery rejected ${rejected.length} of ${rows.length} rows${detail}`);
95
+ } else {
96
+ this.failedBatches++;
97
+ console.log(`Feed items BigQuery insert of ${rows.length} rows failed: ${e?.message || e}`);
98
+ }
99
+ }
100
+ }
101
+ }
@@ -35,57 +35,77 @@ export function googleEngine(car: DataSchemes.Car): string {
35
35
 
36
36
  // BEV: combined WLTP range (CoC 49.2). Plug-in: the EAER (`wltpTotalRange`, CoC 49.5) when present,
37
37
  // else the range the dealer delivered. City ranges are never used; 0 counts as missing.
38
- export function googleElectricRange(car: DataSchemes.Car): string | undefined {
39
- let range: number | undefined;
40
- if (isBev(car)) {
41
- range = positive(get(car, 'battery.wltpRange')) ?? positive(get(car, 'battery.batteryRangeElectric'));
42
- } else if (isPlugIn(car)) {
43
- range = positive(get(car, 'battery.wltpTotalRange'))
44
- ?? positive(get(car, 'battery.wltpRange'))
45
- ?? positive(get(car, 'battery.batteryRangeElectric'));
38
+ const BEV_RANGE_FIELDS = ['battery.wltpRange', 'battery.batteryRangeElectric'];
39
+ const PLUG_IN_RANGE_FIELDS = ['battery.wltpTotalRange', 'battery.wltpRange', 'battery.batteryRangeElectric'];
40
+
41
+ const pickElectricRange = (car: DataSchemes.Car): { field: string; range: number } | undefined => {
42
+ const fields = isBev(car) ? BEV_RANGE_FIELDS : isPlugIn(car) ? PLUG_IN_RANGE_FIELDS : [];
43
+ for (const field of fields) {
44
+ const range = positive(get(car, field));
45
+ if (range) return { field, range };
46
46
  }
47
- return range ? `${range}km` : undefined;
47
+ return undefined;
48
+ };
49
+
50
+ export function googleElectricRange(car: DataSchemes.Car): string | undefined {
51
+ const picked = pickElectricRange(car);
52
+ return picked ? `${picked.range}km` : undefined;
53
+ }
54
+
55
+ // The car field googleElectricRange took its value from (feed audit), null when nothing is sent.
56
+ export function googleElectricRangeSource(car: DataSchemes.Car): string | null {
57
+ return pickElectricRange(car)?.field ?? null;
48
58
  }
49
59
 
50
60
  // Plug-in hybrids: Google and Pkw-EnVKV (CoC 49.4) want the WEIGHTED combined values for fuel,
51
61
  // electricity and CO2, and the fuel consumption with a DISCHARGED battery separately. In CBD those
52
62
  // are wltpWeightedCombined, wltpWeightedPowerCombined, wltpCo2 (weighted for a plug-in) and
53
63
  // wltpCombined (discharged for a plug-in); the legacy consumptionCombined / co2 are only a fallback.
64
+ //
65
+ // engineData.hybridPlugin alone does not prove a plug-in: since ADV-124 every hybrid carries it, and
66
+ // the weighted fields of hybrids without an electric range are enrichment-sheet templates
67
+ // (prod 07.10: 4,178 of 5,576 flagged cars, e.g. 2.3 l + 21.5 kWh + 195 g). Only a hybrid with an
68
+ // electric range counts as a confirmed plug-in; the others keep the values sent before.
69
+ const isConfirmedPlugIn = (car: DataSchemes.Car): boolean => isPlugIn(car) && !!googleElectricRange(car);
54
70
 
55
71
  // Missing or 0 is omitted instead of sending "0l/100km". BEVs carry no fuel consumption
56
72
  // (CBD mirrors kWh into consumptionCombined for them).
57
73
  export function googleFuelConsumption(car: DataSchemes.Car): string | undefined {
58
74
  if (isBev(car)) return undefined;
59
- const value = isPlugIn(car)
75
+ const value = isConfirmedPlugIn(car)
60
76
  ? positive(get(car, 'consumption.wltpWeightedCombined')) ?? positive(get(car, 'consumption.consumptionCombined'))
61
77
  : positive(get(car, 'consumption.consumptionCombined'));
62
78
  return value ? `${value}l/100km` : undefined;
63
79
  }
64
80
 
65
- // Plug-in only. No fallback: the legacy combined value is (close to) the weighted one, sending it
66
- // here would repeat the weighted figure as the discharged one.
81
+ // Confirmed plug-in: wltpCombined, no fallback (the legacy combined value is close to the weighted
82
+ // one, sending it here would repeat the weighted figure). Unconfirmed plug-in: the stored combined
83
+ // value, as before.
67
84
  export function googleFuelConsumptionDischarged(car: DataSchemes.Car): string | undefined {
68
85
  if (!isPlugIn(car)) return undefined;
69
- const value = positive(get(car, 'consumption.wltpCombined'));
86
+ const value = isConfirmedPlugIn(car)
87
+ ? positive(get(car, 'consumption.wltpCombined'))
88
+ : positive(get(car, 'consumption.consumptionCombined')) ?? positive(get(car, 'consumption.wltpCombined'));
70
89
  return value ? `${value}l/100km` : undefined;
71
90
  }
72
91
 
73
- // BEV: combined consumption. Plug-in: weighted combined. Nothing for other cars.
92
+ // BEV: combined consumption. Confirmed plug-in: weighted combined. Unconfirmed plug-in: the stored
93
+ // electric value, as before. Nothing for other cars.
74
94
  export function googleEnergyConsumption(car: DataSchemes.Car): string | undefined {
75
95
  let value: number | undefined;
76
- if (isBev(car)) {
77
- value = positive(get(car, 'consumption.wltpPowerCombined')) ?? positive(get(car, 'consumption.consumptionPowerCombined'));
78
- } else if (isPlugIn(car)) {
96
+ if (isConfirmedPlugIn(car)) {
79
97
  value = positive(get(car, 'consumption.wltpWeightedPowerCombined'));
98
+ } else if (isBev(car) || isPlugIn(car)) {
99
+ value = positive(get(car, 'consumption.wltpPowerCombined')) ?? positive(get(car, 'consumption.consumptionPowerCombined'));
80
100
  }
81
101
  return value ? `${value}kWh/100km` : undefined;
82
102
  }
83
103
 
84
- // A BEV emits 0 g/km by definition; a plug-in sends the weighted WLTP CO2; for every other car the
85
- // stored value. A missing or 0 value is omitted.
104
+ // A BEV emits 0 g/km by definition; a confirmed plug-in sends the weighted WLTP CO2; every other car
105
+ // the stored value. A missing or 0 value is omitted.
86
106
  export function googleCo2(car: DataSchemes.Car): string | undefined {
87
107
  if (isBev(car)) return '0g/km';
88
- const value = isPlugIn(car)
108
+ const value = isConfirmedPlugIn(car)
89
109
  ? positive(get(car, 'environmentEmissions.wltpCo2')) ?? positive(get(car, 'environmentEmissions.co2'))
90
110
  : positive(get(car, 'environmentEmissions.co2'));
91
111
  return value ? `${value}g/km` : undefined;
@@ -0,0 +1,71 @@
1
+ import { get, isBoolean } from 'lodash';
2
+ import { DataSchemes } from '@bytebrand/car-schema';
3
+ import { GoogleXmlItem } from '../interfaces';
4
+ import { googleElectricRangeSource } from './google-feed-envkv';
5
+
6
+ // One BigQuery row per item the Google feed sent (schema/feed-items.json), so a Merchant Center
7
+ // issue can be traced back to the exact values of a run. The attribute strings are the ones in the
8
+ // XML; null means the attribute was omitted.
9
+ export interface FeedItemRow {
10
+ partitionDay: string;
11
+ exportedAt: string;
12
+ feed: string;
13
+ runId: string | null;
14
+ itemId: string;
15
+ vehicleId: string | null;
16
+ make: string | null;
17
+ model: string | null;
18
+ condition: string | null;
19
+ fuel: string | null;
20
+ hybridPlugin: boolean | null;
21
+ engine: string | null;
22
+ electricRange: string | null;
23
+ fuelConsumption: string | null;
24
+ fuelConsumptionDischarged: string | null;
25
+ energyConsumption: string | null;
26
+ co2Emissions: string | null;
27
+ rangeSourceField: string | null;
28
+ price: number | null;
29
+ availability: string | null;
30
+ }
31
+
32
+ const orNull = (value: unknown): string | null =>
33
+ value === undefined || value === null || value === '' ? null : String(value);
34
+
35
+ // "12345.5 EUR" -> 12345.5
36
+ const sentPrice = (price: string | undefined): number | null => {
37
+ const n = parseFloat(price);
38
+ return Number.isFinite(n) ? n : null;
39
+ };
40
+
41
+ // exportedAt is the run's start, shared by all rows of a run; partitionDay is its UTC day.
42
+ export function buildGoogleFeedItemRow(
43
+ car: DataSchemes.Car,
44
+ item: GoogleXmlItem,
45
+ runId: string | null,
46
+ exportedAt: Date,
47
+ ): FeedItemRow {
48
+ const hybridPlugin = get(car, 'engineData.hybridPlugin');
49
+ return {
50
+ partitionDay: exportedAt.toISOString().slice(0, 10),
51
+ exportedAt: exportedAt.toISOString(),
52
+ feed: 'google',
53
+ runId: orNull(runId),
54
+ itemId: String(item.id),
55
+ vehicleId: orNull(get(car, 'metaData.vehicleId')),
56
+ make: orNull(item.brand),
57
+ model: orNull(item.model),
58
+ condition: orNull(item.condition),
59
+ fuel: orNull(get(car, 'consumption.fuel')),
60
+ hybridPlugin: isBoolean(hybridPlugin) ? hybridPlugin : null,
61
+ engine: orNull(item.engine),
62
+ electricRange: orNull(item.electric_range),
63
+ fuelConsumption: orNull(item.fuel_consumption),
64
+ fuelConsumptionDischarged: orNull(item.fuel_consumption_discharged_battery),
65
+ energyConsumption: orNull(item.energy_consumption),
66
+ co2Emissions: orNull(item.co2_emissions),
67
+ rangeSourceField: item.electric_range ? googleElectricRangeSource(car) : null,
68
+ price: sentPrice(item.price),
69
+ availability: orNull(item.availability),
70
+ };
71
+ }
@@ -66,6 +66,7 @@ const cases: Array<[string, () => void]> = [
66
66
  ['plug-in: weighted fuel, discharged fuel, weighted electricity and weighted CO2', () => {
67
67
  const phev = car('selector_fuel_hybridElectricalPetrol', {
68
68
  hybridPlugin: true,
69
+ battery: { wltpTotalRange: 62 },
69
70
  consumption: { consumptionCombined: 1.4, wltpCombined: 6.3, wltpWeightedCombined: 1.2, wltpPowerCombined: 19.3, wltpWeightedPowerCombined: 17.3 },
70
71
  environmentEmissions: { co2: 121, wltpCo2: 28 },
71
72
  });
@@ -77,6 +78,7 @@ const cases: Array<[string, () => void]> = [
77
78
  ['plug-in without weighted values falls back to the legacy combined ones, never the pure-electric value', () => {
78
79
  const phev = car('selector_fuel_hybridElectricalPetrol', {
79
80
  hybridPlugin: true,
81
+ battery: { wltpRange: 59 },
80
82
  consumption: { consumptionCombined: 1.4, wltpPowerCombined: 19.3 },
81
83
  environmentEmissions: { co2: 32 },
82
84
  });
@@ -95,6 +97,17 @@ const cases: Array<[string, () => void]> = [
95
97
  assert.equal(googleEnergyConsumption(hev), undefined);
96
98
  assert.equal(googleCo2(hev), '116g/km');
97
99
  }],
100
+ ['a plug-in flag without an electric range keeps the stored values (ADV-124 flags every hybrid)', () => {
101
+ const flagged = car('selector_fuel_hybridElectricalPetrol', {
102
+ hybridPlugin: true,
103
+ consumption: { consumptionCombined: 5.1, wltpCombined: 5.3, wltpWeightedCombined: 2.3, wltpPowerCombined: 21.5, wltpWeightedPowerCombined: 21.5 },
104
+ environmentEmissions: { co2: 116, wltpCo2: 116, wltpCo2Discharged: 195 },
105
+ });
106
+ assert.equal(googleFuelConsumption(flagged), '5.1l/100km');
107
+ assert.equal(googleFuelConsumptionDischarged(flagged), '5.1l/100km');
108
+ assert.equal(googleEnergyConsumption(flagged), '21.5kWh/100km');
109
+ assert.equal(googleCo2(flagged), '116g/km');
110
+ }],
98
111
  ['BEV energy consumption is the combined value', () => {
99
112
  assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { wltpPowerCombined: 16.5 } })), '16.5kWh/100km');
100
113
  assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { consumptionPowerCombined: 18 } })), '18kWh/100km');
@@ -0,0 +1,121 @@
1
+ // Run: npm test (ts-node, no test framework needed)
2
+ import * as assert from 'node:assert/strict';
3
+ import { buildGoogleFeedItemRow } from '../src/transformers/google-feed-item-row';
4
+ import { googleElectricRange, googleElectricRangeSource } from '../src/transformers/google-feed-envkv';
5
+
6
+ const exportedAt = new Date('2026-10-08T23:30:00.000Z');
7
+
8
+ const car = (fuel: string, extra: Record<string, any> = {}): any => ({
9
+ _id: 'cbd-1',
10
+ metaData: { vehicleId: 'veh-1' },
11
+ consumption: { fuel },
12
+ engineData: 'hybridPlugin' in extra ? { hybridPlugin: extra.hybridPlugin } : {},
13
+ battery: extra.battery || {},
14
+ });
15
+
16
+ // Shape of what GoogleTransformer returns after dropping the empty optional attributes.
17
+ const item = (extra: Record<string, any> = {}): any => ({
18
+ id: 'cbd-1',
19
+ brand: 'VW',
20
+ model: 'Golf',
21
+ condition: 'Used',
22
+ engine: 'Gasoline',
23
+ price: '19990.5 EUR',
24
+ availability: 'in_stock',
25
+ ...extra,
26
+ });
27
+
28
+ const cases: Array<[string, () => void]> = [
29
+ ['combustion car: sent values, omitted attributes are null', () => {
30
+ const row = buildGoogleFeedItemRow(
31
+ car('selector_fuel_petrol', { hybridPlugin: false }),
32
+ item({ fuel_consumption: '5.1l/100km', co2_emissions: '118g/km' }),
33
+ '12345',
34
+ exportedAt,
35
+ );
36
+ assert.deepEqual(row, {
37
+ partitionDay: '2026-10-08',
38
+ exportedAt: '2026-10-08T23:30:00.000Z',
39
+ feed: 'google',
40
+ runId: '12345',
41
+ itemId: 'cbd-1',
42
+ vehicleId: 'veh-1',
43
+ make: 'VW',
44
+ model: 'Golf',
45
+ condition: 'Used',
46
+ fuel: 'selector_fuel_petrol',
47
+ hybridPlugin: false,
48
+ engine: 'Gasoline',
49
+ electricRange: null,
50
+ fuelConsumption: '5.1l/100km',
51
+ fuelConsumptionDischarged: null,
52
+ energyConsumption: null,
53
+ co2Emissions: '118g/km',
54
+ rangeSourceField: null,
55
+ price: 19990.5,
56
+ availability: 'in_stock',
57
+ });
58
+ }],
59
+ ['BEV: range source is the field the range came from', () => {
60
+ const bev = car('selector_fuel_electrical', { battery: { batteryRangeElectric: 400 } });
61
+ const row = buildGoogleFeedItemRow(
62
+ bev,
63
+ item({ engine: 'Electric', electric_range: googleElectricRange(bev), energy_consumption: '16.5kWh/100km', co2_emissions: '0g/km' }),
64
+ '12345',
65
+ exportedAt,
66
+ );
67
+ assert.equal(row.electricRange, '400km');
68
+ assert.equal(row.rangeSourceField, 'battery.batteryRangeElectric');
69
+ assert.equal(row.hybridPlugin, null);
70
+ assert.equal(row.energyConsumption, '16.5kWh/100km');
71
+ assert.equal(row.co2Emissions, '0g/km');
72
+ }],
73
+ ['plug-in: EAER first, then WLTP range, then delivered range', () => {
74
+ const phev = (battery: object) => car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true, battery });
75
+ assert.equal(googleElectricRangeSource(phev({ wltpTotalRange: 62, wltpRange: 59 })), 'battery.wltpTotalRange');
76
+ assert.equal(googleElectricRangeSource(phev({ wltpTotalRange: 0, wltpRange: 59 })), 'battery.wltpRange');
77
+ assert.equal(googleElectricRangeSource(phev({ batteryRangeElectric: 50 })), 'battery.batteryRangeElectric');
78
+ assert.equal(googleElectricRangeSource(phev({ wltpTotalRangeCity: 70 })), null);
79
+ }],
80
+ ['range source agrees with googleElectricRange', () => {
81
+ const cars = [
82
+ car('selector_fuel_electrical', { battery: { wltpRange: 451, batteryRangeElectric: 400 } }),
83
+ car('selector_fuel_electrical', { battery: { wltpRange: 0 } }),
84
+ car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: false, battery: { wltpRange: 50 } }),
85
+ car('selector_fuel_petrol', { battery: { wltpRange: 50 } }),
86
+ ];
87
+ for (const c of cars) {
88
+ assert.equal(googleElectricRangeSource(c) === null, googleElectricRange(c) === undefined);
89
+ }
90
+ assert.equal(googleElectricRangeSource(cars[0]), 'battery.wltpRange');
91
+ }],
92
+ ['no range source when the item carries no electric_range', () => {
93
+ const bev = car('selector_fuel_electrical', { battery: { wltpRange: 451 } });
94
+ assert.equal(buildGoogleFeedItemRow(bev, item(), null, exportedAt).rangeSourceField, null);
95
+ }],
96
+ ['missing run id, vehicle id and unparsable price become null', () => {
97
+ const row = buildGoogleFeedItemRow(
98
+ { consumption: {} } as any,
99
+ item({ price: ' EUR', availability: undefined }),
100
+ undefined,
101
+ exportedAt,
102
+ );
103
+ assert.equal(row.runId, null);
104
+ assert.equal(row.vehicleId, null);
105
+ assert.equal(row.fuel, null);
106
+ assert.equal(row.price, null);
107
+ assert.equal(row.availability, null);
108
+ }],
109
+ ];
110
+
111
+ let failed = 0;
112
+ for (const [name, fn] of cases) {
113
+ try {
114
+ fn();
115
+ console.log(`ok ${name}`);
116
+ } catch (e) {
117
+ failed++;
118
+ console.log(`FAIL ${name}\n ${(e as Error).message}`);
119
+ }
120
+ }
121
+ if (failed) process.exit(1);