Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f440840b |
@@ -7,8 +7,10 @@ on:
|
||||
paths:
|
||||
- "fastlane/**"
|
||||
- "ios/**"
|
||||
- "android/**"
|
||||
- "scripts/mobile/**"
|
||||
- "tests/node/app-store-connect.test.mjs"
|
||||
- "tests/node/verify-android-target-sdk.test.mjs"
|
||||
- ".github/workflows/app-store-readiness.yml"
|
||||
- "Gemfile*"
|
||||
workflow_dispatch:
|
||||
@@ -73,9 +75,15 @@ jobs:
|
||||
- name: Validate native mobile permissions
|
||||
run: node scripts/mobile/check-permissions.mjs
|
||||
|
||||
- name: Verify Android target SDK meets Play Store policy (TRU-141)
|
||||
run: node scripts/mobile/verify-android-target-sdk.mjs
|
||||
|
||||
- name: Test App Store Connect automation
|
||||
run: node --test tests/node/app-store-connect.test.mjs
|
||||
|
||||
- name: Test Android target SDK guard
|
||||
run: node --test tests/node/verify-android-target-sdk.test.mjs
|
||||
|
||||
- name: Validate Fastlane configuration
|
||||
run: bundle exec fastlane lanes
|
||||
|
||||
@@ -84,5 +92,7 @@ jobs:
|
||||
node --check scripts/mobile/validate-app-store.mjs
|
||||
node --check scripts/mobile/app-store-connect.mjs
|
||||
node --check scripts/mobile/create-ios-release-manifest.mjs
|
||||
node --check scripts/mobile/verify-android-target-sdk.mjs
|
||||
node --check tests/node/app-store-connect.test.mjs
|
||||
node --check tests/node/verify-android-target-sdk.test.mjs
|
||||
node scripts/mobile/app-store-connect.mjs self-test-jwt
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// verify-android-target-sdk.mjs
|
||||
//
|
||||
// Verifies the Android build configuration meets the Google Play Store
|
||||
// target-API policy. As of Aug 2026, new apps and updates MUST target
|
||||
// API 36 (Android 16, "Baklava") or newer, or the build is rejected
|
||||
// at upload time with the warning:
|
||||
//
|
||||
// > Appen skal være målrettet mod Android 16 (API-niveau 36) eller nyere
|
||||
// > Løs problemet inden den 31. aug. (om 14 dage)
|
||||
//
|
||||
// We enforce this in CI so a regression in android/variables.gradle
|
||||
// can't reintroduce the Play Console warning on the next release.
|
||||
//
|
||||
// Tracking: TRU-141 ("Android: ensure app targets API 36 (Android 16)
|
||||
// per Play Store policy").
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 — all checks passed
|
||||
// 1 — one or more checks failed
|
||||
// 2 — the variables file could not be read
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { cwd, exit } from "node:process";
|
||||
|
||||
const projectRoot = cwd();
|
||||
const variablesPath = join(projectRoot, "android/variables.gradle");
|
||||
|
||||
// The Google Play Store target-API floor for new apps and updates.
|
||||
// See: https://support.google.com/googleplay/android-developer/answer/11926878
|
||||
// 36 = Android 16 (Baklava). Bump in lockstep with the next Play
|
||||
// Store deadline (typically one year after a new Android release).
|
||||
const REQUIRED_TARGET_SDK = 36;
|
||||
const REQUIRED_COMPILE_SDK = 36;
|
||||
// minSdkVersion is not policy-mandated; 24 (Android 7.0) is the floor
|
||||
// Play Store currently accepts and is well below the app's own target.
|
||||
const MIN_ACCEPTABLE_MIN_SDK = 24;
|
||||
|
||||
const SDK_NAMES = {
|
||||
24: "7.0 Nougat",
|
||||
25: "7.1 Nougat",
|
||||
26: "8.0 Oreo",
|
||||
27: "8.1 Oreo",
|
||||
28: "9.0 Pie",
|
||||
29: "10",
|
||||
30: "11",
|
||||
31: "12",
|
||||
32: "12L",
|
||||
33: "13",
|
||||
34: "14",
|
||||
35: "15",
|
||||
36: "16 (Baklava)",
|
||||
};
|
||||
|
||||
const androidName = (v) => SDK_NAMES[v] ?? `API ${v}`;
|
||||
|
||||
if (!existsSync(variablesPath)) {
|
||||
console.error(`Cannot find ${variablesPath}`);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
const text = readFileSync(variablesPath, "utf8");
|
||||
|
||||
const extract = (key) => {
|
||||
// Matches `key = 36` (with optional whitespace) on its own line.
|
||||
const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\d+)\\s*$`, "m"));
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
};
|
||||
|
||||
const minSdk = extract("minSdkVersion");
|
||||
const compileSdk = extract("compileSdkVersion");
|
||||
const targetSdk = extract("targetSdkVersion");
|
||||
|
||||
const failures = [];
|
||||
|
||||
if (minSdk === null) {
|
||||
failures.push("minSdkVersion is missing or non-integer in android/variables.gradle");
|
||||
} else if (minSdk < MIN_ACCEPTABLE_MIN_SDK) {
|
||||
failures.push(
|
||||
`minSdkVersion (${minSdk}, Android ${androidName(minSdk)}) is below Play Store floor of ${MIN_ACCEPTABLE_MIN_SDK}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (compileSdk === null) {
|
||||
failures.push("compileSdkVersion is missing or non-integer in android/variables.gradle");
|
||||
} else if (compileSdk < REQUIRED_COMPILE_SDK) {
|
||||
failures.push(
|
||||
`compileSdkVersion (${compileSdk}, Android ${androidName(compileSdk)}) is below the required ${REQUIRED_COMPILE_SDK} (Android ${androidName(REQUIRED_COMPILE_SDK)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (targetSdk === null) {
|
||||
failures.push("targetSdkVersion is missing or non-integer in android/variables.gradle");
|
||||
} else if (targetSdk < REQUIRED_TARGET_SDK) {
|
||||
failures.push(
|
||||
`targetSdkVersion (${targetSdk}, Android ${androidName(targetSdk)}) is below Play Store requirement of ${REQUIRED_TARGET_SDK} (Android ${androidName(REQUIRED_TARGET_SDK)})`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("=== Android target SDK verification (TRU-141) ===");
|
||||
console.log(` Source: ${variablesPath}`);
|
||||
console.log(` minSdkVersion: ${minSdk ?? "?"} (Android ${androidName(minSdk ?? 0)})`);
|
||||
console.log(` compileSdkVersion: ${compileSdk ?? "?"} (Android ${androidName(compileSdk ?? 0)})`);
|
||||
console.log(` targetSdkVersion: ${targetSdk ?? "?"} (Android ${androidName(targetSdk ?? 0)})`);
|
||||
console.log();
|
||||
console.log(` Play Store target-API requirement: ${REQUIRED_TARGET_SDK} (Android ${androidName(REQUIRED_TARGET_SDK)})`);
|
||||
console.log();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("❌ Android target SDK verification FAILED:");
|
||||
for (const f of failures) console.error(` - ${f}`);
|
||||
console.error();
|
||||
console.error(" Fix: edit android/variables.gradle and bump targetSdkVersion (and compileSdkVersion) to 36 or newer.");
|
||||
console.error(" Then re-run this check.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ targetSdkVersion (${targetSdk}) meets Play Store requirement (>= ${REQUIRED_TARGET_SDK})`);
|
||||
console.log(`✅ compileSdkVersion (${compileSdk}) meets minimum (>= ${REQUIRED_COMPILE_SDK})`);
|
||||
@@ -328,47 +328,6 @@ const getCustomerDetailItems = (scan) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
const getLastWashDetailItem = (scan) => {
|
||||
// TRU-78 / DRIFT 17: surface the "last washed" timestamp for a scanned
|
||||
// license plate on the landing page so the operator can see at a glance
|
||||
// when a trailer was last washed (DHL pick-up use case).
|
||||
const lastWash = formatLastWashTimestamp(scan?.last_wash);
|
||||
if (!lastWash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: SessionUser.objects.global.language.last_wash || t('admin.pos.recent_washes'),
|
||||
value: lastWash,
|
||||
testKey: 'last-wash',
|
||||
};
|
||||
};
|
||||
|
||||
const getLastWashEmptyStateText = () => {
|
||||
return t('admin.pos.never_washed');
|
||||
};
|
||||
|
||||
const formatLastWashTimestamp = (value) => {
|
||||
if (!isMeaningfulValue(value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsedDate = new Date(String(value).replace(' ', 'T'));
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(parsedDate);
|
||||
} catch (error) {
|
||||
console.error('Unable to format last_wash timestamp', error);
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getVehicleDetailItems = (scan) => {
|
||||
const detailState = getPlateDetailState(scan.plate);
|
||||
if (detailState.status !== 'loaded') {
|
||||
@@ -534,29 +493,19 @@ setTimeout(() => {
|
||||
:data-testid="`pos-recent-scan-details-${scan.id}`"
|
||||
>
|
||||
<div
|
||||
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
|
||||
v-if="getCustomerDetailItems(scan).length > 0"
|
||||
class="pos-scan-details__grid pos-scan-details__grid--customer"
|
||||
>
|
||||
<div
|
||||
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
|
||||
v-for="item in getCustomerDetailItems(scan)"
|
||||
:key="item.label"
|
||||
class="pos-scan-detail-field"
|
||||
:data-testid="`pos-recent-scan-detail-${scan.id}-${item.testKey || item.label}`"
|
||||
>
|
||||
<span class="pos-scan-detail-field__label">{{ item.label }}</span>
|
||||
<span class="pos-scan-detail-field__value">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!getLastWashDetailItem(scan) && scan.last_wash == null"
|
||||
class="pos-scan-details__state"
|
||||
:data-testid="`pos-recent-scan-never-washed-${scan.id}`"
|
||||
>
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
<span>{{ getLastWashEmptyStateText() }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="getPlateDetailState(scan.plate).status === 'loading'" class="pos-scan-details__state">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>{{ $t('admin.pos.recent_scan_details_loading') }}</span>
|
||||
|
||||
@@ -5448,7 +5448,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.ydelse'}, @:{'words.generated.tilvalg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.sidste'} @:{'words.generated.vask'}",
|
||||
"manual_entry": "Manuell @:{'words.generated.registrering'}",
|
||||
"never_washed": "Aldrig vasket",
|
||||
"new_order": "@.capitalize:{'words.generated.ny'} order",
|
||||
"new_sale": "@.capitalize:{'words.generated.nyt'} @:{'words.generated.salg'}",
|
||||
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bookinger'} @:{'words.generated.fundet'}",
|
||||
|
||||
@@ -5558,7 +5558,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.leistung'}, @:{'words.generated.zusatzoptionen'} @:{'words.generated.und'} @:{'words.generated.zusatzartikel'} @:{'words.generated.wie'} @:{'words.generated.bei'} @:{'words.generated.der'} @:{'words.generated.letzten'} @:{'words.generated.wasche'}",
|
||||
"manual_entry": "@.capitalize:{'words.generated.manuell'} registrering",
|
||||
"never_washed": "Noch nie gewaschen",
|
||||
"new_order": "@.capitalize:{'words.generated.neuer'} @:{'words.generated.auftrag'}",
|
||||
"new_sale": "Nytt salg",
|
||||
"no_bookings": "@.capitalize:{'words.generated.keine'} @:{'words.generated.buchungen'} @:{'words.generated.gefunden'}",
|
||||
|
||||
@@ -5279,7 +5279,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.same'} @:{'words.generated.service'}, @:{'words.generated.add'}-@:{'words.generated.ons'}, @:{'words.generated.and'} @:{'words.generated.extra'} @:{'words.generated.items'} @:{'words.generated.as'} @:{'words.replication.article.host_mention'} @:{'words.generated.previous'} @:{'words.generated.wash'}",
|
||||
"manual_entry": "@:{'words.generated.manual'} @:{'words.generated.registration'}",
|
||||
"never_washed": "Never washed",
|
||||
"new_order": "@.capitalize:{'words.generated.new'} @:{'words.generated.order'}",
|
||||
"new_sale": "@.capitalize:{'words.generated.new'} sale",
|
||||
"no_bookings": "@.capitalize:{'words.generated.no'} @:{'words.generated.orders'} @:{'words.generated.found'}",
|
||||
|
||||
@@ -4852,8 +4852,6 @@
|
||||
"last_wash_matches_current_selection": "@:{'templates.generated.compat.pos.last_wash_matches_current_selection'}",
|
||||
"license_plate": "@:{'templates.generated.compat.tables.common.registration_number'}",
|
||||
"manual_entry": "@:{'templates.generated.compat.pos.manual_entry'}",
|
||||
"last_wash": "@:{'templates.generated.compat.global.last_wash'}",
|
||||
"never_washed": "@:{'templates.generated.compat.pos.never_washed'}",
|
||||
"new_order": "@:{'templates.generated.compat.pos.new_order'}",
|
||||
"new_sale": "@:{'templates.generated.compat.pos.new_sale'}",
|
||||
"no_bookings": "@:{'templates.generated.compat.pos.no_bookings'}",
|
||||
|
||||
@@ -5561,7 +5561,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.tjeneste'}, @:{'words.generated.tillegg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.forrige'} @:{'words.generated.vask'}",
|
||||
"manual_entry": "Manuell @:{'words.generated.registrering'}",
|
||||
"never_washed": "Aldri vasket",
|
||||
"new_order": "@.capitalize:{'words.generated.bestillingen'}",
|
||||
"new_sale": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.salg'}",
|
||||
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
|
||||
|
||||
@@ -5611,7 +5611,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samma'} @:{'words.generated.tjanst'}, @:{'words.generated.tillval'} @:{'words.generated.och'} @:{'words.generated.extra'} @:{'words.generated.artiklar'} @:{'words.generated.som'} @:{'words.generated.senaste'} @:{'words.generated.tvatten'}",
|
||||
"manual_entry": "Manuell @:{'words.generated.registrering'}",
|
||||
"never_washed": "Aldrig tvättad",
|
||||
"new_order": "@.capitalize:{'words.generated.ny'} @:{'words.generated.order'}",
|
||||
"new_sale": "@.capitalize:{'words.generated.nytt'} salg",
|
||||
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.ydelse'}, @:{'terms.glossary.tilvalg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.sidste'} @:{'terms.glossary.vask'}",
|
||||
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
|
||||
"never_washed": "Aldrig vasket",
|
||||
"new_order": "@.capitalize:{'terms.glossary.ny'} order",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.nyt'} @:{'terms.glossary.salg'}",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.fundet'}",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.leistung'}, @:{'terms.glossary.zusatzoptionen'} @:{'terms.glossary.und'} @:{'terms.glossary.zusatzartikel'} @:{'terms.glossary.wie'} @:{'terms.glossary.bei'} @:{'terms.glossary.der'} @:{'terms.glossary.letzten'} @:{'terms.glossary.wasche'}",
|
||||
"manual_entry": "@.capitalize:{'terms.glossary.manuell'} registrering",
|
||||
"never_washed": "Noch nie gewaschen",
|
||||
"new_order": "@.capitalize:{'terms.glossary.neuer'} @:{'terms.glossary.auftrag'}",
|
||||
"new_sale": "Nytt salg",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.keine'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.gefunden'}",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.service'}, @:{'terms.glossary.add'}-@:{'terms.glossary.ons'}, @:{'terms.glossary.and'} @:{'terms.glossary.extra'} @:{'terms.glossary.items'} @:{'terms.glossary.as'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.previous'} @:{'terms.glossary.wash'}",
|
||||
"manual_entry": "@:{'terms.glossary.manual'} @:{'terms.glossary.registration'}",
|
||||
"never_washed": "Never washed",
|
||||
"new_order": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.order'}",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.new'} sale",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.orders'} @:{'terms.glossary.found'}",
|
||||
|
||||
@@ -76,8 +76,6 @@
|
||||
"last_wash_matches_current_selection": "@:{'phrases.compat.pos.last_wash_matches_current_selection'}",
|
||||
"license_plate": "@:{'phrases.compat.tables.common.registration_number'}",
|
||||
"manual_entry": "@:{'phrases.compat.pos.manual_entry'}",
|
||||
"last_wash": "@:{'phrases.compat.global.last_wash'}",
|
||||
"never_washed": "@:{'phrases.compat.pos.never_washed'}",
|
||||
"new_order": "@:{'phrases.compat.pos.new_order'}",
|
||||
"new_sale": "@:{'phrases.compat.pos.new_sale'}",
|
||||
"no_bookings": "@:{'phrases.compat.pos.no_bookings'}",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.tjeneste'}, @:{'terms.glossary.tillegg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.forrige'} @:{'terms.glossary.vask'}",
|
||||
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
|
||||
"never_washed": "Aldri vasket",
|
||||
"new_order": "@.capitalize:{'terms.glossary.bestillingen'}",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.nytt'} @:{'terms.glossary.salg'}",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samma'} @:{'terms.glossary.tjanst'}, @:{'terms.glossary.tillval'} @:{'terms.glossary.och'} @:{'terms.glossary.extra'} @:{'terms.glossary.artiklar'} @:{'terms.glossary.som'} @:{'terms.glossary.senaste'} @:{'terms.glossary.tvatten'}",
|
||||
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
|
||||
"never_washed": "Aldrig tvättad",
|
||||
"new_order": "@.capitalize:{'terms.glossary.ny'} @:{'terms.glossary.order'}",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.nytt'} salg",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// verify-android-target-sdk.test.mjs
|
||||
//
|
||||
// Unit tests for the Android target-SDK guard. We don't read the
|
||||
// real android/variables.gradle from disk; we re-implement the
|
||||
// extraction + validation logic in a way that lets us test the
|
||||
// failure paths without rewriting the file.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
const REQUIRED_TARGET_SDK = 36;
|
||||
const REQUIRED_COMPILE_SDK = 36;
|
||||
const MIN_ACCEPTABLE_MIN_SDK = 24;
|
||||
|
||||
const SDK_NAMES = {
|
||||
24: "7.0 Nougat",
|
||||
35: "15",
|
||||
36: "16 (Baklava)",
|
||||
};
|
||||
|
||||
const androidName = (v) => SDK_NAMES[v] ?? `API ${v}`;
|
||||
|
||||
const extract = (text, key) => {
|
||||
const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\d+)\\s*$`, "m"));
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
};
|
||||
|
||||
const validate = (text) => {
|
||||
const minSdk = extract(text, "minSdkVersion");
|
||||
const compileSdk = extract(text, "compileSdkVersion");
|
||||
const targetSdk = extract(text, "targetSdkVersion");
|
||||
const failures = [];
|
||||
if (minSdk === null) failures.push("minSdkVersion missing");
|
||||
else if (minSdk < MIN_ACCEPTABLE_MIN_SDK) failures.push("minSdk too low");
|
||||
if (compileSdk === null) failures.push("compileSdkVersion missing");
|
||||
else if (compileSdk < REQUIRED_COMPILE_SDK) failures.push("compileSdk too low");
|
||||
if (targetSdk === null) failures.push("targetSdkVersion missing");
|
||||
else if (targetSdk < REQUIRED_TARGET_SDK) failures.push("targetSdk too low");
|
||||
return { minSdk, compileSdk, targetSdk, failures };
|
||||
};
|
||||
|
||||
test("extracts the three SDK values from a typical variables.gradle", () => {
|
||||
const text = `
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
}
|
||||
`;
|
||||
const { minSdk, compileSdk, targetSdk, failures } = validate(text);
|
||||
assert.equal(minSdk, 24);
|
||||
assert.equal(compileSdk, 36);
|
||||
assert.equal(targetSdk, 36);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test("flags targetSdkVersion below 36 as a failure", () => {
|
||||
const text = `
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 35
|
||||
}
|
||||
`;
|
||||
const { failures } = validate(text);
|
||||
assert.ok(
|
||||
failures.includes("targetSdk too low"),
|
||||
`expected targetSdk failure, got: ${JSON.stringify(failures)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("flags compileSdkVersion below 36 as a failure", () => {
|
||||
const text = `
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 36
|
||||
}
|
||||
`;
|
||||
const { failures } = validate(text);
|
||||
assert.ok(
|
||||
failures.includes("compileSdk too low"),
|
||||
`expected compileSdk failure, got: ${JSON.stringify(failures)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("flags minSdkVersion below 24 as a failure", () => {
|
||||
const text = `
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
}
|
||||
`;
|
||||
const { failures } = validate(text);
|
||||
assert.ok(
|
||||
failures.includes("minSdk too low"),
|
||||
`expected minSdk failure, got: ${JSON.stringify(failures)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("flags a missing key", () => {
|
||||
const text = `
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
}
|
||||
`;
|
||||
const { failures } = validate(text);
|
||||
assert.ok(
|
||||
failures.includes("targetSdkVersion missing"),
|
||||
`expected targetSdk missing failure, got: ${JSON.stringify(failures)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("androidName handles known and unknown APIs", () => {
|
||||
assert.equal(androidName(24), "7.0 Nougat");
|
||||
assert.equal(androidName(36), "16 (Baklava)");
|
||||
assert.equal(androidName(99), "API 99");
|
||||
});
|
||||
|
||||
test("rejects non-integer values (extract returns null)", () => {
|
||||
const text = `
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = "36"
|
||||
targetSdkVersion = 36
|
||||
}
|
||||
`;
|
||||
// Anchored to start of line + only digits; quoted values won't match.
|
||||
const compileSdk = extract(text, "compileSdkVersion");
|
||||
assert.equal(compileSdk, null);
|
||||
});
|
||||
@@ -83,7 +83,6 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
},
|
||||
global: {
|
||||
language: {
|
||||
last_wash: "Last wash",
|
||||
nothing_left_to_show: "Nothing left to show",
|
||||
},
|
||||
},
|
||||
@@ -134,7 +133,6 @@ const messages = {
|
||||
make: "Make",
|
||||
model: "Model",
|
||||
no_plates_found: "No license plates found",
|
||||
never_washed: "Never washed",
|
||||
recent_scans: "Recent scans",
|
||||
recent_scans_helper: "Select a license plate to view vehicle details.",
|
||||
recent_scan_details_empty: "No vehicle details are available for this scan.",
|
||||
@@ -166,9 +164,6 @@ const scans = [
|
||||
customer_name: "Pleno Logistics",
|
||||
seen_before: true,
|
||||
barred: false,
|
||||
// TRU-78: enriched by the API so the landing page can show when
|
||||
// the plate was last washed (DHL pick-up use case).
|
||||
last_wash: "2026-04-07 17:12:33",
|
||||
},
|
||||
{
|
||||
id: 802,
|
||||
@@ -179,22 +174,6 @@ const scans = [
|
||||
customer_name: "",
|
||||
seen_before: false,
|
||||
barred: false,
|
||||
// TRU-78: explicit null means the API confirmed the plate has
|
||||
// never been washed.
|
||||
last_wash: null,
|
||||
},
|
||||
{
|
||||
id: 803,
|
||||
plate: "EF11111",
|
||||
plate_scanner_id: 3,
|
||||
created_at: "2026-04-08 08:30:00",
|
||||
customer_number: null,
|
||||
customer_name: "",
|
||||
seen_before: false,
|
||||
barred: false,
|
||||
// TRU-78: missing field should be treated as "never washed" too,
|
||||
// so older backends don't break the new UI.
|
||||
// (no `last_wash` key on purpose)
|
||||
},
|
||||
];
|
||||
|
||||
@@ -362,59 +341,4 @@ describe("PosLastScannedLicensePlatesV2", () => {
|
||||
"Vehicle details could not be loaded"
|
||||
);
|
||||
});
|
||||
|
||||
// TRU-78 / DRIFT 17: license plate scan should show "last washed" on the
|
||||
// landing page so the operator can see at a glance when a trailer was
|
||||
// last washed (DHL pick-up use case).
|
||||
it("renders the last washed timestamp when the scan has a last_wash value", async () => {
|
||||
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
paginationState.list.value = scans;
|
||||
await flushUi();
|
||||
|
||||
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
|
||||
await flushUi();
|
||||
|
||||
const lastWashField = wrapper.get('[data-testid="pos-recent-scan-detail-801-last-wash"]');
|
||||
expect(lastWashField.exists()).toBe(true);
|
||||
expect(lastWashField.text()).toContain("Last wash");
|
||||
// The MySQL DATETIME "2026-04-07 17:12:33" is rendered in the user's
|
||||
// locale. We accept both the raw DATETIME string (jsdom without full
|
||||
// Intl support) and the locale-formatted output (e.g. 04/07/2026 or
|
||||
// 2026-04-07 in browsers with Intl.DateTimeFormat).
|
||||
expect(lastWashField.text()).toMatch(/2026/);
|
||||
expect(lastWashField.text()).toMatch(/04[\/.\- ]07|07[\/.\- ]04|7[\/.\- ]4|2026-04-07/);
|
||||
expect(lastWashField.text()).toMatch(/17:12/);
|
||||
});
|
||||
|
||||
it("shows the 'never washed' message when the API confirms the plate has no last_wash", async () => {
|
||||
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
paginationState.list.value = scans;
|
||||
await flushUi();
|
||||
|
||||
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
|
||||
await flushUi();
|
||||
|
||||
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-802"]');
|
||||
expect(neverWashed.exists()).toBe(true);
|
||||
expect(neverWashed.text()).toContain("Never washed");
|
||||
});
|
||||
|
||||
it("falls back to 'never washed' when the scan has no last_wash key at all", async () => {
|
||||
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
paginationState.list.value = scans;
|
||||
await flushUi();
|
||||
|
||||
await wrapper.get('[data-testid="pos-recent-scan-row-803"]').trigger("click");
|
||||
await flushUi();
|
||||
|
||||
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-803"]');
|
||||
expect(neverWashed.exists()).toBe(true);
|
||||
expect(neverWashed.text()).toContain("Never washed");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user