@bytebrand/export-cars 1.26.26 → 1.26.28

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 +28 -0
  2. package/dist/configs/config.d.ts +2 -1
  3. package/dist/configs/config.js +1 -0
  4. package/dist/configs/config.js.map +1 -1
  5. package/dist/configs/google.config.js +16 -0
  6. package/dist/configs/google.config.js.map +1 -1
  7. package/dist/exporters/google.exporter.js +12 -2
  8. package/dist/exporters/google.exporter.js.map +1 -1
  9. package/dist/interfaces/params.interface.d.ts +10 -0
  10. package/dist/services/publication-gate.d.ts +15 -0
  11. package/dist/services/publication-gate.js +107 -0
  12. package/dist/services/publication-gate.js.map +1 -0
  13. package/dist/transformers/google-feed-envkv.d.ts +2 -0
  14. package/dist/transformers/google-feed-envkv.js +62 -24
  15. package/dist/transformers/google-feed-envkv.js.map +1 -1
  16. package/dist/transformers/google-feed-item-row.d.ts +6 -1
  17. package/dist/transformers/google-feed-item-row.js +3 -1
  18. package/dist/transformers/google-feed-item-row.js.map +1 -1
  19. package/dist/tsconfig.build.tsbuildinfo +1 -1
  20. package/package.json +2 -2
  21. package/schema/feed-items.json +12 -0
  22. package/src/configs/config.ts +3 -1
  23. package/src/configs/google.config.ts +41 -26
  24. package/src/export.command.ts +38 -38
  25. package/src/exporters/google.exporter.ts +67 -56
  26. package/src/interfaces/params.interface.ts +43 -29
  27. package/src/services/feed-items-bigquery.writer.ts +101 -101
  28. package/src/services/publication-gate.ts +119 -0
  29. package/src/transformers/google-feed-envkv.ts +81 -25
  30. package/src/transformers/google-feed-item-row.ts +78 -71
  31. package/test/google-feed-envkv.test.ts +8 -3
  32. package/test/google-feed-item-row.test.ts +135 -121
  33. package/test/publication-gate.test.ts +86 -0
