Compare commits

..
Author SHA1 Message Date
Frontend Agent 4637cb867b fix(ios): require arm64 instead of armv7 (TRU-92)
The Info.plist listed armv7 as a UIRequiredDeviceCapability, which
filtered the app out of all iOS 11+ devices (iPhone 5s and later) -
they are 64-bit only and report arm64, not armv7. With IPHONEOS_DEPLOYMENT_TARGET
= 15.0 the app is already arm64-only, so listing armv7 is both wrong
and the reason the build did not install on Mads's iPhone.

Replace armv7 with arm64 so the requirement matches the actual
deployment target and the iPhone App Store can match the device.
2026-08-17 16:38:42 +00:00
openclaw bugfix 3873849de1 fix(ci): regenerate i18n v2 runtime files for last_wash/never_washed (TRU-78)
The i18n:v2:source-check step in the Quality-i18n CI job was failing
because the new pos.last_wash and pos.never_washed leaves added in
f22ca71 were not yet reflected in the generated runtime bundles.

Running 'npm run i18n:v2:compile' regenerates global-v2.json and the
five locale bundles (da, de, en, no, sv) to expose the new keys.
No source phrases changed in this commit.
2026-08-17 14:29:35 +00:00
openclaw bugfix f22ca71558 feat(pos): show 'last washed' timestamp on license plate scan (TRU-78)
When the operator expands a row in the recent license plate scans panel
on the POS landing page, the inline details now include a 'Last wash'
field with the timestamp of the most recent wash for that plate (as
returned by the enriched /numberplatescans endpoint). The label falls
back to the existing 'recent_washes' / global.last_wash translation.

If the API reports last_wash = null, an inline 'Never washed' / 'Aldrig
vasket' note is shown instead (TRU-78 / DRIFT 17 — DHL trailer pick-up
use case: see at a glance whether the trailer needs another wash).

- New pos/never_washed i18n leaf in all 5 locales.
- 3 new vitest cases in pos-last-scanned-license-plates.spec.js:
  - renders the last washed timestamp when present
  - shows 'never washed' when the API returns null
  - falls back to 'never washed' when the field is missing entirely
2026-08-17 14:20:12 +00:00
18 changed files with 144 additions and 264 deletions
-10
View File
@@ -7,10 +7,8 @@ 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:
@@ -75,15 +73,9 @@ 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
@@ -92,7 +84,5 @@ 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
+1 -1
View File
@@ -38,7 +38,7 @@
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
@@ -1,118 +0,0 @@
// 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,6 +328,47 @@ 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') {
@@ -493,19 +534,29 @@ setTimeout(() => {
:data-testid="`pos-recent-scan-details-${scan.id}`"
>
<div
v-if="getCustomerDetailItems(scan).length > 0"
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
class="pos-scan-details__grid pos-scan-details__grid--customer"
>
<div
v-for="item in getCustomerDetailItems(scan)"
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
: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>
+1
View File
@@ -5448,6 +5448,7 @@
},
"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'}",
+1
View File
@@ -5558,6 +5558,7 @@
},
"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'}",
+1
View File
@@ -5279,6 +5279,7 @@
},
"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'}",
+2
View File
@@ -4852,6 +4852,8 @@
"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'}",
+1
View File
@@ -5561,6 +5561,7 @@
},
"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'}",
+1
View File
@@ -5611,6 +5611,7 @@
},
"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,6 +44,7 @@
},
"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,6 +44,7 @@
},
"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,6 +44,7 @@
},
"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,6 +76,8 @@
"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,6 +44,7 @@
},
"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,6 +44,7 @@
},
"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'}",
@@ -1,133 +0,0 @@
// 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,6 +83,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
global: {
language: {
last_wash: "Last wash",
nothing_left_to_show: "Nothing left to show",
},
},
@@ -133,6 +134,7 @@ 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.",
@@ -164,6 +166,9 @@ 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,
@@ -174,6 +179,22 @@ 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)
},
];
@@ -341,4 +362,59 @@ 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");
});
});