@bytebrand/export-cars 1.26.18 → 1.26.19-hotfix.2
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 +58 -0
- package/dist/configs/config.d.ts +4 -1
- package/dist/configs/config.js +3 -0
- package/dist/configs/config.js.map +1 -1
- package/dist/configs/facebook-shop.config.js +1 -1
- package/dist/configs/facebook-shop.config.js.map +1 -1
- package/dist/configs/fb.config.js +1 -1
- package/dist/configs/fb.config.js.map +1 -1
- package/dist/configs/google.config.js +26 -1
- package/dist/configs/google.config.js.map +1 -1
- package/dist/constants/feed-price.constants.d.ts +1 -0
- package/dist/constants/feed-price.constants.js +4 -1
- package/dist/constants/feed-price.constants.js.map +1 -1
- package/dist/export.command.js +1 -0
- package/dist/export.command.js.map +1 -1
- package/dist/exporters/facebook-shop.exporter.js +1 -3
- package/dist/exporters/facebook-shop.exporter.js.map +1 -1
- package/dist/exporters/google.exporter.js +20 -2
- package/dist/exporters/google.exporter.js.map +1 -1
- package/dist/formats/facebook-shop.builder.d.ts +1 -1
- package/dist/formats/facebook-shop.builder.js +6 -9
- package/dist/formats/facebook-shop.builder.js.map +1 -1
- package/dist/interfaces/export-item.interface.d.ts +2 -2
- package/dist/interfaces/params.interface.d.ts +19 -0
- package/dist/services/feed-items-bigquery.writer.d.ts +17 -0
- package/dist/services/feed-items-bigquery.writer.js +96 -0
- package/dist/services/feed-items-bigquery.writer.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/auto24.transfomer.js +1 -1
- package/dist/transformers/auto24.transfomer.js.map +1 -1
- package/dist/transformers/autouncle.transformer.js +1 -1
- package/dist/transformers/autouncle.transformer.js.map +1 -1
- package/dist/transformers/awin.transformer.js +1 -1
- package/dist/transformers/awin.transformer.js.map +1 -1
- package/dist/transformers/criteo.transformer.js +1 -1
- package/dist/transformers/criteo.transformer.js.map +1 -1
- package/dist/transformers/fb.transformer.js +2 -2
- package/dist/transformers/fb.transformer.js.map +1 -1
- package/dist/transformers/google-feed-envkv.d.ts +10 -0
- package/dist/transformers/google-feed-envkv.js +126 -0
- package/dist/transformers/google-feed-envkv.js.map +1 -0
- package/dist/transformers/google-feed-item-row.d.ts +30 -0
- package/dist/transformers/google-feed-item-row.js +39 -0
- package/dist/transformers/google-feed-item-row.js.map +1 -0
- package/dist/transformers/google.transformer.d.ts +0 -4
- package/dist/transformers/google.transformer.js +10 -48
- package/dist/transformers/google.transformer.js.map +1 -1
- package/dist/transformers/mobile.transformer.js +3 -2
- package/dist/transformers/mobile.transformer.js.map +1 -1
- package/dist/transformers/rubrikk.transformer.js +1 -1
- package/dist/transformers/rubrikk.transformer.js.map +1 -1
- package/dist/transformers/santander.transformer.js +3 -2
- package/dist/transformers/santander.transformer.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -2
- package/schema/feed-items.json +134 -0
- package/src/configs/config.ts +7 -1
- package/src/configs/facebook-shop.config.ts +2 -2
- package/src/configs/fb.config.ts +2 -2
- package/src/configs/google.config.ts +28 -3
- package/src/constants/feed-price.constants.ts +7 -0
- package/src/export.command.ts +1 -0
- package/src/exporters/facebook-shop.exporter.ts +1 -3
- package/src/exporters/google.exporter.ts +23 -2
- package/src/formats/facebook-shop.builder.ts +8 -9
- package/src/interfaces/export-item.interface.ts +2 -2
- package/src/interfaces/params.interface.ts +29 -0
- package/src/services/feed-items-bigquery.writer.ts +101 -0
- package/src/services/publication-gate.ts +120 -0
- package/src/transformers/auto24.transfomer.ts +2 -1
- package/src/transformers/autouncle.transformer.ts +2 -1
- package/src/transformers/awin.transformer.ts +2 -1
- package/src/transformers/criteo.transformer.ts +2 -1
- package/src/transformers/fb.transformer.ts +5 -2
- package/src/transformers/google-feed-envkv.ts +168 -0
- package/src/transformers/google-feed-item-row.ts +78 -0
- package/src/transformers/google.transformer.ts +24 -50
- package/src/transformers/mobile.transformer.ts +6 -2
- package/src/transformers/rubrikk.transformer.ts +2 -1
- package/src/transformers/santander.transformer.ts +6 -2
- package/test/google-feed-envkv.test.ts +132 -0
- package/test/google-feed-item-row.test.ts +135 -0
- package/test/publication-gate.test.ts +86 -0
|
@@ -41,7 +41,8 @@ export class Auto24Transformer {
|
|
|
41
41
|
fueltype: this.translateSelector(get(car, 'consumption.fuel', '')),
|
|
42
42
|
consumptionK: toString(get(car, 'consumption.consumptionCombined', '')),
|
|
43
43
|
co2: toString(get(car, 'environmentEmissions.co2', '')),
|
|
44
|
-
|
|
44
|
+
// ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
|
|
45
|
+
efficiencyclass: this.translateSelector(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', '')),
|
|
45
46
|
image: transformedImages[0],
|
|
46
47
|
registration: this.getRegistrationField(get(car, 'mainData.firstRegistration') as number),
|
|
47
48
|
images: transformedImages,
|
|
@@ -68,7 +68,8 @@ export class AutouncleTransformer {
|
|
|
68
68
|
images: transformed_images,
|
|
69
69
|
fuel_economy: toString(get(car, 'consumption.consumptionCombined', '')),
|
|
70
70
|
co2_emission: toString(get(car, 'environmentEmissions.co2', '')),
|
|
71
|
-
|
|
71
|
+
// ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
|
|
72
|
+
co2_effiency_class: toString(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', '')),
|
|
72
73
|
euro_emission_class: toString(get(car, 'environmentEmissions.emissionClass', '')),
|
|
73
74
|
enviromental_sticker: toString(get(car, 'environmentEmissions.emissionSticker', '')),
|
|
74
75
|
};
|
|
@@ -171,7 +171,8 @@ export class AwinTransformer {
|
|
|
171
171
|
selector_energyEfficiencyClass_g: 'G',
|
|
172
172
|
selector_unknown: '',
|
|
173
173
|
};
|
|
174
|
-
|
|
174
|
+
// ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
|
|
175
|
+
const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
|
|
175
176
|
return get(available, energyClass);
|
|
176
177
|
}
|
|
177
178
|
|
|
@@ -140,7 +140,8 @@ export class CriteoTransformer {
|
|
|
140
140
|
selector_energyEfficiencyClass_g: 'G',
|
|
141
141
|
selector_unknown: 'default',
|
|
142
142
|
};
|
|
143
|
-
|
|
143
|
+
// ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
|
|
144
|
+
const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
|
|
144
145
|
return get(available, energyClass);
|
|
145
146
|
}
|
|
146
147
|
|
|
@@ -413,6 +413,9 @@ export class FbTransformer {
|
|
|
413
413
|
return get(car, 'state.isOnline', false) ? 'available' : 'not_available';
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
// ADV-187: no fabricated class for an unknown value — the field previously
|
|
417
|
+
// defaulted every unresolved car to 'G' (worst class), which is a
|
|
418
|
+
// false/misleading statement, not a "no data" state. Empty string here.
|
|
416
419
|
private getEnergyEfficiencyClass(car: DataSchemes.Car): string {
|
|
417
420
|
const available = {
|
|
418
421
|
selector_energyEfficiencyClass_appp: 'A+',
|
|
@@ -425,10 +428,10 @@ export class FbTransformer {
|
|
|
425
428
|
selector_energyEfficiencyClass_e: 'E',
|
|
426
429
|
selector_energyEfficiencyClass_f: 'F',
|
|
427
430
|
selector_energyEfficiencyClass_g: 'G',
|
|
428
|
-
selector_unknown: '
|
|
431
|
+
selector_unknown: '',
|
|
429
432
|
};
|
|
430
433
|
const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
|
|
431
|
-
return get(available, energyClass);
|
|
434
|
+
return get(available, energyClass, '');
|
|
432
435
|
}
|
|
433
436
|
|
|
434
437
|
private getMileage(car: DataSchemes.Car): number {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { get } from 'lodash';
|
|
2
|
+
import { DataSchemes } from '@bytebrand/car-schema';
|
|
3
|
+
|
|
4
|
+
// Google vehicle ads `engine`: gasoline, diesel, electric, hybrid, lpg, methane, natural_gas,
|
|
5
|
+
// plug-in_hybrid, other (https://support.google.com/merchants/answer/14156068). Any other value
|
|
6
|
+
// is rejected as "Missing attribute [engine]" — CarGas / NaturalGas did that for ~290 items.
|
|
7
|
+
const GOOGLE_ENGINE: Record<string, string> = {
|
|
8
|
+
selector_fuel_petrol: 'Gasoline',
|
|
9
|
+
selector_fuel_diesel: 'Diesel',
|
|
10
|
+
selector_fuel_electrical: 'Electric',
|
|
11
|
+
selector_fuel_hybridElectricalPetrol: 'Hybrid',
|
|
12
|
+
selector_fuel_hybridElectricalDiesel: 'Hybrid',
|
|
13
|
+
selector_fuel_carGas: 'lpg',
|
|
14
|
+
selector_fuel_naturalGas: 'natural_gas',
|
|
15
|
+
selector_fuel_ethanol: 'other',
|
|
16
|
+
selector_fuel_hydrogen: 'other',
|
|
17
|
+
selector_unknown: 'other',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const HYBRID_FUELS = ['selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'];
|
|
21
|
+
|
|
22
|
+
const positive = (value: unknown): number | undefined => {
|
|
23
|
+
const n = Number(value);
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isBev = (car: DataSchemes.Car): boolean => get(car, 'consumption.fuel') === 'selector_fuel_electrical';
|
|
28
|
+
const isPlugIn = (car: DataSchemes.Car): boolean =>
|
|
29
|
+
HYBRID_FUELS.includes(get(car, 'consumption.fuel')) && !!get(car, 'engineData.hybridPlugin');
|
|
30
|
+
|
|
31
|
+
export function googleEngine(car: DataSchemes.Car): string {
|
|
32
|
+
const engine = GOOGLE_ENGINE[get(car, 'consumption.fuel', 'selector_unknown')] || 'other';
|
|
33
|
+
return engine === 'Hybrid' && get(car, 'engineData.hybridPlugin') ? 'plug-in_hybrid' : engine;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// BEV: combined WLTP range (CoC 49.2). Plug-in: the EAER (`wltpTotalRange`, CoC 49.5) when present,
|
|
37
|
+
// else the range the dealer delivered. City ranges are never used; 0 counts as missing.
|
|
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
|
+
}
|
|
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;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Plug-in hybrids: Google and Pkw-EnVKV (CoC 49.4) want the WEIGHTED combined values for fuel,
|
|
61
|
+
// electricity and CO2, and the fuel consumption with a DISCHARGED battery separately. In CBD those
|
|
62
|
+
// are wltpWeightedCombined, wltpWeightedPowerCombined, wltpCo2 (weighted for a plug-in) and
|
|
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);
|
|
70
|
+
|
|
71
|
+
// Missing or 0 is omitted instead of sending "0l/100km". BEVs carry no fuel consumption
|
|
72
|
+
// (CBD mirrors kWh into consumptionCombined for them).
|
|
73
|
+
//
|
|
74
|
+
// Each attribute is picked from the first positive field of a list; the pick keeps the field so the
|
|
75
|
+
// publication gate (EnVKV C8) can tell whether the sent value was estimated by CBD.
|
|
76
|
+
interface Picked {
|
|
77
|
+
field: string;
|
|
78
|
+
value: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const firstPositive = (car: DataSchemes.Car, fields: string[]): Picked | undefined => {
|
|
82
|
+
for (const field of fields) {
|
|
83
|
+
const value = positive(get(car, field));
|
|
84
|
+
if (value) return { field, value };
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const pickFuelConsumption = (car: DataSchemes.Car): Picked | undefined => {
|
|
90
|
+
if (isBev(car)) return undefined;
|
|
91
|
+
if (isConfirmedPlugIn(car)) return firstPositive(car, ['consumption.wltpWeightedCombined', 'consumption.consumptionCombined']);
|
|
92
|
+
if (isPlugIn(car)) return firstPositive(car, ['consumption.consumptionCombined']); // unconfirmed plug-in: as before
|
|
93
|
+
// ICE / HEV: the WLTP combined value the VDP and EnVKV use (EnVKV stage D3), the legacy field as fallback
|
|
94
|
+
return firstPositive(car, ['consumption.wltpCombined', 'consumption.consumptionCombined']);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Confirmed plug-in: wltpCombined, no fallback (the legacy combined value is close to the weighted
|
|
98
|
+
// one, sending it here would repeat the weighted figure). Unconfirmed plug-in: the stored combined
|
|
99
|
+
// value, as before.
|
|
100
|
+
const pickFuelConsumptionDischarged = (car: DataSchemes.Car): Picked | undefined => {
|
|
101
|
+
if (!isPlugIn(car)) return undefined;
|
|
102
|
+
return isConfirmedPlugIn(car)
|
|
103
|
+
? firstPositive(car, ['consumption.wltpCombined'])
|
|
104
|
+
: firstPositive(car, ['consumption.consumptionCombined', 'consumption.wltpCombined']);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// BEV: combined consumption. Confirmed plug-in: weighted combined. Unconfirmed plug-in: the stored
|
|
108
|
+
// electric value, as before. Nothing for other cars.
|
|
109
|
+
const pickEnergyConsumption = (car: DataSchemes.Car): Picked | undefined => {
|
|
110
|
+
if (isConfirmedPlugIn(car)) return firstPositive(car, ['consumption.wltpWeightedPowerCombined']);
|
|
111
|
+
if (isBev(car) || isPlugIn(car)) return firstPositive(car, ['consumption.wltpPowerCombined', 'consumption.consumptionPowerCombined']);
|
|
112
|
+
return undefined;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// A BEV emits 0 g/km by definition (no field behind it); a confirmed plug-in and ICE / HEV send the
|
|
116
|
+
// WLTP value (weighted for the plug-in); an unconfirmed plug-in keeps the stored legacy value, as before.
|
|
117
|
+
const pickCo2 = (car: DataSchemes.Car): Picked | undefined => {
|
|
118
|
+
if (isBev(car)) return undefined;
|
|
119
|
+
return isPlugIn(car) && !isConfirmedPlugIn(car)
|
|
120
|
+
? firstPositive(car, ['environmentEmissions.co2'])
|
|
121
|
+
: firstPositive(car, ['environmentEmissions.wltpCo2', 'environmentEmissions.co2']);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function googleFuelConsumption(car: DataSchemes.Car): string | undefined {
|
|
125
|
+
const picked = pickFuelConsumption(car);
|
|
126
|
+
return picked ? `${picked.value}l/100km` : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function googleFuelConsumptionDischarged(car: DataSchemes.Car): string | undefined {
|
|
130
|
+
const picked = pickFuelConsumptionDischarged(car);
|
|
131
|
+
return picked ? `${picked.value}l/100km` : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function googleEnergyConsumption(car: DataSchemes.Car): string | undefined {
|
|
135
|
+
const picked = pickEnergyConsumption(car);
|
|
136
|
+
return picked ? `${picked.value}kWh/100km` : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A missing or 0 value is omitted.
|
|
140
|
+
export function googleCo2(car: DataSchemes.Car): string | undefined {
|
|
141
|
+
if (isBev(car)) return '0g/km';
|
|
142
|
+
const picked = pickCo2(car);
|
|
143
|
+
return picked ? `${picked.value}g/km` : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The EnVKV attributes of the Google item (keys of GoogleXmlItem).
|
|
147
|
+
export type GoogleEnvkvAttribute =
|
|
148
|
+
| 'fuel_consumption'
|
|
149
|
+
| 'fuel_consumption_discharged_battery'
|
|
150
|
+
| 'energy_consumption'
|
|
151
|
+
| 'electric_range'
|
|
152
|
+
| 'co2_emissions';
|
|
153
|
+
|
|
154
|
+
// For every EnVKV attribute the item sends: the car field its value came from.
|
|
155
|
+
export function googleEnvkvSourceFields(car: DataSchemes.Car): Partial<Record<GoogleEnvkvAttribute, string>> {
|
|
156
|
+
const picks: Record<GoogleEnvkvAttribute, { field: string } | undefined> = {
|
|
157
|
+
fuel_consumption: pickFuelConsumption(car),
|
|
158
|
+
fuel_consumption_discharged_battery: pickFuelConsumptionDischarged(car),
|
|
159
|
+
energy_consumption: pickEnergyConsumption(car),
|
|
160
|
+
electric_range: pickElectricRange(car),
|
|
161
|
+
co2_emissions: pickCo2(car),
|
|
162
|
+
};
|
|
163
|
+
const fields: Partial<Record<GoogleEnvkvAttribute, string>> = {};
|
|
164
|
+
for (const [attribute, picked] of Object.entries(picks)) {
|
|
165
|
+
if (picked) fields[attribute as GoogleEnvkvAttribute] = picked.field;
|
|
166
|
+
}
|
|
167
|
+
return fields;
|
|
168
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
// EnVKV C8: gate mode of the run and the attributes whose value CBD estimated (comma-separated);
|
|
31
|
+
// in enforce mode these attributes were left out of the item.
|
|
32
|
+
publicationGate: string | null;
|
|
33
|
+
estimatedAttributes: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const orNull = (value: unknown): string | null =>
|
|
37
|
+
value === undefined || value === null || value === '' ? null : String(value);
|
|
38
|
+
|
|
39
|
+
// "12345.5 EUR" -> 12345.5
|
|
40
|
+
const sentPrice = (price: string | undefined): number | null => {
|
|
41
|
+
const n = parseFloat(price);
|
|
42
|
+
return Number.isFinite(n) ? n : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// exportedAt is the run's start, shared by all rows of a run; partitionDay is its UTC day.
|
|
46
|
+
export function buildGoogleFeedItemRow(
|
|
47
|
+
car: DataSchemes.Car,
|
|
48
|
+
item: GoogleXmlItem,
|
|
49
|
+
runId: string | null,
|
|
50
|
+
exportedAt: Date,
|
|
51
|
+
gate: { mode: string; estimatedAttributes: string[] } | null = null,
|
|
52
|
+
): FeedItemRow {
|
|
53
|
+
const hybridPlugin = get(car, 'engineData.hybridPlugin');
|
|
54
|
+
return {
|
|
55
|
+
partitionDay: exportedAt.toISOString().slice(0, 10),
|
|
56
|
+
exportedAt: exportedAt.toISOString(),
|
|
57
|
+
feed: 'google',
|
|
58
|
+
runId: orNull(runId),
|
|
59
|
+
itemId: String(item.id),
|
|
60
|
+
vehicleId: orNull(get(car, 'metaData.vehicleId')),
|
|
61
|
+
make: orNull(item.brand),
|
|
62
|
+
model: orNull(item.model),
|
|
63
|
+
condition: orNull(item.condition),
|
|
64
|
+
fuel: orNull(get(car, 'consumption.fuel')),
|
|
65
|
+
hybridPlugin: isBoolean(hybridPlugin) ? hybridPlugin : null,
|
|
66
|
+
engine: orNull(item.engine),
|
|
67
|
+
electricRange: orNull(item.electric_range),
|
|
68
|
+
fuelConsumption: orNull(item.fuel_consumption),
|
|
69
|
+
fuelConsumptionDischarged: orNull(item.fuel_consumption_discharged_battery),
|
|
70
|
+
energyConsumption: orNull(item.energy_consumption),
|
|
71
|
+
co2Emissions: orNull(item.co2_emissions),
|
|
72
|
+
rangeSourceField: item.electric_range ? googleElectricRangeSource(car) : null,
|
|
73
|
+
price: sentPrice(item.price),
|
|
74
|
+
availability: orNull(item.availability),
|
|
75
|
+
publicationGate: gate ? gate.mode : null,
|
|
76
|
+
estimatedAttributes: gate && gate.estimatedAttributes.length ? gate.estimatedAttributes.join(',') : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -8,6 +8,14 @@ import { Injectable } from '@nestjs/common';
|
|
|
8
8
|
import { ConfigService, ImagesService, MappingService, TransformerService, UserService } from '../services';
|
|
9
9
|
import { GoogleXmlItem } from '../interfaces';
|
|
10
10
|
import { isValidVin } from '../utils';
|
|
11
|
+
import {
|
|
12
|
+
googleCo2,
|
|
13
|
+
googleElectricRange,
|
|
14
|
+
googleEnergyConsumption,
|
|
15
|
+
googleEngine,
|
|
16
|
+
googleFuelConsumption,
|
|
17
|
+
googleFuelConsumptionDischarged,
|
|
18
|
+
} from './google-feed-envkv';
|
|
11
19
|
import {
|
|
12
20
|
META_PUSH_PRIORITY_ALLOWED_VALUES,
|
|
13
21
|
META_PUSH_PRIORITY_STANDARD,
|
|
@@ -85,17 +93,17 @@ export class GoogleTransformer {
|
|
|
85
93
|
color: this.getColor(car),
|
|
86
94
|
vehicle_option: this.getExtrasField(car),
|
|
87
95
|
body_style: this.mapBodyStyle(car),
|
|
88
|
-
engine:
|
|
96
|
+
engine: googleEngine(car),
|
|
89
97
|
transmission: this.mapTransmission(car),
|
|
90
98
|
drive: this.mapDrive(car),
|
|
91
99
|
doors: this.getDoors(car),
|
|
92
100
|
capacity: this.getCapacity(car),
|
|
93
101
|
date_first_registered: this.getDateFirstRegistered(car),
|
|
94
|
-
fuel_consumption:
|
|
95
|
-
energy_consumption:
|
|
96
|
-
electric_range:
|
|
97
|
-
fuel_consumption_discharged_battery:
|
|
98
|
-
co2_emissions:
|
|
102
|
+
fuel_consumption: googleFuelConsumption(car),
|
|
103
|
+
energy_consumption: googleEnergyConsumption(car),
|
|
104
|
+
electric_range: googleElectricRange(car),
|
|
105
|
+
fuel_consumption_discharged_battery: googleFuelConsumptionDischarged(car),
|
|
106
|
+
co2_emissions: googleCo2(car),
|
|
99
107
|
description: this.processText((await this.getDescription(car))),
|
|
100
108
|
identifier_exists: 'yes',
|
|
101
109
|
availability: 'in_stock',
|
|
@@ -121,6 +129,8 @@ export class GoogleTransformer {
|
|
|
121
129
|
'certification',
|
|
122
130
|
'energy_consumption',
|
|
123
131
|
'electric_range',
|
|
132
|
+
'fuel_consumption',
|
|
133
|
+
'co2_emissions',
|
|
124
134
|
'fuel_consumption_discharged_battery',
|
|
125
135
|
'installment',
|
|
126
136
|
'body_style',
|
|
@@ -288,8 +298,15 @@ export class GoogleTransformer {
|
|
|
288
298
|
return value ? value.replace(/\+/g, '') : '';
|
|
289
299
|
}
|
|
290
300
|
|
|
301
|
+
// ADV-187: the canonical (legally defined, CO2-derived) class lives in
|
|
302
|
+
// wltpEnergyEfficiencyClass — same field the website, PDF exposé and the
|
|
303
|
+
// Facebook feed use. The legacy NEFZ `energyEfficiencyClass` is a different
|
|
304
|
+
// (mass-relative) scale and is NOT validated against CO2, so it must not
|
|
305
|
+
// feed the BMWK Vehicle_CO2_Class certification. No fallback to the legacy
|
|
306
|
+
// field on purpose: an unknown WLTP class omits the certification instead of
|
|
307
|
+
// publishing an unvalidated one.
|
|
291
308
|
private getEnergyClass(car: DataSchemes.Car): string {
|
|
292
|
-
return this.mapEnergyClass(get(car, 'environmentEmissions.
|
|
309
|
+
return this.mapEnergyClass(get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown'));
|
|
293
310
|
}
|
|
294
311
|
|
|
295
312
|
private getDischargedEnergyClass(car: DataSchemes.Car): string {
|
|
@@ -322,30 +339,8 @@ export class GoogleTransformer {
|
|
|
322
339
|
return certifications.length ? certifications : undefined;
|
|
323
340
|
}
|
|
324
341
|
|
|
325
|
-
private getEnergyConsumption(car: DataSchemes.Car): string | undefined {
|
|
326
|
-
const fuel = get(car, 'consumption.fuel');
|
|
327
|
-
const isElectricOrHybrid =
|
|
328
|
-
['selector_fuel_electrical', 'selector_fuel_hybridElectricalPetrol', 'selector_fuel_hybridElectricalDiesel'].includes(fuel) ||
|
|
329
|
-
!!get(car, 'engineData.hybridPlugin');
|
|
330
|
-
if (!isElectricOrHybrid) {
|
|
331
|
-
return undefined;
|
|
332
|
-
}
|
|
333
|
-
const power = get(car, 'consumption.wltpPowerCombined') || get(car, 'consumption.consumptionPowerCombined');
|
|
334
|
-
return power ? `${power}kWh/100km` : undefined;
|
|
335
|
-
}
|
|
336
342
|
|
|
337
|
-
private getElectricRange(car: DataSchemes.Car): string | undefined {
|
|
338
|
-
const range = get(car, 'battery.batteryRangeElectric') || get(car, 'battery.wltpRange');
|
|
339
|
-
return range ? `${range}km` : undefined;
|
|
340
|
-
}
|
|
341
343
|
|
|
342
|
-
private getFuelConsumptionDischargedBattery(car: DataSchemes.Car): string | undefined {
|
|
343
|
-
if (!get(car, 'engineData.hybridPlugin')) {
|
|
344
|
-
return undefined;
|
|
345
|
-
}
|
|
346
|
-
const consumption = get(car, 'consumption.consumptionCombined') || get(car, 'consumption.wltpCombined');
|
|
347
|
-
return consumption ? `${consumption}l/100km` : undefined;
|
|
348
|
-
}
|
|
349
344
|
|
|
350
345
|
private getInstallment(car: DataSchemes.Car) {
|
|
351
346
|
const monthlyInstallment = round(get(car, 'rate.monthlyInstallment', null));
|
|
@@ -379,27 +374,6 @@ export class GoogleTransformer {
|
|
|
379
374
|
}
|
|
380
375
|
}
|
|
381
376
|
|
|
382
|
-
private mapFuel(car: DataSchemes.Car): string {
|
|
383
|
-
const available = {
|
|
384
|
-
selector_fuel_petrol: 'Gasoline',
|
|
385
|
-
selector_fuel_diesel: 'Diesel',
|
|
386
|
-
selector_fuel_electrical: 'Electric',
|
|
387
|
-
selector_fuel_hybridElectricalPetrol: 'Hybrid',
|
|
388
|
-
selector_fuel_hybridElectricalDiesel: 'Hybrid',
|
|
389
|
-
selector_fuel_carGas: 'CarGas',
|
|
390
|
-
selector_fuel_naturalGas: 'NaturalGas',
|
|
391
|
-
selector_fuel_ethanol: 'Ethanol',
|
|
392
|
-
selector_fuel_hydrogen: 'Hydrogen',
|
|
393
|
-
selector_unknown: 'Other',
|
|
394
|
-
};
|
|
395
|
-
const fuel = get(car, 'consumption.fuel', 'selector_unknown');
|
|
396
|
-
let engine = get(available, fuel, 'Other')
|
|
397
|
-
const hybridPlugin = car?.engineData?.hybridPlugin;
|
|
398
|
-
if (engine === 'Hybrid' && hybridPlugin)
|
|
399
|
-
engine = 'plug-in_hybrid';
|
|
400
|
-
|
|
401
|
-
return engine;
|
|
402
|
-
}
|
|
403
377
|
|
|
404
378
|
private mapBodyStyle(car: DataSchemes.Car): string {
|
|
405
379
|
const available = {
|
|
@@ -258,8 +258,11 @@ export class MobileTransformer {
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
// ADV-187: export the canonical CO2-derived WLTP class (not the unvalidated
|
|
262
|
+
// legacy NEFZ class) and never invent one — an unknown class is exported
|
|
263
|
+
// empty instead of the former hard-coded 'C'.
|
|
261
264
|
private getEnergyEfficiencyClass(car: DataSchemes.Car): string {
|
|
262
|
-
const energyEfficiencyClass = get(car, 'environmentEmissions.
|
|
265
|
+
const energyEfficiencyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
|
|
263
266
|
switch (energyEfficiencyClass) {
|
|
264
267
|
case 'selector_energyEfficiencyClass_appp': {
|
|
265
268
|
return 'A+++';
|
|
@@ -271,8 +274,9 @@ export class MobileTransformer {
|
|
|
271
274
|
return 'A+';
|
|
272
275
|
}
|
|
273
276
|
case 'selector_unknown':
|
|
277
|
+
case undefined:
|
|
274
278
|
case null: {
|
|
275
|
-
return '
|
|
279
|
+
return '';
|
|
276
280
|
}
|
|
277
281
|
default: {
|
|
278
282
|
return energyEfficiencyClass.slice(-1).toUpperCase();
|
|
@@ -148,7 +148,8 @@ export class RubrikkTransformer {
|
|
|
148
148
|
selector_energyEfficiencyClass_g: 'G',
|
|
149
149
|
selector_unknown: 'default',
|
|
150
150
|
};
|
|
151
|
-
|
|
151
|
+
// ADV-187: canonical CO2-derived WLTP class, not the unvalidated legacy NEFZ class.
|
|
152
|
+
const energyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
|
|
152
153
|
return get(available, energyClass);
|
|
153
154
|
}
|
|
154
155
|
|
|
@@ -364,8 +364,11 @@ export class SantanderTransformer {
|
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
// ADV-187: export the canonical CO2-derived WLTP class (not the unvalidated
|
|
368
|
+
// legacy NEFZ class) and never invent one — an unknown class is exported
|
|
369
|
+
// empty instead of the former hard-coded 'C'.
|
|
367
370
|
private getEnergyEfficiencyClass(car: DataSchemes.Car): string {
|
|
368
|
-
const energyEfficiencyClass = get(car, 'environmentEmissions.
|
|
371
|
+
const energyEfficiencyClass = get(car, 'environmentEmissions.wltpEnergyEfficiencyClass', 'selector_unknown');
|
|
369
372
|
switch (energyEfficiencyClass) {
|
|
370
373
|
case 'selector_energyEfficiencyClass_appp': {
|
|
371
374
|
return 'A+++';
|
|
@@ -377,8 +380,9 @@ export class SantanderTransformer {
|
|
|
377
380
|
return 'A+';
|
|
378
381
|
}
|
|
379
382
|
case 'selector_unknown':
|
|
383
|
+
case undefined:
|
|
380
384
|
case null: {
|
|
381
|
-
return '
|
|
385
|
+
return '';
|
|
382
386
|
}
|
|
383
387
|
default: {
|
|
384
388
|
return energyEfficiencyClass.slice(-1).toUpperCase();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Run: npm test (ts-node, no test framework needed)
|
|
2
|
+
import * as assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
googleCo2,
|
|
5
|
+
googleElectricRange,
|
|
6
|
+
googleEnergyConsumption,
|
|
7
|
+
googleEngine,
|
|
8
|
+
googleFuelConsumption,
|
|
9
|
+
googleFuelConsumptionDischarged,
|
|
10
|
+
} from '../src/transformers/google-feed-envkv';
|
|
11
|
+
|
|
12
|
+
const car = (fuel: string, extra: Record<string, unknown> = {}): any => ({
|
|
13
|
+
consumption: { fuel, ...(extra.consumption as object) },
|
|
14
|
+
engineData: { hybridPlugin: extra.hybridPlugin },
|
|
15
|
+
battery: extra.battery || {},
|
|
16
|
+
environmentEmissions: extra.environmentEmissions || {},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const cases: Array<[string, () => void]> = [
|
|
20
|
+
['LPG and CNG get the values Google accepts', () => {
|
|
21
|
+
assert.equal(googleEngine(car('selector_fuel_carGas')), 'lpg');
|
|
22
|
+
assert.equal(googleEngine(car('selector_fuel_naturalGas')), 'natural_gas');
|
|
23
|
+
}],
|
|
24
|
+
['ethanol, hydrogen and unknown fuels become other', () => {
|
|
25
|
+
assert.equal(googleEngine(car('selector_fuel_ethanol')), 'other');
|
|
26
|
+
assert.equal(googleEngine(car('selector_fuel_hydrogen')), 'other');
|
|
27
|
+
assert.equal(googleEngine(car('selector_fuel_somethingNew')), 'other');
|
|
28
|
+
}],
|
|
29
|
+
['existing engine values are unchanged', () => {
|
|
30
|
+
assert.equal(googleEngine(car('selector_fuel_petrol')), 'Gasoline');
|
|
31
|
+
assert.equal(googleEngine(car('selector_fuel_diesel')), 'Diesel');
|
|
32
|
+
assert.equal(googleEngine(car('selector_fuel_electrical')), 'Electric');
|
|
33
|
+
assert.equal(googleEngine(car('selector_fuel_hybridElectricalPetrol')), 'Hybrid');
|
|
34
|
+
assert.equal(googleEngine(car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true })), 'plug-in_hybrid');
|
|
35
|
+
}],
|
|
36
|
+
['BEV range is the combined WLTP range, never the city range', () => {
|
|
37
|
+
assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRange: 451, wltpRangeCity: 590 } })), '451km');
|
|
38
|
+
assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRangeCity: 590 } })), undefined);
|
|
39
|
+
}],
|
|
40
|
+
['plug-in range prefers the EAER, then the delivered range', () => {
|
|
41
|
+
const phev = (battery: object) => car('selector_fuel_hybridElectricalPetrol', { hybridPlugin: true, battery });
|
|
42
|
+
assert.equal(googleElectricRange(phev({ wltpTotalRange: 62, wltpRange: 59 })), '62km');
|
|
43
|
+
assert.equal(googleElectricRange(phev({ wltpTotalRange: 0, wltpRange: 59 })), '59km');
|
|
44
|
+
assert.equal(googleElectricRange(phev({ wltpTotalRangeCity: 70 })), undefined);
|
|
45
|
+
}],
|
|
46
|
+
['a range of 0 counts as missing', () => {
|
|
47
|
+
assert.equal(googleElectricRange(car('selector_fuel_electrical', { battery: { wltpRange: 0 } })), undefined);
|
|
48
|
+
}],
|
|
49
|
+
['no range for combustion cars or non-plug-in hybrids', () => {
|
|
50
|
+
assert.equal(googleElectricRange(car('selector_fuel_petrol', { battery: { wltpRange: 50 } })), undefined);
|
|
51
|
+
assert.equal(googleElectricRange(car('selector_fuel_hybridElectricalPetrol', { battery: { wltpRange: 50 } })), undefined);
|
|
52
|
+
}],
|
|
53
|
+
['missing or 0 fuel consumption is omitted, never "0l/100km"', () => {
|
|
54
|
+
assert.equal(googleFuelConsumption(car('selector_fuel_petrol', { consumption: { consumptionCombined: 0 } })), undefined);
|
|
55
|
+
assert.equal(googleFuelConsumption(car('selector_fuel_petrol')), undefined);
|
|
56
|
+
assert.equal(googleFuelConsumption(car('selector_fuel_petrol', { consumption: { consumptionCombined: 6.3 } })), '6.3l/100km');
|
|
57
|
+
}],
|
|
58
|
+
['BEVs send no fuel consumption (CBD mirrors kWh into it)', () => {
|
|
59
|
+
assert.equal(googleFuelConsumption(car('selector_fuel_electrical', { consumption: { consumptionCombined: 18 } })), undefined);
|
|
60
|
+
}],
|
|
61
|
+
['CO2: BEV is 0 g/km, other cars omit a missing or 0 value', () => {
|
|
62
|
+
assert.equal(googleCo2(car('selector_fuel_electrical')), '0g/km');
|
|
63
|
+
assert.equal(googleCo2(car('selector_fuel_petrol', { environmentEmissions: { co2: 0 } })), undefined);
|
|
64
|
+
assert.equal(googleCo2(car('selector_fuel_petrol', { environmentEmissions: { co2: 144 } })), '144g/km');
|
|
65
|
+
}],
|
|
66
|
+
['plug-in: weighted fuel, discharged fuel, weighted electricity and weighted CO2', () => {
|
|
67
|
+
const phev = car('selector_fuel_hybridElectricalPetrol', {
|
|
68
|
+
hybridPlugin: true,
|
|
69
|
+
battery: { wltpTotalRange: 62 },
|
|
70
|
+
consumption: { consumptionCombined: 1.4, wltpCombined: 6.3, wltpWeightedCombined: 1.2, wltpPowerCombined: 19.3, wltpWeightedPowerCombined: 17.3 },
|
|
71
|
+
environmentEmissions: { co2: 121, wltpCo2: 28 },
|
|
72
|
+
});
|
|
73
|
+
assert.equal(googleFuelConsumption(phev), '1.2l/100km');
|
|
74
|
+
assert.equal(googleFuelConsumptionDischarged(phev), '6.3l/100km');
|
|
75
|
+
assert.equal(googleEnergyConsumption(phev), '17.3kWh/100km');
|
|
76
|
+
assert.equal(googleCo2(phev), '28g/km');
|
|
77
|
+
}],
|
|
78
|
+
['plug-in without weighted values falls back to the legacy combined ones, never the pure-electric value', () => {
|
|
79
|
+
const phev = car('selector_fuel_hybridElectricalPetrol', {
|
|
80
|
+
hybridPlugin: true,
|
|
81
|
+
battery: { wltpRange: 59 },
|
|
82
|
+
consumption: { consumptionCombined: 1.4, wltpPowerCombined: 19.3 },
|
|
83
|
+
environmentEmissions: { co2: 32 },
|
|
84
|
+
});
|
|
85
|
+
assert.equal(googleFuelConsumption(phev), '1.4l/100km');
|
|
86
|
+
assert.equal(googleFuelConsumptionDischarged(phev), undefined);
|
|
87
|
+
assert.equal(googleEnergyConsumption(phev), undefined);
|
|
88
|
+
assert.equal(googleCo2(phev), '32g/km');
|
|
89
|
+
}],
|
|
90
|
+
['non-plug-in hybrids and combustion cars send the WLTP values (legacy as fallback) and no electricity', () => {
|
|
91
|
+
const hev = car('selector_fuel_hybridElectricalPetrol', {
|
|
92
|
+
consumption: { consumptionCombined: 5.1, wltpCombined: 5.3, wltpWeightedCombined: 2.3, wltpWeightedPowerCombined: 21.5 },
|
|
93
|
+
environmentEmissions: { co2: 116, wltpCo2: 119 },
|
|
94
|
+
});
|
|
95
|
+
assert.equal(googleFuelConsumption(hev), '5.3l/100km');
|
|
96
|
+
assert.equal(googleFuelConsumptionDischarged(hev), undefined);
|
|
97
|
+
assert.equal(googleEnergyConsumption(hev), undefined);
|
|
98
|
+
assert.equal(googleCo2(hev), '119g/km');
|
|
99
|
+
// D3: Mercedes GLC 200 sent 146.7 g (legacy) while CBD / VDP / EnVKV hold the dealer's WLTP 138 g
|
|
100
|
+
const glc = car('selector_fuel_diesel', { consumption: { consumptionCombined: 5.6, wltpCombined: 5.2 }, environmentEmissions: { co2: 146.7, wltpCo2: 138 } });
|
|
101
|
+
assert.equal(googleCo2(glc), '138g/km');
|
|
102
|
+
assert.equal(googleFuelConsumption(glc), '5.2l/100km');
|
|
103
|
+
assert.equal(googleCo2(car('selector_fuel_diesel', { environmentEmissions: { co2: 150 } })), '150g/km');
|
|
104
|
+
}],
|
|
105
|
+
['a plug-in flag without an electric range keeps the stored values (ADV-124 flags every hybrid)', () => {
|
|
106
|
+
const flagged = car('selector_fuel_hybridElectricalPetrol', {
|
|
107
|
+
hybridPlugin: true,
|
|
108
|
+
consumption: { consumptionCombined: 5.1, wltpCombined: 5.3, wltpWeightedCombined: 2.3, wltpPowerCombined: 21.5, wltpWeightedPowerCombined: 21.5 },
|
|
109
|
+
environmentEmissions: { co2: 116, wltpCo2: 116, wltpCo2Discharged: 195 },
|
|
110
|
+
});
|
|
111
|
+
assert.equal(googleFuelConsumption(flagged), '5.1l/100km');
|
|
112
|
+
assert.equal(googleFuelConsumptionDischarged(flagged), '5.1l/100km');
|
|
113
|
+
assert.equal(googleEnergyConsumption(flagged), '21.5kWh/100km');
|
|
114
|
+
assert.equal(googleCo2(flagged), '116g/km');
|
|
115
|
+
}],
|
|
116
|
+
['BEV energy consumption is the combined value', () => {
|
|
117
|
+
assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { wltpPowerCombined: 16.5 } })), '16.5kWh/100km');
|
|
118
|
+
assert.equal(googleEnergyConsumption(car('selector_fuel_electrical', { consumption: { consumptionPowerCombined: 18 } })), '18kWh/100km');
|
|
119
|
+
}],
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
let failed = 0;
|
|
123
|
+
for (const [name, fn] of cases) {
|
|
124
|
+
try {
|
|
125
|
+
fn();
|
|
126
|
+
console.log(`ok ${name}`);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
failed++;
|
|
129
|
+
console.log(`FAIL ${name}\n ${(e as Error).message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (failed) process.exit(1);
|