@@ -0,0 +1,119 @@
1
+ import { join } from 'path';
2
+ import { get } from 'lodash';
3
+ import { BigQueryLogger } from '@bytebrand/bigquery-logger';
4
+ import { DataSchemes } from '@bytebrand/car-schema';
5
+ import { PublicationGateConfig } from '../interfaces/params.interface';
6
+ import { GoogleEnvkvAttribute, googleEnvkvSourceFields } from '../transformers/google-feed-envkv';
7
+
8
+ // EnVKV stage C8 — publication gate for estimated EnVKV values in the Google feed.
9
+ //
10
+ // No car field says whether a value was delivered or estimated; car-basic-data writes every change it
11
+ // makes to the enrichment audit log (BigQuery). At the start of the export the gate reads the latest
12
+ // log row per car and field; a field counts as estimated when that row came from an estimate source
13
+ // and its final value is still the value stored on the car (a later import with a delivered value
14
+ // replaces it without a log row, so the stored value no longer matches).
15
+ //
16
+ // off no BigQuery call, the feed as before (default)
17
+ // audit the feed as before, the estimated attributes are recorded in the feed items table
18
+ // enforce estimated attributes are left out of the item (and recorded)
19
+
20
+ // finalValueSource values of CBD's enrichment log that are estimates: the CO2 estimator (EEA register /
21
+ // model), the make-model template sheet and the global defaults.
22
+ export const ESTIMATE_SOURCES = ['co2-estimate', 'mmm', 'globalDefault'];
23
+
24
+ // Every car field a Google EnVKV attribute can be taken from (google-feed-envkv picks).
25
+ export const GATED_FIELDS = [
26
+ 'consumption.wltpWeightedCombined',
27
+ 'consumption.wltpCombined',
28
+ 'consumption.consumptionCombined',
29
+ 'consumption.wltpWeightedPowerCombined',
30
+ 'consumption.wltpPowerCombined',
31
+ 'consumption.consumptionPowerCombined',
32
+ 'environmentEmissions.wltpCo2',
33
+ 'environmentEmissions.co2',
34
+ 'battery.wltpTotalRange',
35
+ 'battery.wltpRange',
36
+ 'battery.batteryRangeElectric',
37
+ ];
38
+
39
+ // '7.10' and 7.1 are the same value; non-numeric values compare as strings.
40
+ const sameValue = (logged: string, stored: unknown): boolean => {
41
+ if (stored === undefined || stored === null) return false;
42
+ const a = Number(logged);
43
+ const b = Number(stored);
44
+ if (Number.isFinite(a) && Number.isFinite(b)) return Math.abs(a - b) < 1e-9;
45
+ return logged === String(stored);
46
+ };
47
+
48
+ export class PublicationGate {
49
+ // estimates: `${carId}|${field}` -> final value of the latest estimate row
50
+ constructor(readonly mode: 'off' | 'audit' | 'enforce', private estimates: Map<string, string>) {}
51
+
52
+ static off(): PublicationGate {
53
+ return new PublicationGate('off', new Map());
54
+ }
55
+
56
+ get size(): number {
57
+ return this.estimates.size;
58
+ }
59
+
60
+ // The attributes of this car whose value comes from an estimated field, in a fixed order.
61
+ estimatedAttributes(car: DataSchemes.Car): GoogleEnvkvAttribute[] {
62
+ if (this.mode === 'off') return [];
63
+ const carId = String(get(car, '_id', ''));
64
+ const attributes: GoogleEnvkvAttribute[] = [];
65
+ for (const [attribute, field] of Object.entries(googleEnvkvSourceFields(car))) {
66
+ const logged = this.estimates.get(`${carId}|${field}`);
67
+ if (logged !== undefined && sameValue(logged, get(car, field))) attributes.push(attribute as GoogleEnvkvAttribute);
68
+ }
69
+ return attributes;
70
+ }
71
+
72
+ // enforce: drops the estimated attributes from the item. Returns the item for chaining.
73
+ apply<T extends Partial<Record<GoogleEnvkvAttribute, string>>>(item: T, attributes: GoogleEnvkvAttribute[]): T {
74
+ if (this.mode !== 'enforce') return item;
75
+ for (const attribute of attributes) delete item[attribute];
76
+ return item;
77
+ }
78
+ }
79
+
80
+ class EnrichmentLogReader extends BigQueryLogger {
81
+ async latestEstimates(table: string): Promise<Map<string, string>> {
82
+ const query = `
83
+ SELECT carId, fieldName, finalValue
84
+ FROM (
85
+ SELECT carId, fieldName, finalValue, finalValueSource,
86
+ ROW_NUMBER() OVER (PARTITION BY carId, fieldName ORDER BY timestamp DESC) AS rn
87
+ FROM \`${table}\`
88
+ WHERE fieldName IN UNNEST(@fields)
89
+ )
90
+ WHERE rn = 1 AND finalValueSource IN UNNEST(@sources) AND finalValue IS NOT NULL`;
91
+ const [rows] = await this.client.query({ query, params: { fields: GATED_FIELDS, sources: ESTIMATE_SOURCES } });
92
+ const estimates = new Map<string, string>();
93
+ for (const row of rows) estimates.set(`${row.carId}|${row.fieldName}`, String(row.finalValue));
94
+ return estimates;
95
+ }
96
+ }
97
+
98
+ // Reads the estimates once per run. audit: a failure only switches the gate off. enforce: a failure
99
+ // fails the export, so the last published feed stays in place instead of a feed with unchecked estimates.
100
+ export async function loadPublicationGate(config: PublicationGateConfig | undefined): Promise<PublicationGate> {
101
+ if (!config || config.mode === 'off') return PublicationGate.off();
102
+ const table = `${config.project}.${config.dataset}.${config.table}`;
103
+ try {
104
+ const credentials = require(join(process.cwd(), 'credentials/google-cloud-project-id.json'));
105
+ const reader = new EnrichmentLogReader(credentials, {
106
+ BigQueryProject: config.project,
107
+ BigQueryDataset: config.dataset,
108
+ BigQueryLogTable: config.table,
109
+ });
110
+ const estimates = await reader.latestEstimates(table);
111
+ console.log(`Publication gate ${config.mode}: ${estimates.size} estimated car fields from ${table}`);
112
+ return new PublicationGate(config.mode, estimates);
113
+ } catch (e) {
114
+ const message = String(e?.message || e).split('\n')[0];
115
+ if (config.mode === 'enforce') throw new Error(`Publication gate: cannot read ${table}: ${message}`);
116
+ console.error(`Publication gate disabled for this run: cannot read ${table}: ${message}`);
117
+ return PublicationGate.off();
118
+ }
119
+ }
@@ -70,43 +70,99 @@ const isConfirmedPlugIn = (car: DataSchemes.Car): boolean => isPlugIn(car) && !!
70
70
 
71
71
  // Missing or 0 is omitted instead of sending "0l/100km". BEVs carry no fuel consumption
72
72
  // (CBD mirrors kWh into consumptionCombined for them).
73
- export function googleFuelConsumption(car: DataSchemes.Car): string | undefined {
74
- if (isBev(car)) return undefined;
75
- const value = isConfirmedPlugIn(car)
76
- ? positive(get(car, 'consumption.wltpWeightedCombined')) ?? positive(get(car, 'consumption.consumptionCombined'))
77
- : positive(get(car, 'consumption.consumptionCombined'));
78
- return value ? `${value}l/100km` : undefined;
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
79
  }
80
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
+
81
97
  // Confirmed plug-in: wltpCombined, no fallback (the legacy combined value is close to the weighted
82
98
  // one, sending it here would repeat the weighted figure). Unconfirmed plug-in: the stored combined
83
99
  // value, as before.
84
- export function googleFuelConsumptionDischarged(car: DataSchemes.Car): string | undefined {
100
+ const pickFuelConsumptionDischarged = (car: DataSchemes.Car): Picked | undefined => {
85
101
  if (!isPlugIn(car)) return undefined;
86
- const value = isConfirmedPlugIn(car)
87
- ? positive(get(car, 'consumption.wltpCombined'))
88
- : positive(get(car, 'consumption.consumptionCombined')) ?? positive(get(car, 'consumption.wltpCombined'));
89
- return value ? `${value}l/100km` : undefined;
90
- }
102
+ return isConfirmedPlugIn(car)
103
+ ? firstPositive(car, ['consumption.wltpCombined'])
104
+ : firstPositive(car, ['consumption.consumptionCombined', 'consumption.wltpCombined']);
105
+ };
91
106
 
92
107
  // BEV: combined consumption. Confirmed plug-in: weighted combined. Unconfirmed plug-in: the stored
93
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
+
94
134
  export function googleEnergyConsumption(car: DataSchemes.Car): string | undefined {
95
- let value: number | undefined;
96
- if (isConfirmedPlugIn(car)) {
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'));
100
- }
101
- return value ? `${value}kWh/100km` : undefined;
135
+ const picked = pickEnergyConsumption(car);
136
+ return picked ? `${picked.value}kWh/100km` : undefined;
102
137
  }
103
138
 
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.
139
+ // A missing or 0 value is omitted.
106
140
  export function googleCo2(car: DataSchemes.Car): string | undefined {
107
141
  if (isBev(car)) return '0g/km';
108
- const value = isConfirmedPlugIn(car)
109
- ? positive(get(car, 'environmentEmissions.wltpCo2')) ?? positive(get(car, 'environmentEmissions.co2'))
110
- : positive(get(car, 'environmentEmissions.co2'));
111
- return value ? `${value}g/km` : undefined;
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;
112
168
  }
@@ -1,71 +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
- }
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
- }
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
+ }
@@ -87,15 +87,20 @@ const cases: Array<[string, () => void]> = [
87
87
  assert.equal(googleEnergyConsumption(phev), undefined);
88
88
  assert.equal(googleCo2(phev), '32g/km');
89
89
  }],
90
- ['non-plug-in hybrids and combustion cars keep the stored values and send no electricity', () => {
90
+ ['non-plug-in hybrids and combustion cars send the WLTP values (legacy as fallback) and no electricity', () => {
91
91
  const hev = car('selector_fuel_hybridElectricalPetrol', {
92
92
  consumption: { consumptionCombined: 5.1, wltpCombined: 5.3, wltpWeightedCombined: 2.3, wltpWeightedPowerCombined: 21.5 },
93
93
  environmentEmissions: { co2: 116, wltpCo2: 119 },
94
94
  });
95
- assert.equal(googleFuelConsumption(hev), '5.1l/100km');
95
+ assert.equal(googleFuelConsumption(hev), '5.3l/100km');
96
96
  assert.equal(googleFuelConsumptionDischarged(hev), undefined);
97
97
  assert.equal(googleEnergyConsumption(hev), undefined);
98
- assert.equal(googleCo2(hev), '116g/km');
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');
99
104
  }],
100
105
  ['a plug-in flag without an electric range keeps the stored values (ADV-124 flags every hybrid)', () => {
101
106
  const flagged = car('selector_fuel_hybridElectricalPetrol', {