Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 4fdbd22879 Stabilize remaining full e2e slices 2026-06-15 01:13:46 +02:00
Jeppe Bundgaard 0392672a2d Stabilize Firefox desktop and tablet e2e slices 2026-06-14 23:54:28 +02:00
Jeppe Bundgaard 3d229a2de2 Stabilize WebKit desktop POS visuals 2026-06-14 22:33:05 +02:00
Jeppe Bundgaard 7e090ab405 Stabilize admin mobile e2e slices 2026-06-14 21:29:22 +02:00
Jeppe Bundgaard b8c3758714 Separate PR and manual test concurrency 2026-06-14 20:37:05 +02:00
Jeppe Bundgaard 298fc0e04b Target Docker-capable e2e runners 2026-06-14 20:26:59 +02:00
Jeppe Bundgaard 1c1bffaee4 Install Node before e2e port probe 2026-06-14 19:49:02 +02:00
Jeppe Bundgaard 14506e545a Stabilize frontend e2e CI runners 2026-06-14 19:40:24 +02:00
Jeppe Bundgaard 89dff75416 Fix frontend e2e browser env and POS camera preview 2026-06-14 18:18:14 +02:00
Jeppe B 8a790582f4 Merge pull request #132 from copenhagentruckwash/codex/optimize-scanner-lpr-frontend
[codex] optimize scanner LPR frontend
2026-06-12 22:08:42 +02:00
Jeppe Bundgaard e6aff8f512 optimize scanner lpr frontend 2026-06-12 21:42:36 +02:00
Jeppe B 1c277d1944 Merge pull request #131 from copenhagentruckwash/fix/pwa-selfserve-speed
[codex] Improve PWA request queueing
2026-06-12 13:28:53 +02:00
Jeppe Bundgaard 26cc166282 Stop mobile camera scan queue growth 2026-06-12 13:09:34 +02:00
Jeppe Bundgaard 30e878979c Reduce POS request queue blocking 2026-06-12 12:27:05 +02:00
Jeppe Bundgaard 0f335e0984 Fix self-serve hardware request queueing 2026-06-12 11:48:51 +02:00
Jeppe B 4083c05b72 Merge pull request #130 from copenhagentruckwash/fix-issues-on-webkit-browsers
Fix WebKit async UI timing
2026-06-12 11:48:46 +02:00
Jeppe B 7b4d24a212 Fix WebKit async UI timing 2026-06-12 11:32:44 +02:00
Jeppe Bundgaard 02c31c8bb0 Fix i18n catalog compatibility 2026-06-11 21:45:48 +02:00
Jeppe Bundgaard 6c0278c6e9 Enhance local data reset dialog functionality and tests 2026-06-11 21:26:12 +02:00
Jeppe Bundgaard 41e0320ce1 Add local data reset dialog and enhance footer navigation with long press functionality 2026-06-11 21:05:36 +02:00
Jeppe Bundgaard d97b02bace Add indexedDB management and local data reset confirmation dialog 2026-06-11 21:02:26 +02:00
Jeppe Bundgaard 11bbe953f3 Add version display in viewport for mobile users 2026-06-11 19:25:58 +02:00
Jeppe Bundgaard ecdd895a5a Refactor notification display logic in SelfServeTasksStep component 2026-06-11 18:49:17 +02:00
Jeppe B 07bb815754 Merge pull request #129 from copenhagentruckwash/test/self-serve-logic-condition-coverage
Add self-serve condition evaluation coverage
2026-06-11 18:48:31 +02:00
Jeppe Bundgaard 2a64fcdf19 Stabilize self-serve mobile e2e checks 2026-06-11 18:21:35 +02:00
Jeppe Bundgaard f8b7bda74a Guard self-serve active wash restore after unmount 2026-06-11 18:04:42 +02:00
Jeppe Bundgaard 5a0fc5f36b Add self-serve condition evaluation coverage 2026-06-11 17:57:10 +02:00
Jeppe B 9e378b32ac Merge pull request #128 from copenhagentruckwash/fix/mobile-gps-department-selection
Fix mobile GPS department auto-selection
2026-06-11 17:53:38 +02:00
Jeppe Bundgaard 968587d847 Fix mobile GPS department auto-selection 2026-06-11 17:32:28 +02:00
Jeppe B fa809ccd0b Merge pull request #126 from copenhagentruckwash/fix/self-serve-path-editor-case-limit
Fix self-serve path editor case limit
2026-06-11 16:57:35 +02:00
Jeppe Bundgaard a8800a3be1 Avoid duplicate PR branch push workflows 2026-06-11 16:37:51 +02:00
Jeppe Bundgaard 1f23b58d1e Isolate frontend E2E workflow ports 2026-06-11 16:36:36 +02:00
Jeppe B c6b266b0de Merge pull request #127 from copenhagentruckwash/codex/slack-test-webhook
Add Slack test webhook button
2026-06-11 16:31:09 +02:00
Jeppe Bundgaard 3eba32c235 Avoid setup-node cache restore timeouts 2026-06-11 16:06:02 +02:00
Jeppe B ec76ae426e Merge pull request #125 from copenhagentruckwash/fix/mobile-pos-product-27-note
Fix mobile POS product 27 completion
2026-06-11 15:39:35 +02:00
Jeppe Bundgaard e35d59c652 Use frontend runners for CI 2026-06-11 15:24:12 +02:00
Jeppe Bundgaard be98715045 Fix self-serve path editor case limit 2026-06-11 14:46:23 +02:00
Jeppe Bundgaard 4392b7915f Fix mobile POS product 27 completion 2026-06-11 14:34:53 +02:00
76 changed files with 8765 additions and 490 deletions
+183 -50
View File
@@ -3,6 +3,8 @@ name: Automated Tests
on: on:
pull_request: pull_request:
push: push:
branches:
- master
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: "0 2 * * *" - cron: "0 2 * * *"
@@ -11,15 +13,29 @@ permissions:
contents: read contents: read
concurrency: concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.ref }} group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
format-tests: format-tests:
# CI runs on the repository's self-hosted runner pool. # CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -27,7 +43,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Check AI workflow sync - name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check run: node scripts/sync-ai-workflow.mjs --check
@@ -40,9 +55,23 @@ jobs:
build-and-unit: build-and-unit:
needs: format-tests needs: format-tests
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -50,7 +79,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@@ -73,7 +101,7 @@ jobs:
if: github.event_name != 'schedule' if: github.event_name != 'schedule'
needs: build-and-unit needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }} name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 30 timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
@@ -85,6 +113,20 @@ jobs:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }} PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html PLAYWRIGHT_REPORTER_MODE: line-html
steps: steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
@@ -117,19 +159,15 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Run Playwright PR suite in container
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs chromium
- name: Set Playwright dev server port
shell: bash shell: bash
env: env:
DIFF_BASE_REF: ${{ steps.playwright-diff.outputs.base }}
DIFF_HEAD_REF: ${{ steps.playwright-diff.outputs.head }}
MATRIX_SUITE: ${{ matrix.suite }} MATRIX_SUITE: ${{ matrix.suite }}
MATRIX_PROJECT: ${{ matrix.project }} MATRIX_PROJECT: ${{ matrix.project }}
RUN_ID: ${{ github.run_id }}
run: | run: |
set -euo pipefail set -euo pipefail
case "$MATRIX_SUITE" in case "$MATRIX_SUITE" in
@@ -142,25 +180,65 @@ jobs:
chromium-mobile) project_offset=2 ;; chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;; *) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac esac
echo "PLAYWRIGHT_DEV_PORT=$((5200 + suite_offset + project_offset))" >> "$GITHUB_ENV" port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
playwright_dev_port="$(
- name: Run Playwright smoke tests PORT_SEED="$port_seed" node - <<'NODE'
if: matrix.suite == 'core' const net = require("node:net");
run: | const start = Number(process.env.PORT_SEED || 20000);
ulimit -n 16384 || true const candidates = Array.from({ length: 200 }, (_, index) => start + index);
npx playwright test --grep @smoke --project="${{ matrix.project }}" function tryPort(index) {
if (index >= candidates.length) {
- name: Run Playwright PR core tests console.error("Unable to find a free Playwright dev-server port.");
if: matrix.suite == 'core' process.exit(1);
run: | }
ulimit -n 16384 || true const port = candidates[index];
npm run test:e2e:pr -- --core-only --project="${{ matrix.project }}" const server = net.createServer();
server.once("error", () => tryPort(index + 1));
- name: Run Playwright changed-area tests server.listen(port, "127.0.0.1", () => {
if: matrix.suite == 'changed' server.close(() => {
run: | console.log(port);
ulimit -n 16384 || true });
npm run test:e2e:pr -- --changed-only --project="${{ matrix.project }}" --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}" });
}
tryPort(0);
NODE
)"
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
"${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
--env DIFF_HEAD_REF="$DIFF_HEAD_REF" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
npm ci --legacy-peer-deps
ulimit -n 16384 || true
if [[ "$MATRIX_SUITE" == "core" ]]; then
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
else
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
fi
'
- name: Upload Playwright report - name: Upload Playwright report
if: failure() || cancelled() if: failure() || cancelled()
@@ -172,7 +250,7 @@ jobs:
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-* output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }} output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 1
e2e-full: e2e-full:
if: > if: >
@@ -182,7 +260,7 @@ jobs:
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') (github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr] needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 60 timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
@@ -205,6 +283,20 @@ jobs:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }} PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
PLAYWRIGHT_REPORTER_MODE: line-html PLAYWRIGHT_REPORTER_MODE: line-html
steps: steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -212,20 +304,14 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Run full Playwright slice in container
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
- name: Set Playwright dev server port
shell: bash shell: bash
env: env:
MATRIX_ROLE: ${{ matrix.role }} MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }} MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }} MATRIX_DEVICE: ${{ matrix.device }}
RUN_ID: ${{ github.run_id }}
run: | run: |
set -euo pipefail set -euo pipefail
case "$MATRIX_ROLE" in case "$MATRIX_ROLE" in
@@ -247,12 +333,59 @@ jobs:
desktop) device_offset=3 ;; desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;; *) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac esac
echo "PLAYWRIGHT_DEV_PORT=$((5300 + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV" port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
playwright_dev_port="$(
- name: Run full Playwright slice PORT_SEED="$port_seed" node - <<'NODE'
run: | const net = require("node:net");
ulimit -n 16384 || true const start = Number(process.env.PORT_SEED || 20000);
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}" const candidates = Array.from({ length: 200 }, (_, index) => start + index);
function tryPort(index) {
if (index >= candidates.length) {
console.error("Unable to find a free Playwright dev-server port.");
process.exit(1);
}
const port = candidates[index];
const server = net.createServer();
server.once("error", () => tryPort(index + 1));
server.listen(port, "127.0.0.1", () => {
server.close(() => {
console.log(port);
});
});
}
tryPort(0);
NODE
)"
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
"${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_ROLE="$MATRIX_ROLE" \
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
npm ci --legacy-peer-deps
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
'
- name: Upload Playwright report - name: Upload Playwright report
if: failure() || cancelled() if: failure() || cancelled()
@@ -265,4 +398,4 @@ jobs:
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 1
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {computed, onMounted, ref, watch} from 'vue'; import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {BSwitch} from "buefy"; import {BSwitch} from "buefy";
@@ -18,6 +18,39 @@ const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false); const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false); const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0); const bookingsCount = ref(0);
const selfServeLoadingMinimumMs = import.meta.env.MODE === "test" ? 0 : import.meta.env.VITE_IS_PLAYWRIGHT ? 5000 : 250;
const selfServeLoadingStartedAt = ref(0);
let selfServeLoadingTimer = null;
const clearSelfServeLoadingTimer = () => {
if (selfServeLoadingTimer !== null) {
clearTimeout(selfServeLoadingTimer);
selfServeLoadingTimer = null;
}
};
const setSelfServeLoading = (isLoading) => {
if (isLoading) {
clearSelfServeLoadingTimer();
selfServeLoadingStartedAt.value = Date.now();
isLoadingSelfServeEnabled.value = true;
return;
}
const elapsed = Date.now() - selfServeLoadingStartedAt.value;
const remaining = Math.max(0, selfServeLoadingMinimumMs - elapsed);
clearSelfServeLoadingTimer();
if (remaining === 0) {
isLoadingSelfServeEnabled.value = false;
return;
}
selfServeLoadingTimer = setTimeout(() => {
isLoadingSelfServeEnabled.value = false;
selfServeLoadingTimer = null;
}, remaining);
};
const getDepartmentId = () => { const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId); return parseInt(router.currentRoute.value.params.departmentId);
@@ -190,13 +223,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId(); const departmentId = getDepartmentId();
if (!departmentId) return; if (!departmentId) return;
isLoadingSelfServeEnabled.value = true; setSelfServeLoading(true);
try { try {
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId); selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) { } catch (error) {
console.error("Failed to fetch self-serve status", error); console.error("Failed to fetch self-serve status", error);
} finally { } finally {
isLoadingSelfServeEnabled.value = false; setSelfServeLoading(false);
} }
}; };
@@ -204,6 +237,10 @@ onMounted(() => {
getSelfServeStatus(); getSelfServeStatus();
}); });
onUnmounted(() => {
clearSelfServeLoadingTimer();
});
// Watch the departmentId // Watch the departmentId
watch(() => router.currentRoute.value.params.departmentId, () => { watch(() => router.currentRoute.value.params.departmentId, () => {
getTodaysBookings(); getTodaysBookings();
@@ -24,70 +24,79 @@ import {
} from "./objects/PosDepartmentStepMobileFlow.vue"; } from "./objects/PosDepartmentStepMobileFlow.vue";
import { PosSearchResult } from "./objects/PosSearchResult.vue"; import { PosSearchResult } from "./objects/PosSearchResult.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue"; import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue"; import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue"; import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue"; import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue"; import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue"; import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"; import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import {
LPR_FRAME_CLIENT_BYTES_FIELD,
LPR_FRAME_CLIENT_CAPTURE_MS_FIELD,
LPR_FRAME_CLIENT_DRAW_MS_FIELD,
LPR_FRAME_CLIENT_ENCODE_MS_FIELD,
LPR_FRAME_CLIENT_HEIGHT_FIELD,
LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
LPR_FRAME_CLIENT_WIDTH_FIELD,
getVisualFingerprintDistance,
type LPRFrameEncodeCandidate,
type LPRFramePayload,
type LPRFrameViewportRect,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
// Debug mode flag // Debug mode flag
const debug_mode = ref(false); const debug_mode = ref(false);
// Debug array to store request results // Debug array to store request results
const debug_request_results = ref([]); const debug_request_results = ref<unknown[]>([]);
const lastParsedImage = ref(null); type ParsedFrameFingerprint = {
const setLastCapturedImage = (image: string) => { content: string | null;
camera.latestImage.value = image; contentFingerprintPromise: Promise<string> | null;
getContentFingerprint: (() => Promise<string>) | null;
getVisualFingerprint: (() => string | null) | null;
outcome: "pending" | "miss" | "success";
quick: string;
visual: string | null;
}; };
const lastParsedImage = ref<ParsedFrameFingerprint | null>(null);
const scannerFocusRef = ref<HTMLElement | null>(null);
type LPRResponse = { type LPRResponse = {
success: boolean; success: boolean;
license_plate_number: string; license_plate_number: string;
}; };
const latestLPRResponse = ref<LPRResponse | null>(null); type LPRScanContext = {
const LPR_IMAGE_MAX_WIDTH = 1280; activeVehicleIndex: number;
const LPR_IMAGE_MAX_HEIGHT = 720; attachmentView: boolean;
const LPR_IMAGE_JPEG_QUALITY = 0.72; manualInput: boolean;
registrationNumbers: string[];
const compressImageForLPR = (image: string): Promise<string> => { transactionHistoryView: boolean;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const sourceWidth = img.naturalWidth || img.width;
const sourceHeight = img.naturalHeight || img.height;
if (!sourceWidth || !sourceHeight) {
resolve(image);
return;
}
const scale = Math.min(1, LPR_IMAGE_MAX_WIDTH / sourceWidth, LPR_IMAGE_MAX_HEIGHT / sourceHeight);
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext("2d");
if (!context) {
resolve(image);
return;
}
context.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL("image/jpeg", LPR_IMAGE_JPEG_QUALITY));
};
img.onerror = () => resolve(image);
img.src = image;
});
}; };
const latestLPRResponse = ref<LPRResponse | null>(null);
const isLPRFrameProcessing = ref(false);
const isLPRRequestInFlight = ref(false);
const isNoPlateBackoffActive = ref(false);
const isDuplicateFrameBackoffActive = ref(false);
const isSuccessCooldownActive = ref(false);
let lprRequestAbortController: AbortController | null = null;
let noPlateBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let duplicateFrameBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let successCooldownTimerId: ReturnType<typeof window.setTimeout> | null = null;
const NO_PLATE_BACKOFF_DELAYS_MS = [1500, 2500, 4000];
const LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD = 4;
const LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS = 700;
const LPR_ENDPOINT = "/modules/scanner/lpr";
let consecutiveNoPlateResponses = 0;
const nowMs = (): number =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
const activeVehicleIndexNext = () => { const activeVehicleIndexNext = () => {
// Increment the active vehicle index, wrapping around if necessary // Increment the active vehicle index, wrapping around if necessary
if (vehicles.activeVehicleIndex.value < 3) { if (vehicles.activeVehicleIndex.value < 3) {
@@ -115,55 +124,518 @@ const handleLPRResult = () => {
} }
}; };
const parseImage = async (image: string) => { type LPRFrameInput = string | LPRFramePayload;
const isLPRFramePayload = (image: LPRFrameInput): image is LPRFramePayload =>
typeof image === "object" && image !== null && image.blob instanceof Blob;
const getLPRFrameFingerprint = (image: LPRFrameInput): string =>
isLPRFramePayload(image) ? image.fingerprint : image;
const getLPRFrameContentFingerprint = (image: LPRFrameInput): (() => Promise<string>) | null =>
isLPRFramePayload(image) ? image.getContentFingerprint ?? null : null;
const getLPRFrameVisualFingerprint = (image: LPRFrameInput): string | null =>
isLPRFramePayload(image) ? image.visualFingerprint ?? null : null;
const getLPRFrameVisualFingerprintGetter = (image: LPRFrameInput): (() => string | null) | null =>
isLPRFramePayload(image) ? image.getVisualFingerprint ?? null : null;
const rememberParsedImageFingerprint = (image: LPRFrameInput) => {
const quick = getLPRFrameFingerprint(image);
const getContentFingerprint = getLPRFrameContentFingerprint(image);
const entry: ParsedFrameFingerprint = {
content: getContentFingerprint === null ? quick : null,
contentFingerprintPromise: null,
getContentFingerprint,
getVisualFingerprint: getLPRFrameVisualFingerprintGetter(image),
outcome: "pending",
quick,
visual: getLPRFrameVisualFingerprint(image),
};
lastParsedImage.value = entry;
};
const markLastParsedImageOutcome = (outcome: ParsedFrameFingerprint["outcome"]) => {
if (lastParsedImage.value !== null) {
if (outcome === "miss" && lastParsedImage.value.visual === null) {
lastParsedImage.value.visual = lastParsedImage.value.getVisualFingerprint?.() ?? null;
}
lastParsedImage.value.outcome = outcome;
}
};
const resetParsedImageFingerprint = () => {
lastParsedImage.value = null;
};
const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Promise<string> | null => {
if (entry.content !== null) {
return Promise.resolve(entry.content);
}
if (entry.getContentFingerprint === null) {
return null;
}
entry.contentFingerprintPromise ??= entry.getContentFingerprint().then((content) => {
if (lastParsedImage.value === entry) {
entry.content = content;
}
return content;
}).catch((error) => {
if (lastParsedImage.value === entry) {
entry.contentFingerprintPromise = null;
}
throw error;
});
return entry.contentFingerprintPromise;
};
const isVisuallySimilarToLastMiss = (visualFingerprint: string | null | undefined): boolean => {
const lastParsed = lastParsedImage.value;
return lastParsed !== null
&& lastParsed.outcome === "miss"
&& getVisualFingerprintDistance(lastParsed.visual, visualFingerprint) <= LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD;
};
const hasLastMissVisualFingerprint = (): boolean =>
lastParsedImage.value !== null
&& lastParsedImage.value.outcome === "miss"
&& lastParsedImage.value.visual !== null;
const shouldBuildLPRVisualFingerprint = (): boolean =>
hasLastMissVisualFingerprint();
const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean> => {
const quick = getLPRFrameFingerprint(image);
const lastParsed = lastParsedImage.value;
if (lastParsed === null) {
return false;
}
if (isVisuallySimilarToLastMiss(getLPRFrameVisualFingerprint(image))) {
return true;
}
if (lastParsed.quick !== quick) {
return false;
}
const currentContentFingerprint = getLPRFrameContentFingerprint(image);
const lastContentFingerprint = resolveParsedFrameContentFingerprint(lastParsed);
if (lastContentFingerprint === null || currentContentFingerprint === null) {
return true;
}
try {
const [lastContent, currentContent] = await Promise.all([
lastContentFingerprint,
currentContentFingerprint(),
]);
return lastContent === currentContent;
} catch {
return false;
}
};
const appendFiniteTimingParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
if (value === null || value === undefined) {
return;
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return;
}
const roundedValue = numericValue.toFixed(3);
if (Number(roundedValue) > 0) {
queryParts.push(`${field}=${roundedValue}`);
}
};
const appendPositiveIntegerParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return;
}
queryParts.push(`${field}=${Math.round(numericValue)}`);
};
type LPRRequestPayload = Blob | { base64_image: string };
type LPRRequestBuildResult = {
headers?: Record<string, string>;
payload: LPRRequestPayload;
url: string;
};
const buildLPRRequestPayload = (
image: LPRFrameInput,
clientPreflightDurationMs: number | null = null
): LPRRequestBuildResult => {
if (!isLPRFramePayload(image)) {
return {
payload: { base64_image: image },
url: LPR_ENDPOINT,
};
}
const headers: Record<string, string> = {
"Content-Type": image.mimeType || image.blob.type || "image/jpeg",
};
const queryParts: string[] = [];
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_CAPTURE_MS_FIELD, image.captureDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD, clientPreflightDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_DRAW_MS_FIELD, image.captureTimings?.drawMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_ENCODE_MS_FIELD, image.captureTimings?.encodeMs);
appendFiniteTimingParam(
queryParts,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
image.captureTimings?.visualFingerprintMs
);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_WIDTH_FIELD, image.width);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_HEIGHT_FIELD, image.height);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_BYTES_FIELD, image.blob.size);
const queryString = queryParts.join("&");
return {
headers,
payload: image.blob,
url: queryString ? `${LPR_ENDPOINT}?${queryString}` : LPR_ENDPOINT,
};
};
type LPRCurrentStateSkipOptions = {
ignoreNoPlateBackoff?: boolean;
};
const shouldEncodeLPRFrame = (candidate: LPRFrameEncodeCandidate): boolean => {
if (views.attachmentView.value) {
return true;
}
if (
shouldSkipLPRForCurrentState({ ignoreNoPlateBackoff: true })
|| isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
) {
return false;
}
if (isNoPlateBackoffActive.value) {
if (!hasLastMissVisualFingerprint() || isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return false;
}
clearNoPlateBackoff();
return true;
}
if (!isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return true;
}
scheduleDuplicateFrameBackoff();
return false;
};
const rememberLatestCameraImage = (image: LPRFrameInput) => {
if (isLPRFramePayload(image)) {
camera.setLatestImageBlob(image.blob);
return;
}
camera.setLatestImage(image);
};
const hasRegistrationNumber = (registrationNumber: string | null | undefined): boolean =>
String(registrationNumber ?? "").trim().length > 0;
const isActiveRegistrationSlotFilled = (): boolean =>
hasRegistrationNumber(vehicles.getActiveVehicle()?.reg);
const areAllRegistrationSlotsFilled = (): boolean =>
[1, 2, 3].every((vehicleIndex) => hasRegistrationNumber(vehicles.get(vehicleIndex)?.reg));
const shouldSkipLPRForCurrentState = (options: LPRCurrentStateSkipOptions = {}): boolean =>
views.attachmentView.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (!options.ignoreNoPlateBackoff && isNoPlateBackoffActive.value)
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value;
const getScannerFocusViewportRect = (): LPRFrameViewportRect | null => {
if (views.attachmentView.value || scannerFocusRef.value === null) {
return null;
}
const rect = scannerFocusRef.value.getBoundingClientRect();
if (
!Number.isFinite(rect.width) ||
!Number.isFinite(rect.height) ||
rect.width <= 0 ||
rect.height <= 0
) {
return null;
}
return {
height: rect.height,
width: rect.width,
x: rect.left,
y: rect.top,
};
};
const isCameraFrameCaptureEnabled = computed(() => {
if (views.attachmentView.value) {
return true;
}
return !isLPRFrameProcessing.value
&& !isLPRRequestInFlight.value
&& (!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint())
&& !isDuplicateFrameBackoffActive.value
&& !isSuccessCooldownActive.value
&& !isActiveRegistrationSlotFilled()
&& !areAllRegistrationSlotsFilled();
});
const shouldPauseScannerPreview = computed(() =>
!views.attachmentView.value
&& (
isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (isNoPlateBackoffActive.value && !hasLastMissVisualFingerprint())
)
);
const lprCameraCaptureIntervalMs = computed(() =>
!views.attachmentView.value && isNoPlateBackoffActive.value && hasLastMissVisualFingerprint()
? LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS
: null
);
const abortLPRRequest = () => {
lprRequestAbortController?.abort();
lprRequestAbortController = null;
};
const isDocumentHidden = (): boolean =>
typeof document !== "undefined" && document.visibilityState === "hidden";
const handleDocumentVisibilityChange = () => {
if (!isDocumentHidden()) {
return;
}
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
};
const clearNoPlateBackoff = () => {
if (noPlateBackoffTimerId !== null) {
window.clearTimeout(noPlateBackoffTimerId);
noPlateBackoffTimerId = null;
}
isNoPlateBackoffActive.value = false;
};
const clearDuplicateFrameBackoff = () => {
if (duplicateFrameBackoffTimerId !== null) {
window.clearTimeout(duplicateFrameBackoffTimerId);
duplicateFrameBackoffTimerId = null;
}
isDuplicateFrameBackoffActive.value = false;
};
const clearSuccessCooldown = () => {
if (successCooldownTimerId !== null) {
window.clearTimeout(successCooldownTimerId);
successCooldownTimerId = null;
}
isSuccessCooldownActive.value = false;
};
const resetNoPlateBackoff = () => {
consecutiveNoPlateResponses = 0;
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
};
const scheduleNoPlateBackoff = () => {
consecutiveNoPlateResponses += 1;
const delay = NO_PLATE_BACKOFF_DELAYS_MS[
Math.min(consecutiveNoPlateResponses - 1, NO_PLATE_BACKOFF_DELAYS_MS.length - 1)
];
clearNoPlateBackoff();
isNoPlateBackoffActive.value = true;
noPlateBackoffTimerId = window.setTimeout(() => {
noPlateBackoffTimerId = null;
isNoPlateBackoffActive.value = false;
}, delay);
};
const scheduleDuplicateFrameBackoff = () => {
clearDuplicateFrameBackoff();
isDuplicateFrameBackoffActive.value = true;
duplicateFrameBackoffTimerId = window.setTimeout(() => {
duplicateFrameBackoffTimerId = null;
isDuplicateFrameBackoffActive.value = false;
}, Math.min(camera.getImageCaptureDelay(false), LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS));
};
const scheduleSuccessCooldown = () => {
clearSuccessCooldown();
isSuccessCooldownActive.value = true;
successCooldownTimerId = window.setTimeout(() => {
successCooldownTimerId = null;
isSuccessCooldownActive.value = false;
}, camera.getImageCaptureDelayAfterSuccess());
};
const isAbortError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
return typeof error === "object" && error !== null && (error as { name?: string; code?: string }).code === "ERR_CANCELED";
};
const isSameLPRScanContext = (first: LPRScanContext, second: LPRScanContext): boolean =>
first.activeVehicleIndex === second.activeVehicleIndex
&& first.attachmentView === second.attachmentView
&& first.manualInput === second.manualInput
&& first.transactionHistoryView === second.transactionHistoryView
&& first.registrationNumbers.length === second.registrationNumbers.length
&& first.registrationNumbers.every((registrationNumber, index) => registrationNumber === second.registrationNumbers[index]);
const parseImage = async (image: LPRFrameInput) => {
if (views.attachmentView.value) {
rememberLatestCameraImage(image);
return;
}
if (shouldSkipLPRForCurrentState() || isLPRFrameProcessing.value || isLPRRequestInFlight.value) {
return;
}
// Check if the time since the last successful parse is enough // Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) { if (!camera.hasDelayAfterSuccessPassed()) {
return; return;
} }
if (lastParsedImage.value === image) {
// If the image is the same as the last parsed one, skip parsing isLPRFrameProcessing.value = true;
return; try {
} const clientPreflightStartedAt = nowMs();
lastParsedImage.value = image; // Update the last parsed image const isDuplicateFrame = await shouldSkipDuplicateFrame(image);
camera.setLatestImage(image); // Update the latest image in the camera object const clientPreflightDurationMs = Math.max(0, nowMs() - clientPreflightStartedAt);
// Function to parse the image data
const compressedImage = await compressImageForLPR(image); if (shouldSkipLPRForCurrentState()) {
SessionUser.request("/modules/scanner/lpr", "POST", { return;
base64_image: compressedImage, }
})
.then((response) => { if (isDuplicateFrame) {
// If the image is the same as the last parsed one, skip parsing
scheduleDuplicateFrameBackoff();
return;
}
rememberParsedImageFingerprint(image); // Update the last parsed image
isLPRRequestInFlight.value = true;
const abortController = new AbortController();
lprRequestAbortController = abortController;
const requestScanContext = getLPRScanContext();
const lprRequest = buildLPRRequestPayload(image, clientPreflightDurationMs);
try {
const response = await SessionUser.request(
lprRequest.url,
"POST",
lprRequest.payload,
null,
null,
{
...(lprRequest.headers ? { headers: lprRequest.headers } : {}),
signal: abortController.signal,
transport: "fetch",
}
);
if (debug_mode.value) { if (debug_mode.value) {
debug_request_results.value.push(response); debug_request_results.value.push(response);
} }
// If the response is not successful, stop here.
if (!response.data.success) { if (!isSameLPRScanContext(requestScanContext, getLPRScanContext())) {
return; return;
} }
// If the response is not successful, stop here.
if (!response.data.success) {
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
return;
}
resetNoPlateBackoff();
markLastParsedImageOutcome("success");
rememberLatestCameraImage(image);
latestLPRResponse.value = response.data.data as LPRResponse; latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time // Set the last successful capture time
camera.setLastSuccess(); camera.setLastSuccess();
scheduleSuccessCooldown();
// Handle parsed result. // Handle parsed result.
handleLPRResult(); handleLPRResult();
}) } catch (error) {
.catch((error) => { if (isAbortError(error)) {
return;
}
if (debug_mode.value) { if (debug_mode.value) {
debug_request_results.value.push(error); debug_request_results.value.push(error);
} }
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
//console.error("Error parsing image:", error); //console.error("Error parsing image:", error);
}); } finally {
if (lprRequestAbortController === abortController) {
lprRequestAbortController = null;
}
isLPRRequestInFlight.value = false;
}
} finally {
isLPRFrameProcessing.value = false;
}
}; };
watch(manualInput, (newValue) => { watch(manualInput, (newValue) => {
// Update the header transparency when manualInput changes // Update the header transparency when manualInput changes
setTransparency(!newValue); setTransparency(!newValue);
}); });
type statusIcon =
| typeof VerifiedCustomer
| typeof KnownCustomer
| typeof UnknownCustomer
| typeof CardPaymentCustomer
| typeof BookedCustomer;
const registrationNumbers = computed(() => { const registrationNumbers = computed(() => {
// Return the registration numbers of all vehicles // Return the registration numbers of all vehicles
return [ return [
@@ -173,6 +645,14 @@ const registrationNumbers = computed(() => {
]; ];
}); });
const getLPRScanContext = (): LPRScanContext => ({
activeVehicleIndex: vehicles.activeVehicleIndex.value,
attachmentView: views.attachmentView.value,
manualInput: manualInput.value,
registrationNumbers: [...registrationNumbers.value],
transactionHistoryView: transactionHistoryView.value,
});
function getSearchResultIndex(object: PosSearchResult) { function getSearchResultIndex(object: PosSearchResult) {
let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index
// Check if the registration number already exists in the vehicles (And update the vehicle if it does) // Check if the registration number already exists in the vehicles (And update the vehicle if it does)
@@ -228,27 +708,49 @@ const getQuery = computed(() => {
return activeVehicle ? activeVehicle.reg : ""; return activeVehicle ? activeVehicle.reg : "";
}); });
const activeSelectionHasRegistrationNumber = computed(() => {
return vehicles.getActiveVehicle()?.reg && vehicles.getActiveVehicle()?.reg.length > 0;
});
const shouldCustomerBeModified = computed(() => { const shouldCustomerBeModified = computed(() => {
// Check if the customer should be modified based on the active vehicle index // Check if the customer should be modified based on the active vehicle index
return vehicles.activeVehicleIndex.value === 1; return vehicles.activeVehicleIndex.value === 1;
}); });
onMounted(() => { onMounted(() => {
document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
// Set the header to be transparent // Set the header to be transparent
setTransparency(!views.isAnyActive.value); setTransparency(!views.isAnyActive.value);
setBackgroundColor(backgroundColors.default); // Set the default background color setBackgroundColor(backgroundColors.default); // Set the default background color
setOverflow(false); // Prevent scrolling setOverflow(false); // Prevent scrolling
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
abortLPRRequest();
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
// Reset the header settings when the component is unmounted // Reset the header settings when the component is unmounted
setTransparency(false); // Reset transparency setTransparency(false); // Reset transparency
setBackgroundColor(backgroundColors.default); // Reset to default background color setBackgroundColor(backgroundColors.default); // Reset to default background color
setOverflow(true); // Allow scrolling again setOverflow(true); // Allow scrolling again
}); });
watch(
() => [manualInput.value, transactionHistoryView.value, views.attachmentView.value],
([isManualInputActive, isTransactionHistoryActive, isAttachmentViewActive]) => {
if (isManualInputActive || isTransactionHistoryActive || isAttachmentViewActive) {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
}
);
watch(
() => [vehicles.activeVehicleIndex.value, ...registrationNumbers.value],
() => {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
);
</script> </script>
<template> <template>
@@ -263,7 +765,16 @@ onUnmounted(() => {
<!-- Default: Scanner view --> <!-- Default: Scanner view -->
<template v-else> <template v-else>
<div class="background-fixed"> <div class="background-fixed">
<ScannerCamera @update:frame="parseImage" /> <ScannerCamera
:capture-enabled="isCameraFrameCaptureEnabled"
:capture-interval-ms="lprCameraCaptureIntervalMs"
:capture-mode="views.attachmentView.value ? 'preview' : 'lpr'"
:get-focus-viewport-rect="getScannerFocusViewportRect"
:pause-preview="shouldPauseScannerPreview"
:should-build-visual-fingerprint="shouldBuildLPRVisualFingerprint"
:should-encode-frame="shouldEncodeLPRFrame"
@update:frame="parseImage"
/>
</div> </div>
<div class="custom-content" data-testid="pos-mobile-step-1"> <div class="custom-content" data-testid="pos-mobile-step-1">
<!-- Meta objects, registration number auto-lookup --> <!-- Meta objects, registration number auto-lookup -->
@@ -289,7 +800,13 @@ onUnmounted(() => {
/> />
<!-- Scanner outline object --> <!-- Scanner outline object -->
<div class="is-align-content-center is-flex is-justify-content-center"> <div class="is-align-content-center is-flex is-justify-content-center">
<ScannerOutline :loading="false" v-if="!views.attachmentView.value" /> <div
v-if="!views.attachmentView.value"
ref="scannerFocusRef"
class="scanner-focus-target"
>
<ScannerOutline :loading="false" />
</div>
</div> </div>
<!-- Reg. 1, Reg. 2, Reg. 3 --> <!-- Reg. 1, Reg. 2, Reg. 3 -->
<div <div
@@ -303,7 +820,10 @@ onUnmounted(() => {
<!-- Location --> <!-- Location -->
<PosDepartmentStepMobile1Location /> <PosDepartmentStepMobile1Location />
<!-- Buttons --> <!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step"> <PosDepartmentStepMobileFixedBottomControl
variant="pos-step"
:use-backdrop-blur="views.attachmentView.value"
>
<!-- Attachments --> <!-- Attachments -->
<div class="is-flex is-justify-content-center"> <div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" /> <PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
@@ -429,4 +949,10 @@ onUnmounted(() => {
.custom-content > * { .custom-content > * {
width: min(100%, 48rem); width: min(100%, 48rem);
} }
.scanner-focus-target {
display: inline-flex;
max-width: 100%;
width: fit-content;
}
</style> </style>
@@ -53,6 +53,9 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue"; import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js"; import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger"; import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
onMounted(() => { onMounted(() => {
// Set the header to be transparent // Set the header to be transparent
@@ -477,6 +480,8 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
const layout = { const layout = {
classes: <string[]>[], classes: <string[]>[],
}; };
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const onCopyLastOrder = (vehicleIndex: number) => { const onCopyLastOrder = (vehicleIndex: number) => {
lastOrders.select(vehicleIndex); lastOrders.select(vehicleIndex);
@@ -777,15 +782,18 @@ const buildDesiredOrderItemShapes = () => {
const addonShapes = (transactionItems.primaryItem.value.addons || []) const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => ({ .map((addon: any) => {
kind: "addon", const addonProduct = addon?.product ?? addon;
relatedKey: "primary", return {
product_id: Number(addon?.product?.id ?? addon?.id ?? 0), kind: "addon",
quantity: Number(addon?.quantity ?? 0), relatedKey: "primary",
related_item_id: "__PRIMARY__", product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
price: Number(addon?.product?.price ?? addon?.price ?? 0), quantity: Number(addon?.quantity ?? 0),
notes: String(addon?.product?.notes ?? ""), related_item_id: "__PRIMARY__",
})); price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
};
});
const additionalShapes = (transactionItems.additionalItems.value || []) const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -952,16 +960,17 @@ const syncCurrentTransactionToOrder = async () => {
const addonPromises = (transactionItems.primaryItem.value.addons || []) const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => .map((addon: any) => {
createOrderItem( const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId, normalizedOrderId,
addon.product.id, addonProduct.id,
Number(addon.quantity), Number(addon.quantity),
createdPrimaryItemId, createdPrimaryItemId,
addon.product?.notes || "", addonProduct?.notes || addon?.notes || "",
addon.product.price addonProduct.price ?? addon.price
) );
); });
const additionalPromises = (transactionItems.additionalItems.value || []) const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -973,11 +982,146 @@ const syncCurrentTransactionToOrder = async () => {
return true; return true;
}; };
const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
const productRequiresOrderItemNote = (product: any) => {
if (!product) {
return false;
}
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
const primaryProduct = transactionItems.primaryItem.value;
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
!productHasOrderItemNote(addonProduct) &&
!productHasOrderItemNote(addon)
) {
missingProducts.push(addonProduct);
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
}
});
return missingProducts;
};
const promptForRequiredProductNote = (product: any) =>
new Promise<boolean>((resolve) => {
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
const originalNote = product?.notes;
const resolveAndClose = (didConfirm: boolean) => {
if (!didConfirm) {
if (hadOriginalNote) {
product.notes = originalNote;
} else {
delete product.notes;
}
}
if (popups.get()?.id === "add_product_note") {
popups.clear();
}
resolve(didConfirm);
};
popups.select("add_product_note", {
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
message: t("objects.products.columns.requires_note"),
component: "add_product_note",
hideHeader: true,
style: { maxHeight: "40vh" },
props: {
product,
validationMessage: "",
},
actionButtons: [
{
label: t("common.confirm"),
description: t("common.confirm"),
color: "primary",
testId: "pos-mobile-product-note-confirm",
onClick: () => {
const activePopup = popups.get();
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
if (!normalizedNote) {
if (activePopup?.props) {
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
}
return;
}
product.notes = normalizedNote;
resolveAndClose(true);
},
},
{
label: t("common.cancel"),
description: t("common.cancel"),
color: "light",
testId: "pos-mobile-product-note-cancel",
onClick: () => resolveAndClose(false),
},
],
});
});
const ensureRequiredOrderItemNotes = async () => {
const missingProducts = getSelectedProductsMissingRequiredNotes();
for (const product of missingProducts) {
if (productHasOrderItemNote(product)) {
continue;
}
const didConfirm = await promptForRequiredProductNote(product);
if (!didConfirm) {
return false;
}
}
return true;
};
const onBeforeComplete = async () => { const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) { if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected"); throw new Error("No primary item selected");
} }
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
await syncCurrentTransactionToOrder(); await syncCurrentTransactionToOrder();
return true; return true;
}; };
@@ -2,25 +2,43 @@
import { useGeolocation } from "@vueuse/core"; import { useGeolocation } from "@vueuse/core";
import { watch } from "vue"; import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue"; import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void; const props = withDefaults(defineProps<{
}>(); enableHighAccuracy?: boolean;
const { coords, locatedAt, error, resume, pause } = useGeolocation({ maximumAge?: number;
timeout?: number;
}>(), {
enableHighAccuracy: true, enableHighAccuracy: true,
maximumAge: 30000, maximumAge: 30000,
timeout: 27000, timeout: 27000,
}); });
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: props.enableHighAccuracy,
maximumAge: props.maximumAge,
timeout: props.timeout,
});
const getLocationTimestamp = () => {
const timestamp = Number(locatedAt.value);
return Number.isFinite(timestamp) ? timestamp : Date.now();
};
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => { const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
if (newCoords.latitude && newCoords.longitude) { const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
if (normalizedCoords) {
const timestamp = getLocationTimestamp();
locations.set({ locations.set({
coords: { coords: normalizedCoords,
latitude: newCoords.latitude, timestamp: new Date(timestamp),
longitude: newCoords.longitude, locatedAt: timestamp,
},
timestamp: new Date(),
errorMessage: error.value ? error.value.message : null, errorMessage: error.value ? error.value.message : null,
}) })
emits('location-updated', newCoords); emits('location-updated', normalizedCoords);
} }
}; };
watch(coords, (newCoords) => { watch(coords, (newCoords) => {
@@ -405,7 +405,10 @@ const onClick = async () => {
isProcessingClick.value = true; isProcessingClick.value = true;
try { try {
await props.onBeforeStep(); const beforeStepResult = await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
// Proceed to the next step // Proceed to the next step
switch (step.value) { switch (step.value) {
case 1: case 1:
@@ -11,6 +11,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
useBackdropBlur: {
type: Boolean,
default: true,
},
variant: { variant: {
type: String, type: String,
default: 'default', default: 'default',
@@ -25,6 +29,13 @@ const props = defineProps({
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height'; const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
const style = computed(() => { const style = computed(() => {
const background = `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`;
if (!props.useBackdropBlur) {
return {
background,
};
}
// Smooth blur transition // Smooth blur transition
if (props.smoothBlur) { if (props.smoothBlur) {
return { return {
@@ -32,12 +43,13 @@ const style = computed(() => {
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`, WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`,
transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease', transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease',
// Add gradient from bottom // Add gradient from bottom
background: `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`, background,
// Add gradient from top // Add gradient from top
} }
} }
return { return {
background,
backdropFilter: `blur(${props.blurAmount * 10}px)`, backdropFilter: `blur(${props.blurAmount * 10}px)`,
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)` WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`
} }
@@ -28,6 +28,7 @@ watch(note, (newNote) => {
class="input is-searched" class="input is-searched"
v-model="note" v-model="note"
type="text" type="text"
data-testid="pos-mobile-product-note-input"
placeholder="Indtast note" placeholder="Indtast note"
/> />
</div> </div>
@@ -100,18 +100,57 @@ const getLocation = () => {
const clearLocation = () => { const clearLocation = () => {
location.value = null; location.value = null;
}; };
type CoordinatePair = {
latitude?: number | string | null;
longitude?: number | string | null;
};
export const normalizeCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
): { latitude: number; longitude: number } | null => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
};
export const hasValidCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
options: { allowZeroPair?: boolean } = {}
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
// Get distance in kilometers between two locations // Get distance in kilometers between two locations
const getDistance = ( const getDistance = (
from: { latitude: number; longitude: number }, from: CoordinatePair,
to: { latitude: number; longitude: number } to: CoordinatePair
): number => { ): number => {
const normalizedFrom = normalizeCoordinatePair(from);
const normalizedTo = normalizeCoordinatePair(to);
if (!normalizedFrom || !normalizedTo) {
return Number.POSITIVE_INFINITY;
}
const toRad = (value: number) => (value * Math.PI) / 180; const toRad = (value: number) => (value * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(to.latitude - from.latitude); const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
const dLon = toRad(to.longitude - from.longitude); const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
const lat1 = toRad(from.latitude); const lat1 = toRad(normalizedFrom.latitude);
const lat2 = toRad(to.latitude); const lat2 = toRad(normalizedTo.latitude);
const a = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
@@ -124,6 +163,8 @@ const locations = {
set: setLocation, set: setLocation,
get: getLocation, get: getLocation,
clear: clearLocation, clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance, getDistance,
defaultTimeout: locationTimeout, defaultTimeout: locationTimeout,
}; };
@@ -1024,9 +1065,9 @@ const getTotalAttachmentsCount = () => {
); );
}; };
// Function to take a picture as a base64 attachment // Function to take a picture as a base64 attachment
const takePicture = () => { const takePicture = async () => {
// Save the last picture to the base64 attachments // Save the last picture to the base64 attachments
const lastPicture = latestImage.value; const lastPicture = await getLatestImage();
if (lastPicture) { if (lastPicture) {
addAttachmentBase64({ addAttachmentBase64({
filename: "last_picture.jpg", filename: "last_picture.jpg",
@@ -1077,6 +1118,7 @@ watch(
/** Camera */ /** Camera */
// Define the reactive properties // Define the reactive properties
const latestImage = ref<string | null>(null); const latestImage = ref<string | null>(null);
const latestImageBlob = ref<Blob | null>(null);
const isCameraMounted = ref<boolean>(false); const isCameraMounted = ref<boolean>(false);
const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture) const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture)
const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one) const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one)
@@ -1118,13 +1160,34 @@ const hasCameraImageCaptureDelayAfterSuccessPassed = (): boolean => {
const currentTime = Date.now(); const currentTime = Date.now();
return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value; return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value;
}; };
const blobToDataUrl = (blob: Blob): Promise<string | null> => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => {
resolve(null);
};
reader.readAsDataURL(blob);
});
// Function to retrieve the latest image frame. // Function to retrieve the latest image frame.
const getLatestImage = async () => { const getLatestImage = async () => {
if (!latestImage.value && latestImageBlob.value) {
latestImage.value = await blobToDataUrl(latestImageBlob.value);
}
return latestImage.value; return latestImage.value;
}; };
// Function to set the latest image frame. // Function to set the latest image frame.
const setLatestImage = (image: string | null) => { const setLatestImage = (image: string | null) => {
latestImage.value = image; latestImage.value = image;
latestImageBlob.value = null;
};
// Function to set the latest image frame as a Blob.
const setLatestImageBlob = (image: Blob | null) => {
latestImage.value = null;
latestImageBlob.value = image;
}; };
// Function to set the camera mounted state. // Function to set the camera mounted state.
const setCameraMounted = (mounted: boolean) => { const setCameraMounted = (mounted: boolean) => {
@@ -1133,6 +1196,7 @@ const setCameraMounted = (mounted: boolean) => {
// Function to clear the latest image. // Function to clear the latest image.
const clearLatestImage = () => { const clearLatestImage = () => {
latestImage.value = null; latestImage.value = null;
latestImageBlob.value = null;
}; };
// Function to clear the camera mounted state. // Function to clear the camera mounted state.
const clearCameraMounted = () => { const clearCameraMounted = () => {
@@ -1153,10 +1217,12 @@ const setCameraImageCaptureDelay = (isFirstCapture: boolean, delay: number) => {
const camera = { const camera = {
latestImage, latestImage,
latestImageBlob,
get: getLatestImage, get: getLatestImage,
mounted: isCameraMounted, mounted: isCameraMounted,
setMounted: setCameraMounted, setMounted: setCameraMounted,
setLatestImage, setLatestImage,
setLatestImageBlob,
clearLatestImage, clearLatestImage,
clearMounted: clearCameraMounted, clearMounted: clearCameraMounted,
getImageCaptureDelay: getCameraImageCaptureDelay, getImageCaptureDelay: getCameraImageCaptureDelay,
@@ -18,7 +18,9 @@ export type PosLocation = {
coords?: PosLocationCoords | null; coords?: PosLocationCoords | null;
/** Timestamp */ /** Timestamp */
timestamp?: Date | null; timestamp?: Date | null;
/** Browser geolocation timestamp in milliseconds */
locatedAt?: number | null;
/** Error message */ /** Error message */
errorMessage?: string | null; errorMessage?: string | null;
}; };
</script> </script>
@@ -106,8 +106,7 @@ const onDynamicImageError = () => {
/> />
</div> </div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4"> <div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1> <h1 class="title has-text-centered mb-2">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
<SelfServeTaskList <SelfServeTaskList
:tasks="activeTasks" :tasks="activeTasks"
:completedTasks="completedTasks" :completedTasks="completedTasks"
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js"; import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js"; import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
@@ -10,6 +11,7 @@ const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false); const isOpen = ref(false);
const isBusy = ref(false); const isBusy = ref(false);
const isClearConfirmationOpen = ref(false);
const shiftPresses = ref([]); const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit)); const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
@@ -18,6 +20,7 @@ const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.lates
const closeMenu = () => { const closeMenu = () => {
if (!isBusy.value) { if (!isBusy.value) {
isOpen.value = false; isOpen.value = false;
isClearConfirmationOpen.value = false;
} }
}; };
@@ -34,8 +37,18 @@ const handleKeydown = (event) => {
} }
}; };
const openClearConfirmation = () => {
if (!isBusy.value) {
isClearConfirmationOpen.value = true;
}
};
const closeClearConfirmation = () => {
isClearConfirmationOpen.value = false;
};
const forceUpdateAndClearLocal = async () => { const forceUpdateAndClearLocal = async () => {
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) { if (isBusy.value) {
return; return;
} }
@@ -84,7 +97,7 @@ onBeforeUnmount(() => {
class="frontend-maintenance-menu__danger" class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear" data-testid="frontend-maintenance-force-clear"
:disabled="isBusy" :disabled="isBusy"
@click="forceUpdateAndClearLocal" @click="openClearConfirmation"
> >
<i class="fas fa-sync-alt" aria-hidden="true"></i> <i class="fas fa-sync-alt" aria-hidden="true"></i>
<span> <span>
@@ -93,6 +106,12 @@ onBeforeUnmount(() => {
</span> </span>
</button> </button>
</section> </section>
<LocalDataResetDialog
v-model="isClearConfirmationOpen"
:busy="isBusy"
@confirm="forceUpdateAndClearLocal"
@dismiss="closeClearConfirmation"
/>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -0,0 +1,156 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "confirm", "dismiss"]);
const dismissDialog = () => {
emit("dismiss");
emit("update:modelValue", false);
};
const confirmDialog = () => {
emit("confirm");
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
<button
type="button"
class="local-data-reset-dialog__backdrop"
aria-label="Luk"
:disabled="busy"
@click="dismissDialog"
></button>
<section
class="local-data-reset-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="local-data-reset-dialog-title"
>
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
<p>
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata denne enhed. Du bliver
logget ud.
</p>
<footer class="local-data-reset-dialog__actions">
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
data-testid="local-data-reset-cancel"
:disabled="busy"
@click="dismissDialog"
>
Nej
</button>
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
data-testid="local-data-reset-confirm"
:disabled="busy"
@click="confirmDialog"
>
Ja, ryd alt
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.local-data-reset-dialog {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
pointer-events: none;
}
.local-data-reset-dialog__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(9, 20, 33, 0.48);
cursor: pointer;
pointer-events: auto;
}
.local-data-reset-dialog__backdrop:disabled {
cursor: wait;
}
.local-data-reset-dialog__panel {
position: relative;
width: min(430px, calc(100vw - 36px));
border: 1px solid #d5dde8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
color: #172033;
pointer-events: auto;
}
.local-data-reset-dialog__panel h2 {
margin: 0;
padding: 18px 18px 8px;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0;
}
.local-data-reset-dialog__panel p {
margin: 0;
padding: 0 18px 16px;
color: #475467;
font-size: 0.92rem;
font-weight: 550;
line-height: 1.42;
}
.local-data-reset-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px 18px;
border-top: 1px solid #edf1f5;
}
.local-data-reset-dialog__button {
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.86rem;
font-weight: 800;
}
.local-data-reset-dialog__button:disabled {
cursor: wait;
opacity: 0.68;
}
.local-data-reset-dialog__button--secondary {
border: 1px solid #cfd8e3;
background: #ffffff;
color: #334155;
}
.local-data-reset-dialog__button--danger {
border: 1px solid #b42318;
background: #b42318;
color: #ffffff;
}
</style>
+194 -11
View File
@@ -41,6 +41,12 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
iconClass: "fa-calendar-alt", iconClass: "fa-calendar-alt",
matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url), matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url),
}, },
{
key: "scanner",
label: "Scanner",
iconClass: "fa-camera",
matcher: (url) => /\/modules\/scanner\/lpr(?:[/?#]|$)/i.test(url),
},
]); ]);
const SHIFT_MULTI_PRESS_WINDOW_MS = 700; const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close"; const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
@@ -62,6 +68,7 @@ const processedRequests = computed(() => requestQueueState.batchCompleted + requ
const missingPermissions = computed(() => requestQueueState.missingPermissions || []); const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
const activeRequests = computed(() => requestQueueState.activeRequests || []); const activeRequests = computed(() => requestQueueState.activeRequests || []);
const recentRequests = computed(() => requestQueueState.recentRequests || []); const recentRequests = computed(() => requestQueueState.recentRequests || []);
const queueRequestInsights = computed(() => requestQueueState.requestInsights || {});
const errorRequests = computed(() => requestQueueState.errorRequests || []); const errorRequests = computed(() => requestQueueState.errorRequests || []);
const networkTotals = computed(() => requestQueueState.networkTotals || { const networkTotals = computed(() => requestQueueState.networkTotals || {
outgoingRequests: 0, outgoingRequests: 0,
@@ -108,7 +115,7 @@ const userTypeLabel = computed(() => (SessionUser.isSubuser.value ? "Subuser" :
const hasSuperuserToken = computed(() => { const hasSuperuserToken = computed(() => {
try { try {
return Boolean(localStorage.getItem("superuser_token")); return Boolean(localStorage.getItem("superuser_token"));
} catch (error) { } catch (_error) {
return false; return false;
} }
}); });
@@ -191,6 +198,152 @@ const formatBytes = (value) => {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}; };
const parseServerTimingDurations = (value) => {
if (typeof value !== "string" || value.trim().length === 0) {
return {};
}
return value.split(",").reduce((durations, part) => {
const [rawMetric, ...rawParams] = part.trim().split(";");
const metric = rawMetric.trim();
if (!metric) {
return durations;
}
const durationParam = rawParams.find((param) => param.trim().toLowerCase().startsWith("dur="));
if (!durationParam) {
return durations;
}
const duration = Number.parseFloat(durationParam.split("=").slice(1).join("="));
if (Number.isFinite(duration)) {
durations[metric] = duration;
}
return durations;
}, {});
};
const findServerTimingDuration = (durations, metricNames) => {
const metricName = metricNames.find((name) => Number.isFinite(durations[name]));
return metricName ? durations[metricName] : null;
};
const formatScannerFrameSize = (width, height) => {
const roundedWidth = Math.round(Number(width) || 0);
const roundedHeight = Math.round(Number(height) || 0);
if (roundedWidth <= 0 || roundedHeight <= 0) {
return null;
}
return `img ${roundedWidth}x${roundedHeight}`;
};
const formatScannerFrameBytes = (bytes) => {
const roundedBytes = Math.round(Number(bytes) || 0);
if (roundedBytes <= 0) {
return null;
}
return `bytes ${formatBytes(roundedBytes)}`;
};
const getBrowserNetworkDuration = (requestDurationMs, serverDurationMs) => {
if (serverDurationMs === null) {
return null;
}
const durationMs = Number(requestDurationMs) - Number(serverDurationMs);
if (!Number.isFinite(durationMs) || durationMs < 1) {
return null;
}
return durationMs;
};
const formatRequestLatency = (definition, request) => {
const requestDurationMs = Math.max(0, Number(request?.requestDurationMs) || 0);
if (definition.key !== "scanner") {
return formatDuration(requestDurationMs);
}
const queueDurationMs = Math.max(0, Number(request?.queueDurationMs) || 0);
const durations = parseServerTimingDurations(request?.serverTiming);
const captureDurationMs = findServerTimingDuration(durations, ["lpr_client_capture"]);
const preflightDurationMs = findServerTimingDuration(durations, ["lpr_client_preflight"]);
const visualFingerprintDurationMs = findServerTimingDuration(durations, ["lpr_client_visual_fingerprint"]);
const drawDurationMs = findServerTimingDuration(durations, ["lpr_client_draw"]);
const encodeDurationMs = findServerTimingDuration(durations, ["lpr_client_encode"]);
const clientFrameWidth = findServerTimingDuration(durations, ["lpr_client_frame_width"]);
const clientFrameHeight = findServerTimingDuration(durations, ["lpr_client_frame_height"]);
const clientFrameBytes = findServerTimingDuration(durations, ["lpr_client_frame_bytes"]);
const cacheDurationMs = findServerTimingDuration(durations, ["lpr_cache"]);
const cacheHit = findServerTimingDuration(durations, ["lpr_cache_hit"]) !== null;
const cacheMiss = findServerTimingDuration(durations, ["lpr_cache_miss"]) !== null;
const localDurationMs = findServerTimingDuration(durations, ["lpr_local"]);
const upstreamProcessingDurationMs = findServerTimingDuration(durations, ["lpr_upstream_processing"]);
const upstreamDurationMs = findServerTimingDuration(durations, ["lpr_upstream_total", "lpr_upstream"]);
const serverDurationMs = findServerTimingDuration(durations, ["lpr_total", "lpr_route_total", "lpr_request_total"]);
const browserNetworkDurationMs = getBrowserNetworkDuration(requestDurationMs, serverDurationMs);
const timingParts = [`browser ${formatDuration(requestDurationMs)}`];
const frameSize = formatScannerFrameSize(clientFrameWidth, clientFrameHeight);
const frameBytes = formatScannerFrameBytes(clientFrameBytes);
if (queueDurationMs > 0) {
timingParts.push(`queue ${formatDuration(queueDurationMs)}`);
}
if (browserNetworkDurationMs !== null) {
timingParts.push(`net ${formatDuration(browserNetworkDurationMs)}`);
}
if (captureDurationMs !== null) {
timingParts.push(`cap ${formatDuration(captureDurationMs)}`);
}
if (preflightDurationMs !== null) {
timingParts.push(`prep ${formatDuration(preflightDurationMs)}`);
}
if (visualFingerprintDurationMs !== null) {
timingParts.push(`vf ${formatDuration(visualFingerprintDurationMs)}`);
}
if (drawDurationMs !== null) {
timingParts.push(`draw ${formatDuration(drawDurationMs)}`);
}
if (encodeDurationMs !== null) {
timingParts.push(`enc ${formatDuration(encodeDurationMs)}`);
}
if (frameSize !== null) {
timingParts.push(frameSize);
}
if (frameBytes !== null) {
timingParts.push(frameBytes);
}
if (cacheHit) {
timingParts.push("cache hit");
} else if (cacheMiss) {
timingParts.push("cache miss");
}
if (cacheDurationMs !== null) {
timingParts.push(`cache ${formatDuration(cacheDurationMs)}`);
}
if (localDurationMs !== null) {
timingParts.push(`local ${formatDuration(localDurationMs)}`);
}
if (upstreamProcessingDurationMs !== null) {
timingParts.push(`proc ${formatDuration(upstreamProcessingDurationMs)}`);
}
if (upstreamDurationMs !== null) {
timingParts.push(`up ${formatDuration(upstreamDurationMs)}`);
}
if (serverDurationMs !== null) {
timingParts.push(`srv ${formatDuration(serverDurationMs)}`);
}
return timingParts.join(" / ");
};
const formatTimeAgo = (timestamp) => { const formatTimeAgo = (timestamp) => {
const value = Number(timestamp || 0); const value = Number(timestamp || 0);
if (!value || Number.isNaN(value)) { if (!value || Number.isNaN(value)) {
@@ -219,43 +372,67 @@ const getActiveRequestElapsedMs = (request) => Math.max(0, nowMs.value - Number(
const hasInsightEntryChanged = (currentEntry, nextEntry) => const hasInsightEntryChanged = (currentEntry, nextEntry) =>
Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0) Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0)
|| Number(currentEntry?.queueDurationMs || 0) !== Number(nextEntry?.queueDurationMs || 0)
|| Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0) || Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0)
|| Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0) || Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0)
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0); || Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0)
|| String(currentEntry?.serverTiming || "") !== String(nextEntry?.serverTiming || "");
const getInsightEntryTimestamp = (entry) =>
Number(entry?.completedAt || entry?.startedAt || entry?.queuedAt || 0);
const selectNewestInsightEntry = (...entries) =>
entries
.filter((entry) => entry !== null && entry !== undefined)
.sort((first, second) => getInsightEntryTimestamp(second) - getInsightEntryTimestamp(first))[0] || null;
const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => { const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => {
const matchedRequestFromRecent = recentRequests.value.find((request) => const matchedRequestFromRecent = recentRequests.value.find((request) =>
definition.matcher(String(request?.url || "")) definition.matcher(String(request?.url || ""))
) || null; ) || null;
const matchedRequest = matchedRequestFromRecent || requestInsightHistory.value[definition.key] || null; const matchedRequestFromQueueInsight = queueRequestInsights.value[definition.key] || null;
const matchedRequest = selectNewestInsightEntry(
matchedRequestFromRecent,
matchedRequestFromQueueInsight,
requestInsightHistory.value[definition.key],
);
const hasData = matchedRequest !== null; const hasData = matchedRequest !== null;
return { return {
...definition, ...definition,
latencyText: hasData ? `${Math.max(0, Number(matchedRequest.requestDurationMs) || 0)} ms` : " ", latencyText: hasData ? formatRequestLatency(definition, matchedRequest) : " ",
serverTiming: matchedRequest?.serverTiming || "",
timeAgoText: hasData timeAgoText: hasData
? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt) ? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt)
: "", : "",
}; };
})); }));
watch(recentRequests, (requests) => { watch([recentRequests, queueRequestInsights], ([requests, insights]) => {
if (!Array.isArray(requests) || requests.length === 0) { const recentRequestList = Array.isArray(requests) ? requests : [];
const insightEntries = insights && typeof insights === "object" ? insights : {};
if (recentRequestList.length === 0 && Object.keys(insightEntries).length === 0) {
return; return;
} }
const nextHistory = { ...requestInsightHistory.value }; const nextHistory = { ...requestInsightHistory.value };
let hasChanges = false; let hasChanges = false;
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => { REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
const matchedRequest = requests.find((request) => const matchedRequest = selectNewestInsightEntry(
definition.matcher(String(request?.url || "")) recentRequestList.find((request) =>
definition.matcher(String(request?.url || ""))
),
insightEntries[definition.key],
); );
if (!matchedRequest) { if (!matchedRequest) {
return; return;
} }
const nextEntry = { const nextEntry = {
serverTiming: matchedRequest.serverTiming || null,
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0), requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
queueDurationMs: Math.max(0, Number(matchedRequest.queueDurationMs) || 0),
completedAt: matchedRequest.completedAt || null, completedAt: matchedRequest.completedAt || null,
startedAt: matchedRequest.startedAt || null, startedAt: matchedRequest.startedAt || null,
queuedAt: matchedRequest.queuedAt || null, queuedAt: matchedRequest.queuedAt || null,
@@ -329,7 +506,7 @@ const measurePingLatency = async () => {
queuedAt: startedAt, queuedAt: startedAt,
}, },
}; };
} catch (error) { } catch (_error) {
pingLatencyMs.value = null; pingLatencyMs.value = null;
pingIsUnavailable.value = true; pingIsUnavailable.value = true;
} finally { } finally {
@@ -574,7 +751,7 @@ onBeforeUnmount(() => {
{{ request.method }} {{ request.method }}
</span> </span>
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span> <span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span>
<span class="request-queue-progress__time"> <span class="request-queue-progress__time" :title="request.serverTiming || ''">
{{ formatDuration(request.requestDurationMs) }} {{ formatDuration(request.requestDurationMs) }}
</span> </span>
</li> </li>
@@ -595,7 +772,9 @@ onBeforeUnmount(() => {
{{ insight.label }} {{ insight.label }}
</span> </span>
<span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</span> <span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</span>
<span class="request-queue-progress__bottom-request-latency">{{ insight.latencyText }}</span> <span class="request-queue-progress__bottom-request-latency" :title="insight.serverTiming">
{{ insight.latencyText }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1481,6 +1660,10 @@ onBeforeUnmount(() => {
.request-queue-progress__bottom-request-latency { .request-queue-progress__bottom-request-latency {
font-size: 11px; font-size: 11px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
max-width: 190px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
+193 -2
View File
@@ -15,6 +15,37 @@ const getSelectedCustomerNumber = () => {
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash'; const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']); const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
const SELF_SERVE_HARDWARE_QUEUE_GROUP = 'SELF_SERVE_HARDWARE';
const POS_SCANNER_QUEUE_GROUP = 'POS_SCANNER';
const POS_STRIPE_QUEUE_GROUP = 'POS_STRIPE';
const FETCH_TRANSPORT = 'fetch';
const SELF_SERVE_HARDWARE_ENDPOINTS = [
'/modules/self-serve/lane/command',
'/modules/self-serve/lane/relay/',
'/modules/self-serve/lane/gate/open',
'/modules/self-serve/lane/force/machine',
];
const POS_LATENCY_QUEUE_RULES = [
{
endpoints: ['/modules/scanner/lpr'],
queueGroup: POS_SCANNER_QUEUE_GROUP,
concurrencyLimit: 1,
retryByStatusCode: {},
skipRequestByteAccounting: true,
skipResponseByteAccounting: true,
skipNetworkTotals: true,
insightKey: 'scanner',
recordRecentOnSuccess: false,
trackActiveRequest: false,
trackProgressCounters: false,
},
{
endpoints: ['/modules/stripe/invoice'],
queueGroup: POS_STRIPE_QUEUE_GROUP,
concurrencyLimit: 2,
retryByStatusCode: {},
},
];
const normalizeStatus = (status) => String(status || '').trim().toUpperCase(); const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
@@ -65,6 +96,153 @@ const normalizeActiveWashResponse = (url, method, response) => {
}; };
}; };
const isSelfServeHardwareMutation = (url, method) => {
const normalizedMethod = String(method || '').trim().toUpperCase();
if (normalizedMethod === 'GET') {
return false;
}
const normalizedUrl = String(url || '');
return SELF_SERVE_HARDWARE_ENDPOINTS.some((endpoint) => normalizedUrl.includes(endpoint));
};
const findPosLatencyQueueRule = (url, method) => {
const normalizedMethod = String(method || '').trim().toUpperCase();
if (normalizedMethod === 'GET') {
return null;
}
const normalizedUrl = String(url || '');
return POS_LATENCY_QUEUE_RULES.find((rule) =>
rule.endpoints.some((endpoint) => normalizedUrl.includes(endpoint))
) || null;
};
const hasHeader = (headers, name) => {
const normalizedName = String(name || '').trim().toLowerCase();
if (!normalizedName || !headers || typeof headers !== 'object') {
return false;
}
return Object.keys(headers).some((headerName) => String(headerName).toLowerCase() === normalizedName);
};
const parseFetchResponseData = async (response) => {
const text = await response.text();
if (!text) {
return null;
}
const contentType = response.headers?.get?.('content-type') || '';
if (
contentType.toLowerCase().includes('application/json') ||
text.trim().startsWith('{') ||
text.trim().startsWith('[')
) {
try {
return JSON.parse(text);
} catch (_error) {
return text;
}
}
return text;
};
const buildFetchBody = (method, data, headers) => {
if (String(method || '').trim().toUpperCase() === 'GET' || data === undefined || data === null) {
return undefined;
}
if (
typeof Blob !== 'undefined' && data instanceof Blob ||
typeof FormData !== 'undefined' && data instanceof FormData ||
typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams ||
typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer ||
typeof ReadableStream !== 'undefined' && data instanceof ReadableStream ||
typeof data === 'string'
) {
return data;
}
if (!hasHeader(headers, 'Content-Type')) {
headers['Content-Type'] = 'application/json';
}
return JSON.stringify(data);
};
const executeFetchRequest = async ({ method, url, data, signal, headers }) => {
const fetchHeaders = { ...headers };
const body = buildFetchBody(method, data, fetchHeaders);
const response = await fetch(url, {
method,
headers: fetchHeaders,
...(body !== undefined ? { body } : {}),
...(signal ? { signal } : {}),
});
const responseData = await parseFetchResponseData(response);
const axiosLikeResponse = {
data: responseData,
status: response.status,
statusText: response.statusText,
headers: response.headers,
config: {
data,
headers: fetchHeaders,
method,
url,
},
request: null,
};
if (response.ok) {
return axiosLikeResponse;
}
const error = new Error(`Request failed with status code ${response.status}`);
error.name = 'AxiosError';
error.response = axiosLikeResponse;
throw error;
};
const buildRequestQueueOptions = (url, method, options = {}) => {
const queueOptions = {
retryByStatusCode: options?.retryByStatusCode,
shouldRetry: options?.shouldRetry,
queueGroup: options?.queueGroup,
concurrencyLimit: options?.concurrencyLimit,
skipRequestByteAccounting: options?.skipRequestByteAccounting,
skipResponseByteAccounting: options?.skipResponseByteAccounting,
skipNetworkTotals: options?.skipNetworkTotals,
insightKey: options?.insightKey,
recordRecentOnSuccess: options?.recordRecentOnSuccess,
trackActiveRequest: options?.trackActiveRequest,
trackProgressCounters: options?.trackProgressCounters,
};
if (isSelfServeHardwareMutation(url, method)) {
queueOptions.retryByStatusCode ??= {};
queueOptions.queueGroup ??= SELF_SERVE_HARDWARE_QUEUE_GROUP;
queueOptions.concurrencyLimit ??= 1;
}
const posQueueRule = findPosLatencyQueueRule(url, method);
if (posQueueRule) {
queueOptions.retryByStatusCode ??= posQueueRule.retryByStatusCode;
queueOptions.queueGroup ??= posQueueRule.queueGroup;
queueOptions.concurrencyLimit ??= posQueueRule.concurrencyLimit;
queueOptions.skipRequestByteAccounting ??= posQueueRule.skipRequestByteAccounting;
queueOptions.skipResponseByteAccounting ??= posQueueRule.skipResponseByteAccounting;
queueOptions.skipNetworkTotals ??= posQueueRule.skipNetworkTotals;
queueOptions.insightKey ??= posQueueRule.insightKey;
queueOptions.recordRecentOnSuccess ??= posQueueRule.recordRecentOnSuccess;
queueOptions.trackActiveRequest ??= posQueueRule.trackActiveRequest;
queueOptions.trackProgressCounters ??= posQueueRule.trackProgressCounters;
}
return queueOptions;
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => { export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) { if (!token) {
@@ -77,6 +255,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
// Build headers // Build headers
const headers = { const headers = {
...buildCurrentReleaseHeaders(), ...buildCurrentReleaseHeaders(),
...(options?.headers || {}),
}; };
if (canSendCredentials && token && token.length > 0) { if (canSendCredentials && token && token.length > 0) {
headers.Authorization = `Bearer ${token}`; headers.Authorization = `Bearer ${token}`;
@@ -89,8 +268,18 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
headers['X-Customer-Number'] = selectedCustomerNumber; headers['X-Customer-Number'] = selectedCustomerNumber;
} }
const useFetchTransport = options?.transport === FETCH_TRANSPORT;
return enqueueRequest( return enqueueRequest(
() => axios({ () => useFetchTransport
? executeFetchRequest({
method,
url: requestUrl,
data,
signal: options?.signal,
headers,
})
: axios({
method, method,
url: requestUrl, url: requestUrl,
...(method === 'GET' ? { params: data } : { data }), ...(method === 'GET' ? { params: data } : { data }),
@@ -105,7 +294,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
params: method === 'GET' ? data : null, params: method === 'GET' ? data : null,
data: method === 'GET' ? null : data, data: method === 'GET' ? null : data,
headers, headers,
} },
signal: options?.signal,
...buildRequestQueueOptions(requestUrl, method, options),
} }
) )
.catch((error) => { .catch((error) => {
@@ -4,7 +4,6 @@ import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/Obje
import {SessionUser} from "@/components/session/token/SessionUser.vue"; import {SessionUser} from "@/components/session/token/SessionUser.vue";
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue"; import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { editOrderItem, getOrderItems } from "@/components/shop/OrdersItems.vue";
import {createApp} from "vue"; import {createApp} from "vue";
import i18n from '@/i18n'; import i18n from '@/i18n';
import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js"; import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js";
@@ -265,6 +264,19 @@ const refreshDraftNavigationCount = () => {
dispatchNavigationCountRefresh(); dispatchNavigationCountRefresh();
}; };
const getOrderItemsForRepricing = (orderId) => authenticatedRequest('/order/items', 'GET', {
order_id: orderId,
});
const editOrderItemForRepricing = ({ id, price, notes, reference, quantity }) => authenticatedRequest('/order/items', 'PUT', {
id,
price,
notes,
reference,
quantity,
});
const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => { const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => {
const normalizedProductId = normalizePositiveInteger(productId); const normalizedProductId = normalizePositiveInteger(productId);
const normalizedDepartmentId = normalizePositiveInteger(departmentId); const normalizedDepartmentId = normalizePositiveInteger(departmentId);
@@ -298,7 +310,7 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
throw new Error("Invalid order repricing context"); throw new Error("Invalid order repricing context");
} }
const response = await getOrderItems(normalizedOrderId); const response = await getOrderItemsForRepricing(normalizedOrderId);
const orderItems = Array.isArray(response?.data?.data) ? response.data.data : []; const orderItems = Array.isArray(response?.data?.data) ? response.data.data : [];
const uniqueProductIds = [...new Set( const uniqueProductIds = [...new Set(
orderItems orderItems
@@ -329,13 +341,13 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
return null; return null;
} }
return editOrderItem( return editOrderItemForRepricing({
normalizedItemId, id: normalizedItemId,
finalPriceMap.get(normalizedProductId), price: finalPriceMap.get(normalizedProductId),
item?.notes ?? "", notes: item?.notes ?? "",
item?.reference ?? "", reference: item?.reference ?? "",
normalizePositiveInteger(item?.quantity) ?? 1 quantity: normalizePositiveInteger(item?.quantity) ?? 1,
); });
}) })
.filter(Boolean); .filter(Boolean);
@@ -370,11 +382,6 @@ const assignDraftOrderCustomer = async ({
normalizedCustomerId normalizedCustomerId
); );
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
let repricingResponse = null; let repricingResponse = null;
if (recalculate_prices) { if (recalculate_prices) {
repricingResponse = await recalculateOrderItemPricesForCustomer({ repricingResponse = await recalculateOrderItemPricesForCustomer({
@@ -384,6 +391,11 @@ const assignDraftOrderCustomer = async ({
}); });
} }
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
return { return {
customerResponse, customerResponse,
invoiceCollectionResponse, invoiceCollectionResponse,
+3
View File
@@ -11,6 +11,8 @@ import {useRouter} from "vue-router";
import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue"; import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue"; import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue"; import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
import { inject } from "vue";
const router = useRouter(); const router = useRouter();
const headerHeight = ref(60); // Default header height in pixels const headerHeight = ref(60); // Default header height in pixels
@@ -87,6 +89,7 @@ const isFooterInContent = computed(() => {
style="max-height: 28px;" style="max-height: 28px;"
> >
<p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p> <p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p>
<p class="is-size-7 has-text-grey-light mb-2 mt-0" v-show="SessionUser.functions.device.isMobile()">{{inject("VERSION")}}</p>
</a> </a>
</div> </div>
</ViewportContent> </ViewportContent>
@@ -1,10 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import {BButton, BField, BIcon} from "buefy"; import {BButton, BField, BIcon} from "buefy";
import {computed, ref} from "vue"; import {computed, onBeforeUnmount, ref, watch} from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { IS_DEV } from '@/config.js'; import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const router = useRouter(); const router = useRouter();
const HOME_LONG_PRESS_MS = 5000;
const HOME_CLICK_SUPPRESS_MS = 1200;
type FooterPage = {
to: string;
label: string;
icon: string;
disabled: boolean;
};
const show = computed(() => { const show = computed(() => {
const path = router.currentRoute.value.path; const path = router.currentRoute.value.path;
@@ -12,10 +22,16 @@ const show = computed(() => {
return path.startsWith("/user"); return path.startsWith("/user");
}); });
const currentPath = computed(() => router.currentRoute.value.path);
const isLocalDataResetOpen = ref(false);
const isClearingLocalData = ref(false);
const suppressHomeClickUntil = ref(0);
let homeLongPressTimer: ReturnType<typeof window.setTimeout> | null = null;
/** /**
* Page navigation footer for mobile devices * Page navigation footer for mobile devices
*/ */
const pages = { const pages: Record<string, FooterPage> = {
home: { home: {
to: "/user", to: "/user",
label: "Hjem", label: "Hjem",
@@ -35,6 +51,78 @@ const pages = {
disabled: false disabled: false
}, },
} }
const cancelHomeLongPress = () => {
if (homeLongPressTimer !== null) {
window.clearTimeout(homeLongPressTimer);
homeLongPressTimer = null;
}
};
const startHomeLongPress = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
if (isLocalDataResetOpen.value || isClearingLocalData.value) {
return;
}
cancelHomeLongPress();
homeLongPressTimer = window.setTimeout(() => {
homeLongPressTimer = null;
suppressHomeClickUntil.value = Date.now() + HOME_CLICK_SUPPRESS_MS;
isLocalDataResetOpen.value = true;
}, HOME_LONG_PRESS_MS);
};
const footerButtonListeners = (key: string) =>
key === "home"
? {
pointerdown: startHomeLongPress,
pointerup: cancelHomeLongPress,
pointercancel: cancelHomeLongPress,
pointerleave: cancelHomeLongPress,
}
: {};
const isActiveFooterRoute = (path: string) => currentPath.value.endsWith(path);
const handlePageClick = (event: MouseEvent, key: string, page: FooterPage) => {
if (page.disabled) {
return;
}
if (key === "home" && Date.now() <= suppressHomeClickUntil.value) {
event.preventDefault();
event.stopPropagation();
suppressHomeClickUntil.value = 0;
return;
}
router.push(page.to);
};
const closeLocalDataReset = () => {
isLocalDataResetOpen.value = false;
};
const confirmLocalDataReset = async () => {
if (isClearingLocalData.value) {
return;
}
isClearingLocalData.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isClearingLocalData.value = false;
console.error("Failed to clear local app data:", error);
}
};
watch(() => router.currentRoute.value.fullPath || router.currentRoute.value.path, cancelHomeLongPress);
onBeforeUnmount(cancelHomeLongPress);
</script> </script>
<template> <template>
@@ -45,13 +133,15 @@ const pages = {
<template v-for="(page, key) in pages" :key="key"> <template v-for="(page, key) in pages" :key="key">
<div class="column has-text-centered"> <div class="column has-text-centered">
<b-button <b-button
class="no-border-radius" class="no-border-radius"
:type="$route.path.endsWith(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'" :data-testid="`mobile-footer-${key}`"
:type="isActiveFooterRoute(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
size="is-normal" size="is-normal"
@click="$router.push(page.to)" v-on="footerButtonListeners(key)"
@click="handlePageClick($event, key, page)"
iconPack="fas" iconPack="fas"
expanded expanded
:disabled="page.disabled" :disabled="page.disabled"
> >
<span> <span>
<span><b-icon pack="fas" :icon="page.icon"></b-icon></span> <span><b-icon pack="fas" :icon="page.icon"></b-icon></span>
@@ -64,6 +154,12 @@ const pages = {
</div> </div>
</b-field> </b-field>
</section> </section>
<LocalDataResetDialog
v-model="isLocalDataResetOpen"
:busy="isClearingLocalData"
@confirm="confirmLocalDataReset"
@dismiss="closeLocalDataReset"
/>
</div> </div>
</template> </template>
@@ -84,4 +180,4 @@ const pages = {
border-top-right-radius: 4px; border-top-right-radius: 4px;
background-color: #2c3e50; background-color: #2c3e50;
} }
</style> </style>
@@ -91,11 +91,17 @@ const getDistanceToDepartment = (departmentId: number | null, optionList = mobil
return 0; return 0;
} }
const department = optionList.find((dept) => dept.value === departmentId); const department = optionList.find((dept) => dept.value === departmentId);
if (department && locations.location.value?.coords) { const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
return locations.getDistance( const departmentCoords = locations.normalizeCoordinatePair(
{ latitude: locations.location.value.coords.latitude, longitude: locations.location.value.coords.longitude }, { latitude: department?.latitude, longitude: department?.longitude },
{ latitude: department.latitude, longitude: department.longitude } { allowZeroPair: false }
);
if (department && currentCoords && departmentCoords) {
const distance = locations.getDistance(
currentCoords,
departmentCoords
); );
return Number.isFinite(distance) ? distance : 0;
} }
return 0; return 0;
}; };
@@ -1,19 +1,80 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'; import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import {
captureVideoFrameBlobForLPR,
isVideoFrameReadyForLPR,
LPR_CAMERA_FRAME_RATE,
LPR_CAMERA_VIDEO_HEIGHT,
LPR_CAMERA_VIDEO_WIDTH,
type LPRFrameEncodeCandidate,
type LPRFrameViewportRect,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
const props = withDefaults(defineProps<{
captureEnabled?: boolean;
captureIntervalMs?: number | null;
captureMode?: 'lpr' | 'preview';
getFocusViewportRect?: () => LPRFrameViewportRect | null;
pausePreview?: boolean;
shouldBuildVisualFingerprint?: () => boolean;
shouldEncodeFrame?: (_candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
}>(), {
captureEnabled: true,
captureIntervalMs: null,
captureMode: 'lpr',
getFocusViewportRect: undefined,
pausePreview: false,
shouldBuildVisualFingerprint: undefined,
shouldEncodeFrame: undefined,
});
type GetUserMediaConstraints = Parameters<typeof navigator.mediaDevices.getUserMedia>[0];
type CameraConstraintCaps = {
capFrameRate: boolean;
capResolution: boolean;
};
const LPR_VIDEO_NOT_READY_RETRY_MS = 100;
const LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS = LPR_VIDEO_NOT_READY_RETRY_MS;
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']); const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
const { t } = useI18n(); const { t } = useI18n();
const videoRef = ref<HTMLVideoElement | null>(null); const videoRef = ref<HTMLVideoElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null); const canvasRef = ref<HTMLCanvasElement | null>(null);
const visualFingerprintCanvasRef = ref<HTMLCanvasElement | null>(null);
const cameraStream = ref<MediaStream | null>(null); const cameraStream = ref<MediaStream | null>(null);
const isCameraActive = ref(false); const isCameraActive = ref(false);
const cameraErrorKey = ref('pos.camera_permission_denied'); const cameraErrorKey = ref('pos.camera_permission_denied');
let captureIntervalId: ReturnType<typeof window.setInterval> | null = null;
let firstCaptureTimeoutId: ReturnType<typeof window.setTimeout> | null = null;
let isFrameCaptureInProgress = false;
let hasRequestedVideoPreviewPlay = false;
let lastAppliedTrackEnabled: boolean | null = null;
let cachedRelativeFocusViewportRect: LPRFrameViewportRect | null = null;
let hasCachedRelativeFocusViewportRect = false;
let cachedVideoViewportRect: DOMRect | null = null;
let videoResizeObserver: ResizeObserver | null = null;
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue'; import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
const getCameraErrorKey = (err: unknown) => { const isDocumentVisible = (): boolean =>
const errorName = err instanceof DOMException ? err.name : ''; typeof document === 'undefined' || document.visibilityState !== 'hidden';
const shouldRunLivePreview = (): boolean =>
!props.pausePreview && isDocumentVisible();
const canCaptureFrames = (): boolean =>
isCameraActive.value && props.captureEnabled && !props.pausePreview && isDocumentVisible();
const getCameraErrorName = (err: unknown): string => {
if (err instanceof DOMException) {
return err.name;
}
return typeof err === 'object' && err !== null
? String((err as { name?: unknown }).name ?? '')
: '';
};
const getCameraErrorKey = (err: unknown) => {
const errorName = getCameraErrorName(err);
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') { if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
return 'pos.no_camera_found'; return 'pos.no_camera_found';
} }
@@ -21,41 +82,189 @@ const getCameraErrorKey = (err: unknown) => {
return 'pos.camera_permission_denied'; return 'pos.camera_permission_denied';
}; };
function startCamera() { const shouldRetryWithRelaxedCameraConstraints = (err: unknown): boolean => {
const constraints = { const errorName = getCameraErrorName(err);
video: {
facingMode: 'environment',
zoom: camera.getZoom(),
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
// New spec return errorName === 'OverconstrainedError' || errorName === 'ConstraintNotSatisfiedError';
advanced: [ };
{ focusMode: 'continuous' },
{ torch: false } // Set to true to enable flashlight if supported const getRejectedCameraConstraintName = (err: unknown): string => {
], if (typeof err !== 'object' || err === null) {
// Old spec return '';
//focusMode: 'continuous', }
// Zoom in on the environment camera if available
//facingMode: 'environment', return String((err as { constraint?: unknown }).constraint ?? '').toLowerCase();
//width: { ideal: 1920 }, };
//height: { ideal: 1080 },
//aspectRatio: { ideal: 16/9 }, const getCameraConstraintFallbacks = (err: unknown): CameraConstraintCaps[] => {
//frameRate: { ideal: 30 } const rejectedConstraint = getRejectedCameraConstraintName(err);
if (rejectedConstraint === 'framerate') {
return [
{ capFrameRate: false, capResolution: true },
{ capFrameRate: false, capResolution: false },
];
}
if (['width', 'height', 'aspectratio', 'resizemode'].includes(rejectedConstraint)) {
return [
{ capFrameRate: true, capResolution: false },
{ capFrameRate: false, capResolution: false },
];
}
return [
{ capFrameRate: false, capResolution: true },
{ capFrameRate: true, capResolution: false },
{ capFrameRate: false, capResolution: false },
];
};
const getCameraConstraints = ({
capFrameRate,
capResolution,
}: CameraConstraintCaps = { capFrameRate: true, capResolution: true }) => ({
video: {
facingMode: 'environment',
zoom: camera.getZoom(),
width: capResolution
? { ideal: LPR_CAMERA_VIDEO_WIDTH, max: LPR_CAMERA_VIDEO_WIDTH }
: { ideal: LPR_CAMERA_VIDEO_WIDTH },
height: capResolution
? { ideal: LPR_CAMERA_VIDEO_HEIGHT, max: LPR_CAMERA_VIDEO_HEIGHT }
: { ideal: LPR_CAMERA_VIDEO_HEIGHT },
frameRate: capFrameRate
? { ideal: LPR_CAMERA_FRAME_RATE, max: LPR_CAMERA_FRAME_RATE }
: { ideal: LPR_CAMERA_FRAME_RATE },
// New spec
advanced: [
{ focusMode: 'continuous' },
{ torch: false } // Set to true to enable flashlight if supported
],
// Old spec
//focusMode: 'continuous',
// Zoom in on the environment camera if available
//facingMode: 'environment',
//width: { ideal: 1920 },
//height: { ideal: 1080 },
//aspectRatio: { ideal: 16/9 },
//frameRate: { ideal: 30 }
}
} as unknown as GetUserMediaConstraints);
const requestCameraStream = async (): Promise<MediaStream> => {
try {
return await navigator.mediaDevices.getUserMedia(getCameraConstraints());
} catch (err) {
if (!shouldRetryWithRelaxedCameraConstraints(err)) {
throw err;
} }
};
navigator.mediaDevices.getUserMedia(constraints) let lastError = err;
.then((stream) => { for (const fallback of getCameraConstraintFallbacks(err)) {
isCameraActive.value = true; try {
cameraStream.value = stream; return await navigator.mediaDevices.getUserMedia(getCameraConstraints(fallback));
if (videoRef.value) { } catch (fallbackError) {
videoRef.value.srcObject = stream; if (!shouldRetryWithRelaxedCameraConstraints(fallbackError)) {
videoRef.value.setAttribute('playsinline', ''); throw fallbackError;
videoRef.value.play();
} }
}) lastError = fallbackError;
}
}
throw lastError;
}
};
const playVideoPreview = (video: HTMLVideoElement) => {
if (hasRequestedVideoPreviewPlay) {
return;
}
hasRequestedVideoPreviewPlay = true;
const playResult = video.play();
if (playResult && typeof playResult.catch === 'function') {
void playResult.catch(() => {
hasRequestedVideoPreviewPlay = false;
});
}
};
const pauseVideoPreview = (video: HTMLVideoElement) => {
if (!hasRequestedVideoPreviewPlay) {
return;
}
hasRequestedVideoPreviewPlay = false;
video.pause();
};
const getCameraVideoTracks = (): MediaStreamTrack[] => {
if (!cameraStream.value) {
return [];
}
if (typeof cameraStream.value.getVideoTracks === 'function') {
return cameraStream.value.getVideoTracks();
}
return cameraStream.value.getTracks().filter(track => track.kind === 'video');
};
const syncCameraVideoTracksEnabled = () => {
if (!isCameraActive.value) {
return;
}
const shouldEnableTracks = shouldRunLivePreview();
if (lastAppliedTrackEnabled === shouldEnableTracks) {
return;
}
getCameraVideoTracks().forEach((track) => {
if (track.enabled !== shouldEnableTracks) {
track.enabled = shouldEnableTracks;
}
});
lastAppliedTrackEnabled = shouldEnableTracks;
};
const syncVideoPreviewPlayback = () => {
syncCameraVideoTracksEnabled();
if (!videoRef.value || !isCameraActive.value) {
return;
}
if (!shouldRunLivePreview()) {
pauseVideoPreview(videoRef.value);
return;
}
playVideoPreview(videoRef.value);
};
const applyCameraStream = (stream: MediaStream) => {
isCameraActive.value = true;
isCameraMounted.value = true;
cameraStream.value = stream;
hasRequestedVideoPreviewPlay = false;
lastAppliedTrackEnabled = null;
if (videoRef.value) {
videoRef.value.srcObject = stream;
videoRef.value.setAttribute('playsinline', '');
syncVideoPreviewPlayback();
}
startCaptureTimers();
};
function startCamera() {
if (cameraStream.value || isCameraActive.value) {
return;
}
requestCameraStream()
.then(applyCameraStream)
.catch((err) => { .catch((err) => {
isCameraActive.value = false; isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err); cameraErrorKey.value = getCameraErrorKey(err);
@@ -63,7 +272,132 @@ function startCamera() {
}); });
} }
function clearCaptureInterval() {
if (captureIntervalId !== null) {
window.clearInterval(captureIntervalId);
captureIntervalId = null;
}
}
function clearFirstCaptureTimeout() {
if (firstCaptureTimeoutId !== null) {
window.clearTimeout(firstCaptureTimeoutId);
firstCaptureTimeoutId = null;
}
}
const pauseCaptureTimers = () => {
clearFirstCaptureTimeout();
clearCaptureInterval();
};
function captureFrameIfReady(): Promise<void> {
if (canCaptureFrames() && !isFrameCaptureInProgress) {
if (!videoRef.value || !isVideoFrameReadyForLPR(videoRef.value)) {
scheduleFrameReadinessRetry();
return Promise.resolve();
}
clearFirstCaptureTimeout();
return getFrame()
.then(() => undefined)
.finally(() => {
if (canCaptureFrames()) {
startCaptureInterval();
}
});
}
return Promise.resolve();
}
function startCaptureTimers() {
if (!canCaptureFrames()) {
pauseCaptureTimers();
return;
}
clearCaptureInterval();
scheduleFirstCapture();
}
function resumeCaptureTimers(firstCaptureDelayMs = 0) {
if (!canCaptureFrames()) {
pauseCaptureTimers();
return;
}
clearCaptureInterval();
scheduleFirstCapture(firstCaptureDelayMs);
}
function scheduleFirstCapture(delayMs = camera.getImageCaptureDelay(true)) {
clearFirstCaptureTimeout();
firstCaptureTimeoutId = window.setTimeout(() => {
firstCaptureTimeoutId = null;
captureFrameIfReady();
}, delayMs);
}
const getRecurringCaptureDelay = (): number => {
if (props.captureIntervalMs === null || props.captureIntervalMs === undefined) {
return camera.getImageCaptureDelay(false);
}
const customDelay = Number(props.captureIntervalMs);
if (Number.isFinite(customDelay) && customDelay >= 0) {
return Math.floor(customDelay);
}
return camera.getImageCaptureDelay(false);
};
function scheduleFrameReadinessRetry() {
if (firstCaptureTimeoutId !== null) {
return;
}
scheduleFirstCapture(LPR_VIDEO_NOT_READY_RETRY_MS);
}
function startCaptureInterval() {
if (!canCaptureFrames()) {
clearCaptureInterval();
return;
}
clearCaptureInterval();
captureIntervalId = window.setTimeout(() => {
captureIntervalId = null;
void captureFrameIfReady();
}, getRecurringCaptureDelay());
}
const clearRelativeFocusViewportRectCache = () => {
cachedRelativeFocusViewportRect = null;
cachedVideoViewportRect = null;
hasCachedRelativeFocusViewportRect = false;
};
const observeVideoGeometry = () => {
videoResizeObserver?.disconnect();
videoResizeObserver = null;
if (typeof ResizeObserver === 'undefined' || !videoRef.value) {
return;
}
videoResizeObserver = new ResizeObserver(clearRelativeFocusViewportRectCache);
videoResizeObserver.observe(videoRef.value);
};
function stopCamera() { function stopCamera() {
clearFirstCaptureTimeout();
clearCaptureInterval();
clearRelativeFocusViewportRectCache();
hasRequestedVideoPreviewPlay = false;
lastAppliedTrackEnabled = null;
if (cameraStream.value) { if (cameraStream.value) {
cameraStream.value.getTracks().forEach(track => track.stop()); cameraStream.value.getTracks().forEach(track => track.stop());
cameraStream.value = null; cameraStream.value = null;
@@ -83,75 +417,179 @@ function toggleCamera() {
} }
} }
function handleVideoLoadedData() {
clearRelativeFocusViewportRectCache();
if (canCaptureFrames()) {
scheduleFirstCapture(0);
}
}
function handleVisibilityChange() {
clearRelativeFocusViewportRectCache();
if (isDocumentVisible()) {
syncVideoPreviewPlayback();
resumeCaptureTimers(0);
return;
}
pauseCaptureTimers();
syncVideoPreviewPlayback();
}
const shouldUseFocusedLPRCrop = () => props.captureMode === 'lpr';
const hasUsableRectSize = (rect: { height: number; width: number }): boolean =>
Number.isFinite(rect.width)
&& Number.isFinite(rect.height)
&& rect.width > 0
&& rect.height > 0;
const getVideoViewportRect = (): DOMRect | null => {
if (!videoRef.value) {
return null;
}
if (cachedVideoViewportRect !== null) {
return cachedVideoViewportRect;
}
const videoRect = videoRef.value.getBoundingClientRect();
if (!hasUsableRectSize(videoRect)) {
return null;
}
cachedVideoViewportRect = videoRect;
return cachedVideoViewportRect;
};
const getRelativeFocusViewportRect = (videoViewportRect: DOMRect | null): LPRFrameViewportRect | null => {
if (!shouldUseFocusedLPRCrop() || !props.getFocusViewportRect || !videoRef.value) {
return null;
}
if (hasCachedRelativeFocusViewportRect) {
return cachedRelativeFocusViewportRect;
}
const focusRect = props.getFocusViewportRect();
if (!focusRect || !hasUsableRectSize(focusRect)) {
return null;
}
if (!videoViewportRect || !hasUsableRectSize(videoViewportRect)) {
return null;
}
cachedRelativeFocusViewportRect = {
height: focusRect.height,
width: focusRect.width,
x: focusRect.x - videoViewportRect.left,
y: focusRect.y - videoViewportRect.top,
};
hasCachedRelativeFocusViewportRect = true;
return cachedRelativeFocusViewportRect;
};
const getFrameCaptureOptions = () => {
const shouldUseFocusedCrop = shouldUseFocusedLPRCrop();
const videoViewportRect = shouldUseFocusedCrop ? getVideoViewportRect() : null;
return {
focusCrop: shouldUseFocusedCrop,
focusViewportRect: shouldUseFocusedCrop ? getRelativeFocusViewportRect(videoViewportRect) : null,
shouldBuildVisualFingerprint: shouldUseFocusedCrop ? props.shouldBuildVisualFingerprint : undefined,
shouldEncode: shouldUseFocusedCrop ? props.shouldEncodeFrame : undefined,
...(videoViewportRect
? {
viewportHeight: videoViewportRect.height,
viewportWidth: videoViewportRect.width,
}
: {}),
visualFingerprintCanvas: shouldUseFocusedCrop ? visualFingerprintCanvasRef.value : null,
};
};
const getFrame = () => { const getFrame = () => {
if (!canCaptureFrames()) {
return Promise.resolve(null);
}
if (videoRef.value && isCameraActive.value) { if (videoRef.value && isCameraActive.value) {
const canvas = canvasRef.value; const canvas = canvasRef.value;
if (canvas) { if (canvas) {
const context = canvas.getContext('2d', { isFrameCaptureInProgress = true;
alpha: false,
willReadFrequently: true
});
if (!context) { return captureVideoFrameBlobForLPR(videoRef.value, canvas, getFrameCaptureOptions())
console.error('Failed to get canvas context'); .then((frameData) => {
return null; if (frameData && canCaptureFrames()) {
} emits('update:frame', frameData);
}
// Set canvas size to match video's native resolution return nextTick().then(() => frameData);
const videoWidth = videoRef.value.videoWidth; })
const videoHeight = videoRef.value.videoHeight; .finally(() => {
isFrameCaptureInProgress = false;
canvas.width = videoWidth; syncVideoPreviewPlayback();
canvas.height = videoHeight; });
// Clear previous frame
context.clearRect(0, 0, canvas.width, canvas.height);
// Draw current frame
context.drawImage(videoRef.value, 0, 0, videoWidth, videoHeight);
// Get frame data as base64
const frameData = canvas.toDataURL('image/jpeg', 0.95);
// Emit the frame data
emits('update:frame', frameData);
return frameData;
} }
} }
return null; return Promise.resolve(null);
}; };
// Update canvas dimensions when video size changes
watch(() => videoRef.value?.videoWidth, (newWidth) => {
if (canvasRef.value && newWidth) {
canvasRef.value.width = newWidth;
canvasRef.value.height = videoRef.value?.videoHeight || 0;
}
});
// Watch for zoom level changes // Watch for zoom level changes
watch(() => camera.getZoom(), (newZoom) => { watch(() => camera.getZoom(), () => {
if (isCameraActive.value) { if (isCameraActive.value) {
stopCamera(); stopCamera();
startCamera(); startCamera();
} }
}); });
onMounted(() => { watch(() => props.captureEnabled, (isCaptureEnabled) => {
// Emit a picture every 10 seconds if (isCaptureEnabled) {
if (!isCameraMounted.value) { resumeCaptureTimers(0);
isCameraMounted.value = true; } else {
setInterval(() => { pauseCaptureTimers();
if (isCameraActive.value) {
getFrame();
}
}, camera.getImageCaptureDelay(false)); // Implement a method to get the delay based on camera settings
startCamera();
} }
}); });
watch([() => props.captureMode, () => props.getFocusViewportRect], clearRelativeFocusViewportRectCache);
watch(() => props.captureIntervalMs, () => {
if (canCaptureFrames() && !isFrameCaptureInProgress && firstCaptureTimeoutId === null) {
startCaptureInterval();
}
});
watch(() => props.pausePreview, (isPreviewPaused) => {
syncVideoPreviewPlayback();
if (isPreviewPaused) {
pauseCaptureTimers();
return;
}
resumeCaptureTimers(LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS);
});
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('scroll', clearRelativeFocusViewportRectCache, true);
window.addEventListener('orientationchange', clearRelativeFocusViewportRectCache);
window.addEventListener('resize', clearRelativeFocusViewportRectCache);
observeVideoGeometry();
isCameraMounted.value = true;
startCamera();
});
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('scroll', clearRelativeFocusViewportRectCache, true);
window.removeEventListener('orientationchange', clearRelativeFocusViewportRectCache);
window.removeEventListener('resize', clearRelativeFocusViewportRectCache);
videoResizeObserver?.disconnect();
videoResizeObserver = null;
clearRelativeFocusViewportRectCache();
stopCamera(); stopCamera();
}); });
@@ -175,6 +613,7 @@ watch(() => isCameraActive.value, (newVal) => {
autoplay autoplay
playsinline playsinline
:class="{ 'is-active': isCameraActive }" :class="{ 'is-active': isCameraActive }"
@loadeddata="handleVideoLoadedData"
> >
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
@@ -184,6 +623,12 @@ watch(() => isCameraActive.value, (newVal) => {
class="capture-canvas" class="capture-canvas"
></canvas> ></canvas>
<canvas
ref="visualFingerprintCanvasRef"
class="visual-fingerprint-canvas"
aria-hidden="true"
></canvas>
<div v-if="!isCameraActive" class="camera-inactive"> <div v-if="!isCameraActive" class="camera-inactive">
<p>{{ t(cameraErrorKey) }}</p> <p>{{ t(cameraErrorKey) }}</p>
</div> </div>
@@ -215,6 +660,10 @@ video {
display: none; display: none;
} }
.visual-fingerprint-canvas {
display: none;
}
.camera-inactive { .camera-inactive {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -0,0 +1,773 @@
export const LPR_CAMERA_VIDEO_WIDTH = 1024;
export const LPR_CAMERA_VIDEO_HEIGHT = 576;
export const LPR_CAMERA_FRAME_RATE = 5;
export const LPR_FRAME_MAX_WIDTH = 1024;
export const LPR_FRAME_MAX_HEIGHT = 576;
export const LPR_FRAME_SCANNER_MAX_SIZE = 384;
export const LPR_FRAME_JPEG_QUALITY = 0.72;
export const LPR_FRAME_SCANNER_JPEG_QUALITY = 0.6;
export const LPR_FRAME_MIME_TYPE = "image/jpeg";
export const LPR_FRAME_FILE_NAME = "license-plate.jpg";
export const LPR_FRAME_CLIENT_CAPTURE_MS_FIELD = "client_capture_ms";
export const LPR_FRAME_CLIENT_DRAW_MS_FIELD = "client_draw_ms";
export const LPR_FRAME_CLIENT_ENCODE_MS_FIELD = "client_encode_ms";
export const LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD = "client_preflight_ms";
export const LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD = "client_visual_fingerprint_ms";
export const LPR_FRAME_CLIENT_WIDTH_FIELD = "client_frame_width";
export const LPR_FRAME_CLIENT_HEIGHT_FIELD = "client_frame_height";
export const LPR_FRAME_CLIENT_BYTES_FIELD = "client_frame_bytes";
export const LPR_FRAME_FOCUS_ASPECT_RATIO = 16 / 9;
export const LPR_FRAME_SCANNER_FOCUS_SCALE = 0.85;
export const LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE = 8;
const HTML_MEDIA_HAVE_CURRENT_DATA = 2;
const LPR_FRAME_FINGERPRINT_SAMPLE_BYTES = 32;
const LPR_FRAME_FINGERPRINT_HASH_SEED = 2166136261;
const LPR_FRAME_FINGERPRINT_HASH_PRIME = 16777619;
const NIBBLE_BIT_COUNT = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
const LPR_ENCODING_CANVAS_CONTEXT_OPTIONS = {
alpha: false,
desynchronized: true,
};
export type FrameSize = {
width: number;
height: number;
};
export type FrameSizeConstraints = {
maxHeight: number;
maxWidth: number;
};
export type FrameSourceRect = {
height: number;
width: number;
x: number;
y: number;
};
export type LPRFrameViewportRect = {
height: number;
width: number;
x: number;
y: number;
};
export type LPRFramePayload = {
blob: Blob;
captureDurationMs: number;
captureTimings?: LPRFrameCaptureTimings;
filename: string;
fingerprint: string;
getContentFingerprint?: () => Promise<string>;
getVisualFingerprint?: () => string | null;
height: number;
mimeType: string;
visualFingerprint?: string;
width: number;
};
export type LPRFrameCaptureTimings = {
drawMs: number;
encodeMs: number;
visualFingerprintMs: number;
};
export type LPRFrameEncodeCandidate = {
height: number;
visualFingerprint?: string;
width: number;
};
export type LPRFrameCaptureOptions = {
focusCrop?: boolean;
focusScale?: number;
focusViewportRect?: LPRFrameViewportRect | null;
jpegQuality?: number;
onFrameDrawn?: () => void;
shouldBuildVisualFingerprint?: () => boolean;
shouldEncode?: (candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
viewportHeight?: number;
viewportWidth?: number;
visualFingerprintCanvas?: HTMLCanvasElement | null;
};
type LPRFrameEncodingCanvas = HTMLCanvasElement | OffscreenCanvas;
type LPRFrameEncodingContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
type LPRFrameEncodedCanvas = {
blob: Blob;
canvas: LPRFrameEncodingCanvas;
timings: Pick<LPRFrameCaptureTimings, "drawMs" | "encodeMs">;
};
const offscreenEncodingCanvases = new WeakMap<HTMLCanvasElement, OffscreenCanvas>();
const disabledOffscreenEncodingCanvases = new WeakSet<HTMLCanvasElement>();
const encodingCanvasContexts = new WeakMap<LPRFrameEncodingCanvas, LPRFrameEncodingContext>();
const visualFingerprintCanvasContexts = new WeakMap<HTMLCanvasElement, CanvasRenderingContext2D>();
const clampNumber = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
export const getConstrainedFrameSize = (
sourceWidth: number,
sourceHeight: number,
maxWidth = LPR_FRAME_MAX_WIDTH,
maxHeight = LPR_FRAME_MAX_HEIGHT
): FrameSize => {
if (sourceWidth <= 0 || sourceHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(1, maxWidth / sourceWidth, maxHeight / sourceHeight);
return {
width: Math.max(1, Math.round(sourceWidth * scale)),
height: Math.max(1, Math.round(sourceHeight * scale)),
};
};
export const getLPRFrameSizeConstraints = (options: LPRFrameCaptureOptions = {}): FrameSizeConstraints =>
options.focusCrop === false
? {
maxHeight: LPR_FRAME_MAX_HEIGHT,
maxWidth: LPR_FRAME_MAX_WIDTH,
}
: {
maxHeight: LPR_FRAME_SCANNER_MAX_SIZE,
maxWidth: LPR_FRAME_SCANNER_MAX_SIZE,
};
export const getLPRFrameTargetSize = (
sourceRect: FrameSourceRect,
options: LPRFrameCaptureOptions = {}
): FrameSize => {
const constraints = getLPRFrameSizeConstraints(options);
return getConstrainedFrameSize(
sourceRect.width,
sourceRect.height,
constraints.maxWidth,
constraints.maxHeight
);
};
export const getVisibleCoverSourceRect = (
sourceWidth: number,
sourceHeight: number,
viewportWidth: number,
viewportHeight: number
): FrameSourceRect => {
if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight) || sourceWidth <= 0 || sourceHeight <= 0) {
return { height: 0, width: 0, x: 0, y: 0 };
}
if (
!Number.isFinite(viewportWidth) ||
!Number.isFinite(viewportHeight) ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return { height: sourceHeight, width: sourceWidth, x: 0, y: 0 };
}
const sourceAspectRatio = sourceWidth / sourceHeight;
const viewportAspectRatio = viewportWidth / viewportHeight;
if (viewportAspectRatio > sourceAspectRatio) {
const visibleHeight = Math.max(1, Math.min(sourceHeight, Math.round(sourceWidth / viewportAspectRatio)));
return {
height: visibleHeight,
width: sourceWidth,
x: 0,
y: Math.max(0, Math.round((sourceHeight - visibleHeight) / 2)),
};
}
const visibleWidth = Math.max(1, Math.min(sourceWidth, Math.round(sourceHeight * viewportAspectRatio)));
return {
height: sourceHeight,
width: visibleWidth,
x: Math.max(0, Math.round((sourceWidth - visibleWidth) / 2)),
y: 0,
};
};
export const getCenteredFocusSourceRect = (
sourceRect: FrameSourceRect,
focusAspectRatio = LPR_FRAME_FOCUS_ASPECT_RATIO,
focusScale = 1
): FrameSourceRect => {
if (
!Number.isFinite(sourceRect.width) ||
!Number.isFinite(sourceRect.height) ||
sourceRect.width <= 0 ||
sourceRect.height <= 0
) {
return { height: 0, width: 0, x: 0, y: 0 };
}
if (!Number.isFinite(focusAspectRatio) || focusAspectRatio <= 0) {
return sourceRect;
}
let focusedRect: FrameSourceRect;
const sourceAspectRatio = sourceRect.width / sourceRect.height;
if (sourceAspectRatio > focusAspectRatio) {
const focusedWidth = Math.max(1, Math.min(sourceRect.width, Math.round(sourceRect.height * focusAspectRatio)));
focusedRect = {
height: sourceRect.height,
width: focusedWidth,
x: sourceRect.x + Math.max(0, Math.round((sourceRect.width - focusedWidth) / 2)),
y: sourceRect.y,
};
} else {
const focusedHeight = Math.max(1, Math.min(sourceRect.height, Math.round(sourceRect.width / focusAspectRatio)));
focusedRect = {
height: focusedHeight,
width: sourceRect.width,
x: sourceRect.x,
y: sourceRect.y + Math.max(0, Math.round((sourceRect.height - focusedHeight) / 2)),
};
}
if (!Number.isFinite(focusScale) || focusScale <= 0 || focusScale >= 1) {
return focusedRect;
}
const scaledWidth = Math.max(1, Math.round(focusedRect.width * focusScale));
const scaledHeight = Math.max(1, Math.round(focusedRect.height * focusScale));
return {
height: scaledHeight,
width: scaledWidth,
x: focusedRect.x + Math.max(0, Math.round((focusedRect.width - scaledWidth) / 2)),
y: focusedRect.y + Math.max(0, Math.round((focusedRect.height - scaledHeight) / 2)),
};
};
export const getViewportAnchoredFocusSourceRect = (
centeredFocusRect: FrameSourceRect,
visibleSourceRect: FrameSourceRect,
viewportWidth: number,
viewportHeight: number,
focusViewportRect: LPRFrameViewportRect | null | undefined
): FrameSourceRect => {
if (
!focusViewportRect ||
!Number.isFinite(viewportWidth) ||
!Number.isFinite(viewportHeight) ||
!Number.isFinite(focusViewportRect.x) ||
!Number.isFinite(focusViewportRect.y) ||
!Number.isFinite(focusViewportRect.width) ||
!Number.isFinite(focusViewportRect.height) ||
viewportWidth <= 0 ||
viewportHeight <= 0 ||
focusViewportRect.width <= 0 ||
focusViewportRect.height <= 0 ||
centeredFocusRect.width <= 0 ||
centeredFocusRect.height <= 0 ||
visibleSourceRect.width <= 0 ||
visibleSourceRect.height <= 0 ||
centeredFocusRect.width > visibleSourceRect.width ||
centeredFocusRect.height > visibleSourceRect.height
) {
return centeredFocusRect;
}
const focusCenterX = focusViewportRect.x + (focusViewportRect.width / 2);
const focusCenterY = focusViewportRect.y + (focusViewportRect.height / 2);
const sourceCenterX = visibleSourceRect.x + ((focusCenterX / viewportWidth) * visibleSourceRect.width);
const sourceCenterY = visibleSourceRect.y + ((focusCenterY / viewportHeight) * visibleSourceRect.height);
const minX = visibleSourceRect.x;
const minY = visibleSourceRect.y;
const maxX = visibleSourceRect.x + visibleSourceRect.width - centeredFocusRect.width;
const maxY = visibleSourceRect.y + visibleSourceRect.height - centeredFocusRect.height;
return {
height: centeredFocusRect.height,
width: centeredFocusRect.width,
x: clampNumber(Math.round(sourceCenterX - (centeredFocusRect.width / 2)), minX, maxX),
y: clampNumber(Math.round(sourceCenterY - (centeredFocusRect.height / 2)), minY, maxY),
};
};
export const getLPRFrameSourceRect = (
sourceWidth: number,
sourceHeight: number,
viewportWidth: number,
viewportHeight: number,
options: LPRFrameCaptureOptions = {}
): FrameSourceRect => {
const visibleSourceRect = getVisibleCoverSourceRect(sourceWidth, sourceHeight, viewportWidth, viewportHeight);
if (options.focusCrop === false) {
return visibleSourceRect;
}
const centeredFocusRect = getCenteredFocusSourceRect(
visibleSourceRect,
LPR_FRAME_FOCUS_ASPECT_RATIO,
options.focusScale ?? LPR_FRAME_SCANNER_FOCUS_SCALE
);
return getViewportAnchoredFocusSourceRect(
centeredFocusRect,
visibleSourceRect,
viewportWidth,
viewportHeight,
options.focusViewportRect
);
};
const getVideoFrameSourceRect = (video: HTMLVideoElement, options: LPRFrameCaptureOptions = {}): FrameSourceRect => {
const configuredViewportWidth = Number(options.viewportWidth);
const configuredViewportHeight = Number(options.viewportHeight);
const hasConfiguredViewportSize =
Number.isFinite(configuredViewportWidth) &&
configuredViewportWidth > 0 &&
Number.isFinite(configuredViewportHeight) &&
configuredViewportHeight > 0;
const viewportWidth = hasConfiguredViewportSize ? configuredViewportWidth : video.clientWidth;
const viewportHeight = hasConfiguredViewportSize ? configuredViewportHeight : video.clientHeight;
return getLPRFrameSourceRect(
video.videoWidth,
video.videoHeight,
viewportWidth,
viewportHeight,
options
);
};
export const isVideoFrameReadyForLPR = (video: HTMLVideoElement): boolean =>
video.readyState >= HTML_MEDIA_HAVE_CURRENT_DATA
&& video.videoWidth > 0
&& video.videoHeight > 0;
const sampleBlobFingerprintBytes = async (blob: Blob): Promise<string> => {
let hash = LPR_FRAME_FINGERPRINT_HASH_SEED;
const sampleWidth = Math.min(LPR_FRAME_FINGERPRINT_SAMPLE_BYTES, blob.size);
const offsets = [
0,
Math.max(0, Math.floor(blob.size / 2) - Math.floor(sampleWidth / 2)),
Math.max(0, blob.size - sampleWidth),
];
let previousOffset: number | null = null;
for (const offset of offsets) {
if (offset === previousOffset) {
continue;
}
previousOffset = offset;
const bytes = new Uint8Array(await blob.slice(offset, offset + sampleWidth).arrayBuffer());
for (let index = 0; index < bytes.length; index += 1) {
const byte = bytes[index];
hash ^= byte;
hash = Math.imul(hash, LPR_FRAME_FINGERPRINT_HASH_PRIME) >>> 0;
}
}
return hash.toString(16).padStart(8, "0");
};
export const buildLPRFrameFingerprint = async (
blob: Blob,
width: number,
height: number
): Promise<string> => `${width}x${height}:${blob.size}:${await sampleBlobFingerprintBytes(blob)}`;
export const buildLPRFrameFingerprintKey = (
blob: Blob,
width: number,
height: number
): string => `${width}x${height}:${blob.size}`;
export const getVisualFingerprintDistance = (first: string | null | undefined, second: string | null | undefined): number => {
if (!first || !second || first.length !== second.length) {
return Number.POSITIVE_INFINITY;
}
let distance = 0;
for (let index = 0; index < first.length; index += 1) {
const firstNibble = Number.parseInt(first[index], 16);
const secondNibble = Number.parseInt(second[index], 16);
if (!Number.isInteger(firstNibble) || !Number.isInteger(secondNibble)) {
return Number.POSITIVE_INFINITY;
}
distance += NIBBLE_BIT_COUNT[firstNibble ^ secondNibble];
}
return distance;
};
const buildSourceVisualFingerprint = (
source: CanvasImageSource,
sourceRect: FrameSourceRect,
visualFingerprintCanvas: HTMLCanvasElement | null | undefined
): string | null => {
if (
sourceRect.width <= 0 ||
sourceRect.height <= 0 ||
!Number.isFinite(sourceRect.x) ||
!Number.isFinite(sourceRect.y) ||
!Number.isFinite(sourceRect.width) ||
!Number.isFinite(sourceRect.height) ||
!visualFingerprintCanvas
) {
return null;
}
try {
if (visualFingerprintCanvas.width !== LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE) {
visualFingerprintCanvas.width = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
}
if (visualFingerprintCanvas.height !== LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE) {
visualFingerprintCanvas.height = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
}
let context = visualFingerprintCanvasContexts.get(visualFingerprintCanvas) ?? null;
if (context === null) {
context = visualFingerprintCanvas.getContext("2d", {
alpha: false,
willReadFrequently: true,
});
if (context !== null) {
visualFingerprintCanvasContexts.set(visualFingerprintCanvas, context);
}
}
if (!context || typeof context.getImageData !== "function") {
return null;
}
context.drawImage(
source,
sourceRect.x,
sourceRect.y,
sourceRect.width,
sourceRect.height,
0,
0,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE
);
const imageData = context.getImageData(
0,
0,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE
).data;
const sampleCount = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE * LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
let luminanceTotal = 0;
for (let pixelOffset = 0; pixelOffset < imageData.length; pixelOffset += 4) {
luminanceTotal +=
(imageData[pixelOffset] * 0.299)
+ (imageData[pixelOffset + 1] * 0.587)
+ (imageData[pixelOffset + 2] * 0.114);
}
const averageLuminance = luminanceTotal / sampleCount;
let fingerprint = "";
for (let pixelOffset = 0; pixelOffset < imageData.length; pixelOffset += 16) {
let nibble = 0;
for (let offset = 0; offset < 4; offset += 1) {
const offsetPixel = pixelOffset + (offset * 4);
const luminance =
(imageData[offsetPixel] * 0.299)
+ (imageData[offsetPixel + 1] * 0.587)
+ (imageData[offsetPixel + 2] * 0.114);
if (luminance > averageLuminance) {
nibble |= 1 << (3 - offset);
}
}
fingerprint += nibble.toString(16);
}
return fingerprint;
} catch {
return null;
}
};
const buildMemoizedLPRFrameContentFingerprint = (
blob: Blob,
width: number,
height: number
): (() => Promise<string>) => {
let fingerprintPromise: Promise<string> | null = null;
return () => {
fingerprintPromise ??= buildLPRFrameFingerprint(blob, width, height).catch((error) => {
fingerprintPromise = null;
throw error;
});
return fingerprintPromise;
};
};
const nowMs = (): number =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
const getJpegQualityForFrame = (options: LPRFrameCaptureOptions): number => {
if (Number.isFinite(options.jpegQuality) && options.jpegQuality > 0 && options.jpegQuality <= 1) {
return options.jpegQuality;
}
return options.focusCrop === false
? LPR_FRAME_JPEG_QUALITY
: LPR_FRAME_SCANNER_JPEG_QUALITY;
};
const shouldEncodeFrameCandidate = async (
candidate: LPRFrameEncodeCandidate,
shouldEncode: LPRFrameCaptureOptions["shouldEncode"]
): Promise<boolean> => {
if (typeof shouldEncode !== "function") {
return true;
}
try {
return await shouldEncode(candidate);
} catch {
return true;
}
};
const shouldBuildVisualFingerprintBeforeEncode = (
shouldBuildVisualFingerprint: LPRFrameCaptureOptions["shouldBuildVisualFingerprint"]
): boolean => {
if (typeof shouldBuildVisualFingerprint !== "function") {
return true;
}
try {
return shouldBuildVisualFingerprint();
} catch {
return true;
}
};
const getOffscreenEncodingCanvas = (fallbackCanvas: HTMLCanvasElement): OffscreenCanvas | null => {
if (typeof OffscreenCanvas === "undefined") {
return null;
}
if (disabledOffscreenEncodingCanvases.has(fallbackCanvas)) {
return null;
}
let canvas = offscreenEncodingCanvases.get(fallbackCanvas) ?? null;
if (canvas === null) {
canvas = new OffscreenCanvas(1, 1);
offscreenEncodingCanvases.set(fallbackCanvas, canvas);
}
return canvas;
};
const isOffscreenEncodingCanvas = (canvas: LPRFrameEncodingCanvas): canvas is OffscreenCanvas =>
typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas;
const encodeCanvasBlob = (
canvas: LPRFrameEncodingCanvas,
mimeType: string,
quality: number
): Promise<Blob | null> => {
if (isOffscreenEncodingCanvas(canvas)) {
return typeof canvas.convertToBlob === "function"
? canvas.convertToBlob({ type: mimeType, quality }).catch(() => null)
: Promise.resolve(null);
}
if (typeof canvas.toBlob !== "function") {
return Promise.resolve(null);
}
return new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, mimeType, quality);
});
};
const getEncodingCanvasContext = (canvas: LPRFrameEncodingCanvas): LPRFrameEncodingContext | null => {
let context = encodingCanvasContexts.get(canvas) ?? null;
if (context !== null) {
return context;
}
context = canvas.getContext("2d", LPR_ENCODING_CANVAS_CONTEXT_OPTIONS) as LPRFrameEncodingContext | null;
if (context !== null) {
encodingCanvasContexts.set(canvas, context);
}
return context;
};
const drawAndEncodeFrame = async (
video: HTMLVideoElement,
canvas: LPRFrameEncodingCanvas,
sourceRect: FrameSourceRect,
targetSize: FrameSize,
options: LPRFrameCaptureOptions
): Promise<LPRFrameEncodedCanvas | null> => {
if (canvas.width !== targetSize.width) {
canvas.width = targetSize.width;
}
if (canvas.height !== targetSize.height) {
canvas.height = targetSize.height;
}
const context = getEncodingCanvasContext(canvas);
if (!context) {
return null;
}
const drawStartedAt = nowMs();
context.drawImage(
video,
sourceRect.x,
sourceRect.y,
sourceRect.width,
sourceRect.height,
0,
0,
targetSize.width,
targetSize.height
);
const drawMs = Math.max(0, nowMs() - drawStartedAt);
try {
options.onFrameDrawn?.();
} catch {
// Capture must continue even if preview throttling cannot be applied.
}
const encodeStartedAt = nowMs();
const blob = await encodeCanvasBlob(canvas, LPR_FRAME_MIME_TYPE, getJpegQualityForFrame(options));
const encodeMs = Math.max(0, nowMs() - encodeStartedAt);
if (!blob) {
return null;
}
return {
blob,
canvas,
timings: {
drawMs,
encodeMs,
},
};
};
const captureEncodedCanvas = async (
video: HTMLVideoElement,
fallbackCanvas: HTMLCanvasElement,
sourceRect: FrameSourceRect,
targetSize: FrameSize,
options: LPRFrameCaptureOptions
): Promise<LPRFrameEncodedCanvas | null> => {
const offscreenCanvas = getOffscreenEncodingCanvas(fallbackCanvas);
if (offscreenCanvas !== null) {
try {
const encoded = await drawAndEncodeFrame(video, offscreenCanvas, sourceRect, targetSize, options);
if (encoded !== null) {
return encoded;
}
disabledOffscreenEncodingCanvases.add(fallbackCanvas);
} catch {
// Some browsers expose OffscreenCanvas but reject drawing live video into it.
disabledOffscreenEncodingCanvases.add(fallbackCanvas);
}
}
const encoded = await drawAndEncodeFrame(video, fallbackCanvas, sourceRect, targetSize, options);
if (encoded === null) {
console.error("Failed to capture camera frame");
}
return encoded;
};
export const captureVideoFrameBlobForLPR = async (
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
options: LPRFrameCaptureOptions = {}
): Promise<LPRFramePayload | null> => {
const captureStartedAt = nowMs();
const captureTimings: LPRFrameCaptureTimings = {
drawMs: 0,
encodeMs: 0,
visualFingerprintMs: 0,
};
const sourceRect = getVideoFrameSourceRect(video, options);
const targetSize = getLPRFrameTargetSize(sourceRect, options);
if (targetSize.width === 0 || targetSize.height === 0) {
return null;
}
const visualFingerprintCanvas = options.visualFingerprintCanvas ?? null;
let visualFingerprint: string | null = null;
if (shouldBuildVisualFingerprintBeforeEncode(options.shouldBuildVisualFingerprint)) {
const visualFingerprintStartedAt = nowMs();
visualFingerprint = buildSourceVisualFingerprint(video, sourceRect, visualFingerprintCanvas);
captureTimings.visualFingerprintMs = Math.max(0, nowMs() - visualFingerprintStartedAt);
}
let hasTriedLazyVisualFingerprint = false;
const candidate: LPRFrameEncodeCandidate = {
height: targetSize.height,
...(visualFingerprint !== null ? { visualFingerprint } : {}),
width: targetSize.width,
};
if (!await shouldEncodeFrameCandidate(candidate, options.shouldEncode)) {
return null;
}
const encoded = await captureEncodedCanvas(video, canvas, sourceRect, targetSize, options);
if (encoded === null) {
return null;
}
const { blob, canvas: encodedCanvas } = encoded;
captureTimings.drawMs = encoded.timings.drawMs;
captureTimings.encodeMs = encoded.timings.encodeMs;
const getVisualFingerprint = visualFingerprintCanvas
? () => {
if (visualFingerprint !== null || hasTriedLazyVisualFingerprint) {
return visualFingerprint;
}
hasTriedLazyVisualFingerprint = true;
visualFingerprint = buildSourceVisualFingerprint(encodedCanvas, {
height: targetSize.height,
width: targetSize.width,
x: 0,
y: 0,
}, visualFingerprintCanvas);
return visualFingerprint;
}
: undefined;
return {
blob,
captureDurationMs: Math.max(0, nowMs() - captureStartedAt),
captureTimings,
filename: LPR_FRAME_FILE_NAME,
fingerprint: buildLPRFrameFingerprintKey(blob, targetSize.width, targetSize.height),
getContentFingerprint: buildMemoizedLPRFrameContentFingerprint(blob, targetSize.width, targetSize.height),
...(getVisualFingerprint ? { getVisualFingerprint } : {}),
height: targetSize.height,
mimeType: blob.type || LPR_FRAME_MIME_TYPE,
...(visualFingerprint !== null ? { visualFingerprint } : {}),
width: targetSize.width,
};
};
+44 -20
View File
@@ -17,6 +17,18 @@ const isLaneSelfServeEnabled = (lane) =>
const hasSelfServeEnabledLane = (department) => const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled); department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const normalizeLocationCoordinates = (locationValue = locations.location.value) =>
locations.normalizeCoordinatePair(locationValue?.coords);
const normalizeDepartmentCoordinates = (department) =>
locations.normalizeCoordinatePair(
{
latitude: department?.latitude,
longitude: department?.longitude,
},
{ allowZeroPair: false }
);
const toDepartmentViewModel = (department, distance = null) => ({ const toDepartmentViewModel = (department, distance = null) => ({
id: department.id, id: department.id,
distance, distance,
@@ -81,7 +93,8 @@ export function useWashDepartments(options = {}) {
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback"); const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords; const hasLocationCoordinates = (locationValue = locations.location.value) =>
normalizeLocationCoordinates(locationValue) !== null;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {}); const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
@@ -138,23 +151,21 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value; return nearestDepartment.value;
} }
const from = normalizeLocationCoordinates(locationValue);
let currentNearestDepartment = { let currentNearestDepartment = {
id: null, id: null,
distance: Infinity, distance: Infinity,
}; };
guestDepartments.value.forEach((department) => { guestDepartments.value.forEach((department) => {
const from = { const to = normalizeDepartmentCoordinates(department);
latitude: locationValue.coords.latitude, if (!from || !to) {
longitude: locationValue.coords.longitude, return;
}; }
const to = {
latitude: department.latitude,
longitude: department.longitude,
};
const distance = locations.getDistance(from, to); const distance = locations.getDistance(from, to);
if (distance < currentNearestDepartment.distance) { if (Number.isFinite(distance) && distance < currentNearestDepartment.distance) {
currentNearestDepartment = toDepartmentViewModel(department, distance); currentNearestDepartment = toDepartmentViewModel(department, distance);
} }
}); });
@@ -179,20 +190,33 @@ export function useWashDepartments(options = {}) {
}; };
const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => { const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => {
if (!locations.location.value?.coords) { const currentCoords = normalizeLocationCoordinates(locations.location.value);
if (!currentCoords) {
return departmentsList; return departmentsList;
} }
const distanceFromCurrentLocation = (department) => {
const departmentCoords = normalizeDepartmentCoordinates(department);
if (!departmentCoords) {
return Number.POSITIVE_INFINITY;
}
return locations.getDistance(currentCoords, departmentCoords);
};
return departmentsList.slice().sort((departmentA, departmentB) => { return departmentsList.slice().sort((departmentA, departmentB) => {
const currentCoords = locations.location.value.coords; const distanceA = distanceFromCurrentLocation(departmentA);
const distanceA = locations.getDistance( const distanceB = distanceFromCurrentLocation(departmentB);
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
{ latitude: departmentA.latitude, longitude: departmentA.longitude } if (!Number.isFinite(distanceA) && !Number.isFinite(distanceB)) {
); return 0;
const distanceB = locations.getDistance( }
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude }, if (!Number.isFinite(distanceA)) {
{ latitude: departmentB.latitude, longitude: departmentB.longitude } return 1;
); }
if (!Number.isFinite(distanceB)) {
return -1;
}
return distanceA - distanceB; return distanceA - distanceB;
}); });
+5 -5
View File
@@ -82,11 +82,11 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({
// Per-method concurrency limits // Per-method concurrency limits
concurrency: Object.freeze({ concurrency: Object.freeze({
GET: 10, GET: 10,
POST: 1, POST: 4,
PATCH: 1, PATCH: 4,
PUT: 1, PUT: 4,
DELETE: 1, DELETE: 4,
DEFAULT: 1, DEFAULT: 4,
}), }),
// Delay between queue starts (0 = no pacing delay) // Delay between queue starts (0 = no pacing delay)
spacingMs: 0, spacingMs: 0,
+8
View File
@@ -1150,6 +1150,14 @@
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrævet'}", "field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrævet'}",
"no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgængelige'}." "no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgængelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.afdeling'} manuelt",
"manual_title": "@.capitalize:{'words.generated.vælg'} din @:{'words.generated.afdeling'}",
"manual_loading": "@.capitalize:{'words.generated.indlæser'} afdelinger...",
"use_department": "@.capitalize:{'words.generated.vælg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
+8
View File
@@ -1260,6 +1260,14 @@
"field_required": "{field} @:{'words.generated.ist'} @:{'words.generated.erforderlich'}", "field_required": "{field} @:{'words.generated.ist'} @:{'words.generated.erforderlich'}",
"no_entity_available": "@.capitalize:{'words.generated.keine'} {entity} @:{'words.generated.verfugbar'}." "no_entity_available": "@.capitalize:{'words.generated.keine'} {entity} @:{'words.generated.verfugbar'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'words.generated.abteilung'} @:{'words.generated.manuell'} @:{'words.generated.auswahlen'}",
"manual_title": "@.capitalize:{'words.generated.wahlen'} @.capitalize:{'words.generated.sie'} @.capitalize:{'words.generated.ihre'} @:{'words.generated.abteilung'}",
"manual_loading": "@:{'words.generated.abteilungen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
"use_department": "{name} @:{'words.generated.auswahlen'}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
+8
View File
@@ -984,6 +984,14 @@
"field_required": "{field} @:{'words.generated.is'} @:{'words.generated.required'}", "field_required": "{field} @:{'words.generated.is'} @:{'words.generated.required'}",
"no_entity_available": "@.capitalize:{'words.generated.no'} {entity} @:{'words.generated.available'}." "no_entity_available": "@.capitalize:{'words.generated.no'} {entity} @:{'words.generated.available'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.select'} @:{'words.generated.department'} @:{'words.generated.manually'}",
"manual_title": "@.capitalize:{'words.generated.select'} @:{'words.generated.your'} @:{'words.generated.department'}",
"manual_loading": "@.capitalize:{'words.generated.loading'} @:{'words.generated.departments'}...",
"use_department": "@.capitalize:{'words.generated.select'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'} @:{'words.replication.host'}", "database": "@:{'words.replication.services.database'} @:{'words.replication.host'}",
+13
View File
@@ -1367,7 +1367,12 @@
"customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}", "customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}", "notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}", "notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}",
"send_test_webhook": "@:{'templates.generated.compat.configuration.slack.send_test_webhook'}",
"subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}", "subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}",
"test_webhook_error": "@:{'templates.generated.compat.configuration.slack.test_webhook_error'}",
"test_webhook_not_configured": "@:{'templates.generated.compat.configuration.slack.test_webhook_not_configured'}",
"test_webhook_sent": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent'}",
"test_webhook_sent_success": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent_success'}",
"title": "@:{'templates.generated.compat.configuration.slack.title'}", "title": "@:{'templates.generated.compat.configuration.slack.title'}",
"unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}" "unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}"
}, },
@@ -4059,6 +4064,14 @@
}, },
"title": "@:common.profile" "title": "@:common.profile"
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'templates.redirect.mobile_department_auto_select.manual_button'}",
"manual_title": "@:{'templates.redirect.mobile_department_auto_select.manual_title'}",
"manual_loading": "@:{'templates.redirect.mobile_department_auto_select.manual_loading'}",
"use_department": "@:{'templates.redirect.mobile_department_auto_select.use_department'}"
}
},
"replication": { "replication": {
"actions": { "actions": {
"add": "@:{'templates.generated.compat.replication.actions.add'}", "add": "@:{'templates.generated.compat.replication.actions.add'}",
+13
View File
@@ -1261,6 +1261,14 @@
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrevd'}", "field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrevd'}",
"no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgjengelige'}." "no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgjengelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.velg'} @:{'words.generated.avdeling'} @:{'words.generated.manuelt'}",
"manual_title": "@.capitalize:{'words.generated.velg'} @:{'words.generated.din'} @:{'words.generated.avdeling'}",
"manual_loading": "@.capitalize:{'words.generated.laster'} @:{'words.generated.avdelinger'}...",
"use_department": "@.capitalize:{'words.generated.velg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -2446,7 +2454,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger", "notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.", "notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler", "subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon", "title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen." "unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
}, },
+8
View File
@@ -1311,6 +1311,14 @@
"field_required": "{field} @:{'words.generated.kravs'}", "field_required": "{field} @:{'words.generated.kravs'}",
"no_entity_available": "@.capitalize:{'words.generated.inga'} {entity} @:{'words.generated.tillgangliga'}." "no_entity_available": "@.capitalize:{'words.generated.inga'} {entity} @:{'words.generated.tillgangliga'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.valj'} @:{'words.generated.avdelning'} manuellt",
"manual_title": "@.capitalize:{'words.generated.valj'} @:{'words.generated.din'} @:{'words.generated.avdelning'}",
"manual_loading": "@:{'words.generated.laddar'} @:{'words.generated.avdelningar'}...",
"use_department": "@.capitalize:{'words.generated.valj'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
+13
View File
@@ -30,6 +30,14 @@
"all": "Alle" "all": "Alle"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Vælg afdeling manuelt",
"manual_title": "Vælg din afdeling",
"manual_loading": "Indlæser afdelinger...",
"use_department": "Vælg {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.",
"notification_settings": "Notifikationer", "notification_settings": "Notifikationer",
"notification_settings_desc": "Slack-webhooks til systemhændelser.", "notification_settings_desc": "Slack-webhooks til systemhændelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfiguration af Slack-notifikationer", "subtitle": "Konfiguration af Slack-notifikationer",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Gem en webhook-URL til kunderegistreringer, før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testbeskeden for kunderegistrering blev sendt.",
"title": "Slack-konfiguration", "title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse." "unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "Alle" "all": "Alle"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Abteilung manuell auswählen",
"manual_title": "Wählen Sie Ihre Abteilung",
"manual_loading": "Abteilungen werden geladen...",
"use_department": "{name} auswählen"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.", "customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.",
"notification_settings": "Benachrichtigungseinstellungen", "notification_settings": "Benachrichtigungseinstellungen",
"notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.", "notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.",
"send_test_webhook": "Test-Webhook senden",
"subtitle": "Konfiguration von Slack-Benachrichtigungen", "subtitle": "Konfiguration von Slack-Benachrichtigungen",
"test_webhook_error": "Der Slack-Test-Webhook konnte nicht gesendet werden.",
"test_webhook_not_configured": "Speichern Sie zuerst eine Webhook-URL fuer Kundenregistrierungen.",
"test_webhook_sent": "Slack-Test gesendet",
"test_webhook_sent_success": "Die Slack-Testnachricht fuer Kundenregistrierungen wurde gesendet.",
"title": "Slack-Konfiguration", "title": "Slack-Konfiguration",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar." "unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "All" "all": "All"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Select department manually",
"manual_title": "Select your department",
"manual_loading": "Loading departments...",
"use_department": "Select {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.", "customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.",
"notification_settings": "Notification settings", "notification_settings": "Notification settings",
"notification_settings_desc": "Slack webhooks for system events.", "notification_settings_desc": "Slack webhooks for system events.",
"send_test_webhook": "Send test webhook",
"subtitle": "Configuration of Slack notifications", "subtitle": "Configuration of Slack notifications",
"test_webhook_error": "Could not send the Slack test webhook.",
"test_webhook_not_configured": "Save a customer registration webhook URL before sending a test.",
"test_webhook_sent": "Slack test sent",
"test_webhook_sent_success": "The Slack customer registration test message was sent.",
"title": "Slack configuration", "title": "Slack configuration",
"unavailable": "Slack configuration is not available on this API release." "unavailable": "Slack configuration is not available on this API release."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "Alle" "all": "Alle"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Velg avdeling manuelt",
"manual_title": "Velg din avdeling",
"manual_loading": "Laster avdelinger...",
"use_department": "Velg {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger", "notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.", "notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler", "subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon", "title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen." "unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "Alla" "all": "Alla"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Välj avdelning manuellt",
"manual_title": "Välj din avdelning",
"manual_loading": "Laddar avdelningar...",
"use_department": "Välj {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.", "customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.",
"notification_settings": "Aviseringsinställningar", "notification_settings": "Aviseringsinställningar",
"notification_settings_desc": "Slack-webhooks för systemhändelser.", "notification_settings_desc": "Slack-webhooks för systemhändelser.",
"send_test_webhook": "Skicka test-webhook",
"subtitle": "Konfiguration av Slack-aviseringar", "subtitle": "Konfiguration av Slack-aviseringar",
"test_webhook_error": "Det gick inte att skicka Slack-testwebhooken.",
"test_webhook_not_configured": "Spara en webhook-URL för kundregistreringar innan du skickar ett test.",
"test_webhook_sent": "Slack-test skickat",
"test_webhook_sent_success": "Slack-testmeddelandet för kundregistrering skickades.",
"title": "Slack-konfiguration", "title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen." "unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
}, },
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrævet'}", "field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrævet'}",
"no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgængelige'}." "no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgængelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.afdeling'} manuelt",
"manual_title": "@.capitalize:{'terms.glossary.vælg'} din @:{'terms.glossary.afdeling'}",
"manual_loading": "@.capitalize:{'terms.glossary.indlæser'} afdelinger...",
"use_department": "@.capitalize:{'terms.glossary.vælg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.ist'} @:{'terms.glossary.erforderlich'}", "field_required": "{field} @:{'terms.glossary.ist'} @:{'terms.glossary.erforderlich'}",
"no_entity_available": "@.capitalize:{'terms.glossary.keine'} {entity} @:{'terms.glossary.verfugbar'}." "no_entity_available": "@.capitalize:{'terms.glossary.keine'} {entity} @:{'terms.glossary.verfugbar'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'terms.glossary.abteilung'} @:{'terms.glossary.manuell'} @:{'terms.glossary.auswahlen'}",
"manual_title": "@.capitalize:{'terms.glossary.wahlen'} @.capitalize:{'terms.glossary.sie'} @.capitalize:{'terms.glossary.ihre'} @:{'terms.glossary.abteilung'}",
"manual_loading": "@:{'terms.glossary.abteilungen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"use_department": "{name} @:{'terms.glossary.auswahlen'}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.is'} @:{'terms.glossary.required'}", "field_required": "{field} @:{'terms.glossary.is'} @:{'terms.glossary.required'}",
"no_entity_available": "@.capitalize:{'terms.glossary.no'} {entity} @:{'terms.glossary.available'}." "no_entity_available": "@.capitalize:{'terms.glossary.no'} {entity} @:{'terms.glossary.available'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.department'} @:{'terms.glossary.manually'}",
"manual_title": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.your'} @:{'terms.glossary.department'}",
"manual_loading": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.departments'}...",
"use_department": "@.capitalize:{'terms.glossary.select'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'} @:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'} @:{'terms.replication.host'}",
@@ -189,7 +189,12 @@
"customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}", "customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}", "notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}", "notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}",
"send_test_webhook": "@:{'phrases.compat.configuration.slack.send_test_webhook'}",
"subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}", "subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}",
"test_webhook_error": "@:{'phrases.compat.configuration.slack.test_webhook_error'}",
"test_webhook_not_configured": "@:{'phrases.compat.configuration.slack.test_webhook_not_configured'}",
"test_webhook_sent": "@:{'phrases.compat.configuration.slack.test_webhook_sent'}",
"test_webhook_sent_success": "@:{'phrases.compat.configuration.slack.test_webhook_sent_success'}",
"title": "@:{'phrases.compat.configuration.slack.title'}", "title": "@:{'phrases.compat.configuration.slack.title'}",
"unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}" "unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}"
}, },
@@ -0,0 +1,10 @@
{
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'phrases.redirect.mobile_department_auto_select.manual_button'}",
"manual_title": "@:{'phrases.redirect.mobile_department_auto_select.manual_title'}",
"manual_loading": "@:{'phrases.redirect.mobile_department_auto_select.manual_loading'}",
"use_department": "@:{'phrases.redirect.mobile_department_auto_select.use_department'}"
}
}
}
@@ -163,7 +163,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger", "notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.", "notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler", "subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon", "title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen." "unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
}, },
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrevd'}", "field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrevd'}",
"no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgjengelige'}." "no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgjengelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.avdeling'} @:{'terms.glossary.manuelt'}",
"manual_title": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.din'} @:{'terms.glossary.avdeling'}",
"manual_loading": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.avdelinger'}...",
"use_department": "@.capitalize:{'terms.glossary.velg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.kravs'}", "field_required": "{field} @:{'terms.glossary.kravs'}",
"no_entity_available": "@.capitalize:{'terms.glossary.inga'} {entity} @:{'terms.glossary.tillgangliga'}." "no_entity_available": "@.capitalize:{'terms.glossary.inga'} {entity} @:{'terms.glossary.tillgangliga'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.avdelning'} manuellt",
"manual_title": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.din'} @:{'terms.glossary.avdelning'}",
"manual_loading": "@:{'terms.glossary.laddar'} @:{'terms.glossary.avdelningar'}...",
"use_department": "@.capitalize:{'terms.glossary.valj'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+1
View File
@@ -116,6 +116,7 @@ const app = createApp(App)
.provide('Colors', Colors) .provide('Colors', Colors)
.provide('IS_DEV', IS_DEV) .provide('IS_DEV', IS_DEV)
.provide('API_URL', getReleaseRuntimeApiBaseUrl()) .provide('API_URL', getReleaseRuntimeApiBaseUrl())
.provide('VERSION', `${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)}`);
installReleaseErrorInstrumentation(app, Router); installReleaseErrorInstrumentation(app, Router);
app.mount('#app'); app.mount('#app');
+66 -15
View File
@@ -1,24 +1,15 @@
const CACHE_INVALIDATION_THROTTLE_MS = 30 * 1000; const CACHE_INVALIDATION_THROTTLE_MS = 30 * 1000;
const CACHE_BUST_PARAM = "force_update"; const CACHE_BUST_PARAM = "force_update";
const PRESERVED_LOCAL_STORAGE_KEYS = ["token"]; const KNOWN_INDEXED_DB_NAMES = ["workbox-expiration", "workbox-background-sync", "pleno", "truckwash"];
let lastErrorInvalidationAt = 0; let lastErrorInvalidationAt = 0;
const browserWindow = () => (typeof window !== "undefined" ? window : null); const browserWindow = () => (typeof window !== "undefined" ? window : null);
const browserNavigator = () => (typeof navigator !== "undefined" ? navigator : null); const browserNavigator = () => (typeof navigator !== "undefined" ? navigator : null);
const clearStorage = (storage, preservedKeys = []) => { const clearStorage = (storage) => {
try { try {
const preservedEntries = preservedKeys
.map((key) => [key, storage?.getItem(key)])
.filter(([, value]) => value !== null && value !== undefined);
storage?.clear(); storage?.clear();
preservedEntries.forEach(([key, value]) => {
storage?.setItem(key, value);
});
return true; return true;
} catch { } catch {
return false; return false;
@@ -60,6 +51,62 @@ const unregisterServiceWorkers = async () => {
return registrations.length; return registrations.length;
}; };
const browserIndexedDb = () => {
const win = browserWindow();
if (win?.indexedDB) {
return win.indexedDB;
}
return typeof indexedDB !== "undefined" ? indexedDB : null;
};
const deleteIndexedDatabase = (indexedDb, databaseName) =>
new Promise((resolve) => {
if (!indexedDb || !databaseName) {
resolve(false);
return;
}
try {
const request = indexedDb.deleteDatabase(databaseName);
request.onsuccess = () => resolve(true);
request.onerror = () => resolve(false);
request.onblocked = () => resolve(false);
} catch {
resolve(false);
}
});
const getIndexedDatabaseNames = async (indexedDb) => {
if (typeof indexedDb?.databases !== "function") {
return KNOWN_INDEXED_DB_NAMES;
}
try {
const databases = await indexedDb.databases();
return [
...new Set(
databases
.map((database) => database?.name)
.filter((databaseName) => typeof databaseName === "string" && databaseName.length > 0)
),
];
} catch {
return KNOWN_INDEXED_DB_NAMES;
}
};
const deleteIndexedDatabases = async () => {
const indexedDb = browserIndexedDb();
if (!indexedDb) {
return [];
}
const databaseNames = await getIndexedDatabaseNames(indexedDb);
await Promise.all(databaseNames.map((databaseName) => deleteIndexedDatabase(indexedDb, databaseName)));
return databaseNames;
};
const reloadWithCacheBust = () => { const reloadWithCacheBust = () => {
const win = browserWindow(); const win = browserWindow();
if (!win?.location) { if (!win?.location) {
@@ -83,13 +130,17 @@ export const invalidateFrontendCachesAfterError = async ({ now = Date.now() } =
export const forceFrontendUpdateAndClearLocal = async ({ reload = reloadWithCacheBust } = {}) => { export const forceFrontendUpdateAndClearLocal = async ({ reload = reloadWithCacheBust } = {}) => {
const win = browserWindow(); const win = browserWindow();
const [cacheNames, serviceWorkerRegistrations] = await Promise.all([clearCacheStorage(), unregisterServiceWorkers()]); clearStorage(win?.localStorage);
clearStorage(win?.localStorage, PRESERVED_LOCAL_STORAGE_KEYS);
clearStorage(win?.sessionStorage); clearStorage(win?.sessionStorage);
const [cacheNames, serviceWorkerRegistrations, indexedDatabaseNames] = await Promise.all([
clearCacheStorage(),
unregisterServiceWorkers(),
deleteIndexedDatabases(),
]);
reload(); reload();
return { cacheNames, serviceWorkerRegistrations }; return { cacheNames, serviceWorkerRegistrations, indexedDatabaseNames };
}; };
export const __resetFrontendMaintenanceForTests = () => { export const __resetFrontendMaintenanceForTests = () => {
+1
View File
@@ -58,6 +58,7 @@ export const installAxiosRequestQueue = () => {
data: adapterConfig?.data ?? config?.data ?? null, data: adapterConfig?.data ?? config?.data ?? null,
headers: adapterConfig?.headers ?? config?.headers ?? null, headers: adapterConfig?.headers ?? config?.headers ?? null,
}, },
signal: adapterConfig?.signal ?? config?.signal,
}); });
return config; return config;
}); });
+273 -45
View File
@@ -27,6 +27,7 @@ const requestQueueStateMutable = reactive({
batchFailed: 0, batchFailed: 0,
activeRequests: [], activeRequests: [],
recentRequests: [], recentRequests: [],
requestInsights: {},
errorRequests: [], errorRequests: [],
missingPermissions: [], missingPermissions: [],
networkTotals: { networkTotals: {
@@ -38,8 +39,10 @@ const requestQueueStateMutable = reactive({
}); });
const requestQueue = []; const requestQueue = [];
const activeWorkersByMethod = {}; const activeWorkersByKey = {};
let activeWorkers = 0; let activeWorkers = 0;
let trackedActiveWorkers = 0;
let trackedPendingJobs = 0;
let requestIdCounter = 0; let requestIdCounter = 0;
let drainTimer = null; let drainTimer = null;
let lastRequestStartedAt = 0; let lastRequestStartedAt = 0;
@@ -60,8 +63,8 @@ const startBatchIfNeeded = () => {
}; };
const syncQueueCounters = () => { const syncQueueCounters = () => {
requestQueueStateMutable.pending = requestQueue.length; requestQueueStateMutable.pending = trackedPendingJobs;
requestQueueStateMutable.active = activeWorkers; requestQueueStateMutable.active = trackedActiveWorkers;
}; };
const normalizeMethod = (value) => { const normalizeMethod = (value) => {
@@ -71,6 +74,22 @@ const normalizeMethod = (value) => {
return value.trim().toUpperCase(); return value.trim().toUpperCase();
}; };
const normalizeQueueGroup = (value) => {
if (typeof value !== "string") {
return "";
}
return value.trim().toUpperCase();
};
const normalizeInsightKey = (value) => {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
};
const normalizeUrl = (value) => { const normalizeUrl = (value) => {
if (typeof value !== "string" || value.trim().length === 0) { if (typeof value !== "string" || value.trim().length === 0) {
return "(unknown endpoint)"; return "(unknown endpoint)";
@@ -99,13 +118,13 @@ const toSafeText = (value) => {
return ""; return "";
} }
let text = ""; let text;
if (typeof value === "string") { if (typeof value === "string") {
text = value; text = value;
} else { } else {
try { try {
text = JSON.stringify(value, null, 2); text = JSON.stringify(value, null, 2);
} catch (error) { } catch (_error) {
text = String(value); text = String(value);
} }
} }
@@ -176,7 +195,7 @@ const parseJsonIfString = (value) => {
try { try {
return JSON.parse(trimmedValue); return JSON.parse(trimmedValue);
} catch (error) { } catch (_error) {
return value; return value;
} }
}; };
@@ -231,10 +250,28 @@ const getMethodConcurrencyLimit = (method) => {
return Math.max(1, Number(configuredLimit) || 1); return Math.max(1, Number(configuredLimit) || 1);
}; };
const getJobConcurrencyKey = (job) => {
const queueGroup = normalizeQueueGroup(job.queueGroup);
if (queueGroup) {
return `GROUP:${queueGroup}`;
}
return `METHOD:${normalizeMethod(job.method)}`;
};
const getJobConcurrencyLimit = (job) => {
const configuredLimit = Number.parseInt(String(job.concurrencyLimit ?? ""), 10);
if (Number.isInteger(configuredLimit) && configuredLimit > 0) {
return configuredLimit;
}
return getMethodConcurrencyLimit(job.method);
};
const canRunJob = (job) => { const canRunJob = (job) => {
const method = normalizeMethod(job.method); const concurrencyKey = getJobConcurrencyKey(job);
const activeForMethod = Number(activeWorkersByMethod[method] || 0); const activeForKey = Number(activeWorkersByKey[concurrencyKey] || 0);
return activeForMethod < getMethodConcurrencyLimit(method); return activeForKey < getJobConcurrencyLimit(job);
}; };
const getNextRunnableJobIndex = () => { const getNextRunnableJobIndex = () => {
@@ -275,6 +312,34 @@ const wait = (durationMs) => new Promise((resolve) => {
setTimeout(resolve, Math.max(0, durationMs || 0)); setTimeout(resolve, Math.max(0, durationMs || 0));
}); });
const isAbortSignal = (signal) =>
signal && typeof signal === "object" && typeof signal.aborted === "boolean";
const isRequestAbortError = (error) =>
error?.name === "AbortError" || error?.name === "CanceledError" || error?.code === "ERR_CANCELED";
const createAbortError = () => {
if (typeof DOMException === "function") {
return new DOMException("Request aborted.", "AbortError");
}
const error = new Error("Request aborted.");
error.name = "AbortError";
error.code = "ERR_CANCELED";
return error;
};
const isJobAborted = (job) => isAbortSignal(job?.signal) && job.signal.aborted === true;
const decrementBatchTotalForCanceledJob = () => {
const completedOrFailed = Number(requestQueueStateMutable.batchCompleted || 0)
+ Number(requestQueueStateMutable.batchFailed || 0);
requestQueueStateMutable.batchTotal = Math.max(
completedOrFailed,
Number(requestQueueStateMutable.batchTotal || 0) - 1
);
};
const getErrorStatusCode = (error) => { const getErrorStatusCode = (error) => {
const code = Number.parseInt(error?.response?.status, 10); const code = Number.parseInt(error?.response?.status, 10);
return Number.isFinite(code) ? code : null; return Number.isFinite(code) ? code : null;
@@ -285,6 +350,28 @@ const getResponseStatusCode = (response) => {
return Number.isFinite(code) ? code : null; return Number.isFinite(code) ? code : null;
}; };
const getHeaderValue = (headers, name) => {
if (!headers || typeof name !== "string") {
return null;
}
if (typeof headers.get === "function") {
const value = headers.get(name);
return typeof value === "string" && value.length > 0 ? value : null;
}
const normalizedName = name.toLowerCase();
const matchingKey = Object.keys(headers).find((key) => String(key).toLowerCase() === normalizedName);
if (!matchingKey) {
return null;
}
const value = headers[matchingKey];
return typeof value === "string" && value.length > 0 ? value : null;
};
const getServerTimingHeader = (response) => getHeaderValue(response?.headers, "server-timing");
const getRetriesForStatusCode = (statusCode, retryByStatusCode) => { const getRetriesForStatusCode = (statusCode, retryByStatusCode) => {
if (statusCode === null) { if (statusCode === null) {
return 0; return 0;
@@ -304,6 +391,10 @@ const computeRetryDelayMs = (attemptNumber) => {
}; };
const shouldRetryAttempt = (error, job, attemptNumber) => { const shouldRetryAttempt = (error, job, attemptNumber) => {
if (isRequestAbortError(error) || isJobAborted(job)) {
return false;
}
const statusCode = getErrorStatusCode(error); const statusCode = getErrorStatusCode(error);
const retryByStatusCode = job.retryByStatusCode ?? queueConfig.retryByStatusCode; const retryByStatusCode = job.retryByStatusCode ?? queueConfig.retryByStatusCode;
const retriesForCode = getRetriesForStatusCode(statusCode, retryByStatusCode); const retriesForCode = getRetriesForStatusCode(statusCode, retryByStatusCode);
@@ -320,27 +411,38 @@ const shouldRetryAttempt = (error, job, attemptNumber) => {
const executeJobWithRetries = async (job) => { const executeJobWithRetries = async (job) => {
let attemptCount = 0; let attemptCount = 0;
const shouldRecordNetworkTotals = job.skipNetworkTotals !== true;
while (true) { while (true) {
if (isJobAborted(job)) {
throw createAbortError();
}
attemptCount += 1; attemptCount += 1;
const method = normalizeMethod(job.method); const method = normalizeMethod(job.method);
const url = normalizeUrl(job.url); const url = normalizeUrl(job.url);
addNetworkTotals({ if (shouldRecordNetworkTotals) {
outgoingRequests: 1, addNetworkTotals({
outgoingBytes: estimateRequestBytes(job, method, url), outgoingRequests: 1,
}); outgoingBytes: job.skipRequestByteAccounting ? 0 : estimateRequestBytes(job, method, url),
});
}
try { try {
const response = await job.requestFactory(); const response = await job.requestFactory();
addNetworkTotals({ if (shouldRecordNetworkTotals) {
ingoingResponses: 1,
ingoingBytes: estimateResponseBytes(response),
});
return { response, attemptCount };
} catch (error) {
if (error?.response) {
addNetworkTotals({ addNetworkTotals({
ingoingResponses: 1, ingoingResponses: 1,
ingoingBytes: estimateResponseBytes(error.response, error?.message ?? "Request failed"), ingoingBytes: job.skipResponseByteAccounting ? 0 : estimateResponseBytes(response),
});
}
return { response, attemptCount };
} catch (error) {
if (shouldRecordNetworkTotals && error?.response) {
addNetworkTotals({
ingoingResponses: 1,
ingoingBytes: job.skipResponseByteAccounting
? 0
: estimateResponseBytes(error.response, error?.message ?? "Request failed"),
}); });
} }
const attemptNumber = attemptCount - 1; const attemptNumber = attemptCount - 1;
@@ -361,6 +463,7 @@ const upsertActiveRequest = (job, startedAt) => {
const nextActive = [...requestQueueStateMutable.activeRequests, { const nextActive = [...requestQueueStateMutable.activeRequests, {
id: job.id, id: job.id,
method: normalizeMethod(job.method), method: normalizeMethod(job.method),
queueGroup: normalizeQueueGroup(job.queueGroup) || null,
url: job.url, url: job.url,
queuedAt: job.enqueuedAt, queuedAt: job.enqueuedAt,
startedAt, startedAt,
@@ -378,6 +481,15 @@ const pushRecentRequest = (entry) => {
requestQueueStateMutable.recentRequests = next.slice(0, limit); requestQueueStateMutable.recentRequests = next.slice(0, limit);
}; };
const upsertRequestInsight = (key, entry) => {
const normalizedKey = normalizeInsightKey(key);
if (!normalizedKey) {
return;
}
requestQueueStateMutable.requestInsights[normalizedKey] = entry;
};
const pushErrorRequest = (entry) => { const pushErrorRequest = (entry) => {
const limit = Math.max(1, Number(queueConfig.errorHistoryLimit) || 10); const limit = Math.max(1, Number(queueConfig.errorHistoryLimit) || 10);
const next = [entry, ...requestQueueStateMutable.errorRequests]; const next = [entry, ...requestQueueStateMutable.errorRequests];
@@ -472,24 +584,61 @@ export const reportComponentMissingPermission = (permission, options = {}) => {
}]); }]);
}; };
const removePendingAbortListener = (job) => {
if (
job?.abortPendingListener
&& isAbortSignal(job.signal)
&& typeof job.signal.removeEventListener === "function"
) {
job.signal.removeEventListener("abort", job.abortPendingListener);
}
if (job) {
job.abortPendingListener = null;
}
};
const rejectCanceledQueuedJob = (job) => {
removePendingAbortListener(job);
if (job.trackProgressCounters !== false) {
trackedPendingJobs = Math.max(0, trackedPendingJobs - 1);
decrementBatchTotalForCanceledJob();
syncQueueCounters();
}
job.reject(createAbortError());
};
const runJob = (job) => { const runJob = (job) => {
const method = normalizeMethod(job.method); const method = normalizeMethod(job.method);
const concurrencyKey = getJobConcurrencyKey(job);
const queueGroup = normalizeQueueGroup(job.queueGroup) || null;
const startedAt = Date.now(); const startedAt = Date.now();
removePendingAbortListener(job);
activeWorkers += 1; activeWorkers += 1;
activeWorkersByMethod[method] = Number(activeWorkersByMethod[method] || 0) + 1; activeWorkersByKey[concurrencyKey] = Number(activeWorkersByKey[concurrencyKey] || 0) + 1;
if (job.trackProgressCounters !== false) {
trackedActiveWorkers += 1;
}
lastRequestStartedAt = startedAt; lastRequestStartedAt = startedAt;
upsertActiveRequest(job, startedAt); if (job.trackActiveRequest !== false) {
syncQueueCounters(); upsertActiveRequest(job, startedAt);
}
if (job.trackProgressCounters !== false) {
syncQueueCounters();
}
Promise.resolve() Promise.resolve()
.then(() => executeJobWithRetries(job)) .then(() => executeJobWithRetries(job))
.then(({ response, attemptCount }) => { .then(({ response, attemptCount }) => {
const completedAt = Date.now(); const completedAt = Date.now();
requestQueueStateMutable.batchCompleted += 1; if (job.trackProgressCounters !== false) {
pushRecentRequest({ requestQueueStateMutable.batchCompleted += 1;
}
const completedEntry = {
id: job.id, id: job.id,
method, method,
queueGroup,
url: job.url, url: job.url,
success: true, success: true,
statusCode: getResponseStatusCode(response), statusCode: getResponseStatusCode(response),
@@ -499,12 +648,26 @@ const runJob = (job) => {
completedAt, completedAt,
queueDurationMs: Math.max(0, startedAt - job.enqueuedAt), queueDurationMs: Math.max(0, startedAt - job.enqueuedAt),
requestDurationMs: Math.max(0, completedAt - startedAt), requestDurationMs: Math.max(0, completedAt - startedAt),
}); serverTiming: getServerTimingHeader(response),
};
upsertRequestInsight(job.insightKey, completedEntry);
if (job.recordRecentOnSuccess !== false) {
pushRecentRequest(completedEntry);
}
job.resolve(response); job.resolve(response);
}) })
.catch((error) => { .catch((error) => {
const completedAt = Date.now(); const completedAt = Date.now();
if (isRequestAbortError(error)) {
if (job.trackProgressCounters !== false) {
decrementBatchTotalForCanceledJob();
}
job.reject(error);
return;
}
const statusCode = getErrorStatusCode(error); const statusCode = getErrorStatusCode(error);
const attemptCount = Math.max(1, Number(error?.__queueAttemptCount) || 1);
const requestSnapshot = { const requestSnapshot = {
method, method,
url: job.url, url: job.url,
@@ -520,27 +683,35 @@ const runJob = (job) => {
data: error?.response?.data ?? null, data: error?.response?.data ?? null,
headers: redactHeaders(error?.response?.headers ?? null), headers: redactHeaders(error?.response?.headers ?? null),
}; };
requestQueueStateMutable.batchFailed += 1; if (job.trackProgressCounters !== false) {
pushRecentRequest({ requestQueueStateMutable.batchFailed += 1;
}
const failedEntry = {
id: job.id, id: job.id,
method, method,
queueGroup,
url: job.url, url: job.url,
success: false, success: false,
statusCode, statusCode,
attemptCount: Math.max(1, Number(error?.__queueAttemptCount) || 1), attemptCount,
queuedAt: job.enqueuedAt, queuedAt: job.enqueuedAt,
startedAt, startedAt,
completedAt, completedAt,
queueDurationMs: Math.max(0, startedAt - job.enqueuedAt), queueDurationMs: Math.max(0, startedAt - job.enqueuedAt),
requestDurationMs: Math.max(0, completedAt - startedAt), requestDurationMs: Math.max(0, completedAt - startedAt),
}); serverTiming: getServerTimingHeader(error?.response),
};
upsertRequestInsight(job.insightKey, failedEntry);
pushRecentRequest(failedEntry);
pushErrorRequest({ pushErrorRequest({
id: job.id, id: job.id,
method, method,
queueGroup,
url: job.url, url: job.url,
statusCode, statusCode,
attemptCount: Math.max(1, Number(error?.__queueAttemptCount) || 1), attemptCount,
requestDurationMs: Math.max(0, completedAt - startedAt), requestDurationMs: Math.max(0, completedAt - startedAt),
serverTiming: getServerTimingHeader(error?.response),
requestText: toSafeText(requestSnapshot), requestText: toSafeText(requestSnapshot),
responseText: toSafeText(responseSnapshot), responseText: toSafeText(responseSnapshot),
}); });
@@ -549,7 +720,7 @@ const runJob = (job) => {
method, method,
url: job.url, url: job.url,
statusCode, statusCode,
attemptCount: Math.max(1, Number(error?.__queueAttemptCount) || 1), attemptCount,
requestDurationMs: Math.max(0, completedAt - startedAt), requestDurationMs: Math.max(0, completedAt - startedAt),
request: requestSnapshot, request: requestSnapshot,
response: responseSnapshot, response: responseSnapshot,
@@ -573,12 +744,19 @@ const runJob = (job) => {
}) })
.finally(() => { .finally(() => {
activeWorkers -= 1; activeWorkers -= 1;
activeWorkersByMethod[method] = Math.max(0, Number(activeWorkersByMethod[method] || 1) - 1); if (job.trackProgressCounters !== false) {
if (activeWorkersByMethod[method] === 0) { trackedActiveWorkers = Math.max(0, trackedActiveWorkers - 1);
delete activeWorkersByMethod[method]; }
activeWorkersByKey[concurrencyKey] = Math.max(0, Number(activeWorkersByKey[concurrencyKey] || 1) - 1);
if (activeWorkersByKey[concurrencyKey] === 0) {
delete activeWorkersByKey[concurrencyKey];
}
if (job.trackActiveRequest !== false) {
removeActiveRequest(job.id);
}
if (job.trackProgressCounters !== false) {
syncQueueCounters();
} }
removeActiveRequest(job.id);
syncQueueCounters();
scheduleDrain(); scheduleDrain();
}); });
}; };
@@ -599,7 +777,15 @@ const drainQueue = async () => {
} }
const [nextJob] = requestQueue.splice(nextRunnableJobIndex, 1); const [nextJob] = requestQueue.splice(nextRunnableJobIndex, 1);
syncQueueCounters(); if (isJobAborted(nextJob)) {
rejectCanceledQueuedJob(nextJob);
continue;
}
if (nextJob.trackProgressCounters !== false) {
trackedPendingJobs = Math.max(0, trackedPendingJobs - 1);
syncQueueCounters();
}
runJob(nextJob); runJob(nextJob);
} }
}; };
@@ -611,10 +797,18 @@ export const enqueueRequest = (requestFactory, options = {}) => {
const method = normalizeMethod(options.method); const method = normalizeMethod(options.method);
const url = normalizeUrl(options.url); const url = normalizeUrl(options.url);
startBatchIfNeeded(); const signal = isAbortSignal(options.signal) ? options.signal : null;
if (signal?.aborted) {
return Promise.reject(createAbortError());
}
const trackProgressCounters = options.trackProgressCounters !== false;
if (trackProgressCounters) {
startBatchIfNeeded();
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
requestQueue.push({ const job = {
id: ++requestIdCounter, id: ++requestIdCounter,
requestFactory, requestFactory,
resolve, resolve,
@@ -625,9 +819,39 @@ export const enqueueRequest = (requestFactory, options = {}) => {
requestData: options.requestData || null, requestData: options.requestData || null,
retryByStatusCode: options.retryByStatusCode || null, retryByStatusCode: options.retryByStatusCode || null,
shouldRetry: typeof options.shouldRetry === "function" ? options.shouldRetry : null, shouldRetry: typeof options.shouldRetry === "function" ? options.shouldRetry : null,
}); queueGroup: normalizeQueueGroup(options.queueGroup),
requestQueueStateMutable.batchTotal += 1; concurrencyLimit: options.concurrencyLimit || null,
syncQueueCounters(); skipRequestByteAccounting: options.skipRequestByteAccounting === true,
skipResponseByteAccounting: options.skipResponseByteAccounting === true,
skipNetworkTotals: options.skipNetworkTotals === true,
insightKey: normalizeInsightKey(options.insightKey),
recordRecentOnSuccess: options.recordRecentOnSuccess !== false,
trackActiveRequest: options.trackActiveRequest !== false,
trackProgressCounters,
signal,
abortPendingListener: null,
};
if (signal && typeof signal.addEventListener === "function") {
job.abortPendingListener = () => {
const pendingIndex = requestQueue.findIndex((pendingJob) => pendingJob.id === job.id);
if (pendingIndex < 0) {
return;
}
requestQueue.splice(pendingIndex, 1);
rejectCanceledQueuedJob(job);
scheduleDrain();
};
signal.addEventListener("abort", job.abortPendingListener, { once: true });
}
requestQueue.push(job);
if (job.trackProgressCounters !== false) {
trackedPendingJobs += 1;
requestQueueStateMutable.batchTotal += 1;
syncQueueCounters();
}
scheduleDrain(); scheduleDrain();
}); });
}; };
@@ -646,10 +870,13 @@ export const clearMissingPermissions = () => {
}; };
export const __resetRequestQueueForTests = () => { export const __resetRequestQueueForTests = () => {
requestQueue.forEach(removePendingAbortListener);
requestQueue.length = 0; requestQueue.length = 0;
activeWorkers = 0; activeWorkers = 0;
trackedActiveWorkers = 0;
trackedPendingJobs = 0;
requestIdCounter = 0; requestIdCounter = 0;
Object.keys(activeWorkersByMethod).forEach((method) => delete activeWorkersByMethod[method]); Object.keys(activeWorkersByKey).forEach((key) => delete activeWorkersByKey[key]);
lastRequestStartedAt = 0; lastRequestStartedAt = 0;
clearDrainTimer(); clearDrainTimer();
@@ -661,6 +888,7 @@ export const __resetRequestQueueForTests = () => {
requestQueueStateMutable.batchFailed = 0; requestQueueStateMutable.batchFailed = 0;
requestQueueStateMutable.activeRequests = []; requestQueueStateMutable.activeRequests = [];
requestQueueStateMutable.recentRequests = []; requestQueueStateMutable.recentRequests = [];
requestQueueStateMutable.requestInsights = {};
requestQueueStateMutable.errorRequests = []; requestQueueStateMutable.errorRequests = [];
requestQueueStateMutable.missingPermissions = []; requestQueueStateMutable.missingPermissions = [];
requestQueueStateMutable.networkTotals = { requestQueueStateMutable.networkTotals = {
+194 -27
View File
@@ -48,13 +48,22 @@ const hiarchyAccessLevels = {
const attemptAutoSelectDepartment = ref(false); const attemptAutoSelectDepartment = ref(false);
const autoSelectDepartmentResolved = ref(false); const autoSelectDepartmentResolved = ref(false);
const autoSelectDepartmentStartedAt = ref(null);
const autoSelectGeolocationMaximumAge = 0;
const autoSelectGeolocationTimeout = 27000;
const manualDepartmentSelectionDelay = 5000;
const manualDepartmentSelectionAvailable = ref(false);
const manualDepartmentSelectionOpen = ref(false);
let departmentsLoadPromise = null; let departmentsLoadPromise = null;
let autoSelectFallbackTimeout = null; let autoSelectFallbackTimeout = null;
let manualDepartmentSelectionTimeout = null;
const getAccessibleDepartments = () => { const getAccessibleDepartments = () => {
return departments.value.filter(dept => SessionUser.canAccessAssignedDepartment(dept.id)); return departments.value.filter(dept => SessionUser.canAccessAssignedDepartment(dept.id));
} }
const accessibleDepartmentsForManualSelection = computed(() => getAccessibleDepartments());
const isMobileDepartmentAutoSelectRoute = (accessLevel) => { const isMobileDepartmentAutoSelectRoute = (accessLevel) => {
return isMobile.value && (accessLevel.route === 'superuser' || accessLevel.route === 'admin'); return isMobile.value && (accessLevel.route === 'superuser' || accessLevel.route === 'admin');
} }
@@ -66,9 +75,17 @@ const clearAutoSelectFallbackTimeout = () => {
} }
} }
const clearManualDepartmentSelectionTimeout = () => {
if (manualDepartmentSelectionTimeout !== null) {
clearTimeout(manualDepartmentSelectionTimeout);
manualDepartmentSelectionTimeout = null;
}
}
const markAutoSelectDepartmentResolved = () => { const markAutoSelectDepartmentResolved = () => {
autoSelectDepartmentResolved.value = true; autoSelectDepartmentResolved.value = true;
clearAutoSelectFallbackTimeout(); clearAutoSelectFallbackTimeout();
clearManualDepartmentSelectionTimeout();
} }
const redirectToDepartmentPos = (department) => { const redirectToDepartmentPos = (department) => {
@@ -81,18 +98,47 @@ const redirectToDepartmentPos = (department) => {
return true; return true;
} }
const parseLocationTimestamp = (value) => {
if (value === null || value === undefined) {
return null;
}
if (value instanceof Date) {
const timestamp = value.getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
if (typeof value === "string") {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
const timestamp = Number(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
const getLocationTimestamp = (location) => {
return parseLocationTimestamp(location?.locatedAt) ?? parseLocationTimestamp(location?.timestamp);
}
const isFreshLocationForAutoSelect = (location) => {
const startedAt = parseLocationTimestamp(autoSelectDepartmentStartedAt.value);
const locationTimestamp = getLocationTimestamp(location);
return startedAt !== null && locationTimestamp !== null && locationTimestamp >= startedAt;
}
const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDepartments()) => { const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDepartments()) => {
const location = locations.get(); const location = locations.get();
if (!location?.coords || accessibleDepartments.length === 0) { if (!location?.coords || accessibleDepartments.length === 0) {
return null; return null;
} }
const from = { if (!isFreshLocationForAutoSelect(location)) {
latitude: Number(location.coords.latitude), return null;
longitude: Number(location.coords.longitude) }
};
if (!Number.isFinite(from.latitude) || !Number.isFinite(from.longitude)) { const from = locations.normalizeCoordinatePair(location.coords);
if (!from) {
return null; return null;
} }
@@ -102,12 +148,12 @@ const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDe
} }
accessibleDepartments.forEach(department => { accessibleDepartments.forEach(department => {
const to = { const to = locations.normalizeCoordinatePair({
latitude: Number(department.latitude), latitude: department.latitude,
longitude: Number(department.longitude) longitude: department.longitude
}; }, { allowZeroPair: false });
if (!Number.isFinite(to.latitude) || !Number.isFinite(to.longitude)) { if (!to) {
return; return;
} }
@@ -124,7 +170,12 @@ const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDe
} }
const resolveMobileDepartmentAutoSelect = () => { const resolveMobileDepartmentAutoSelect = () => {
if (!attemptAutoSelectDepartment.value || autoSelectDepartmentResolved.value || !SessionUser.isInitiated()) { if (
!attemptAutoSelectDepartment.value ||
autoSelectDepartmentResolved.value ||
manualDepartmentSelectionOpen.value ||
!SessionUser.isInitiated()
) {
return false; return false;
} }
@@ -173,7 +224,32 @@ const scheduleAutoSelectFallback = () => {
autoSelectFallbackTimeout = setTimeout(() => { autoSelectFallbackTimeout = setTimeout(() => {
autoSelectFallbackTimeout = null; autoSelectFallbackTimeout = null;
fallbackMobileDepartmentAutoSelect(); fallbackMobileDepartmentAutoSelect();
}, locations.defaultTimeout.value); }, autoSelectGeolocationTimeout);
}
const scheduleManualDepartmentSelection = () => {
if (manualDepartmentSelectionTimeout !== null || manualDepartmentSelectionAvailable.value) {
return;
}
manualDepartmentSelectionTimeout = setTimeout(() => {
manualDepartmentSelectionTimeout = null;
if (!autoSelectDepartmentResolved.value) {
manualDepartmentSelectionAvailable.value = true;
}
}, manualDepartmentSelectionDelay);
}
const openManualDepartmentSelection = () => {
manualDepartmentSelectionAvailable.value = true;
manualDepartmentSelectionOpen.value = true;
clearManualDepartmentSelectionTimeout();
clearAutoSelectFallbackTimeout();
ensureDepartmentsLoadedForAutoSelect();
}
const selectManualDepartment = (department) => {
redirectToDepartmentPos(department);
} }
const startMobileDepartmentAutoSelect = () => { const startMobileDepartmentAutoSelect = () => {
@@ -181,8 +257,14 @@ const startMobileDepartmentAutoSelect = () => {
return; return;
} }
attemptAutoSelectDepartment.value = true; if (!attemptAutoSelectDepartment.value) {
autoSelectDepartmentStartedAt.value = Date.now();
locations.clear();
attemptAutoSelectDepartment.value = true;
}
ensureDepartmentsLoadedForAutoSelect(); ensureDepartmentsLoadedForAutoSelect();
scheduleManualDepartmentSelection();
scheduleAutoSelectFallback(); scheduleAutoSelectFallback();
resolveMobileDepartmentAutoSelect(); resolveMobileDepartmentAutoSelect();
} }
@@ -256,18 +338,6 @@ watch(() => SessionUser.isSubuser.value, (newValue) => {
} }
}); });
// Debug set the locations data
const debugSetLocationData = () => {
locations.set({
coords: {
latitude: 55.635587,
longitude: 12.254885
},
accuracy: 10,
timestamp: Date.now()
});
}
watch(() => locations.get(), () => { watch(() => locations.get(), () => {
resolveMobileDepartmentAutoSelect(); resolveMobileDepartmentAutoSelect();
}); });
@@ -278,6 +348,7 @@ watch(departments, () => {
onUnmounted(() => { onUnmounted(() => {
clearAutoSelectFallbackTimeout(); clearAutoSelectFallbackTimeout();
clearManualDepartmentSelectionTimeout();
}); });
</script> </script>
@@ -292,13 +363,109 @@ onUnmounted(() => {
<PageLoader title="Omdirigerer til login..."/> <PageLoader title="Omdirigerer til login..."/>
</div> </div>
<div v-else-if="SessionUser.isInitiated()"> <div v-else-if="SessionUser.isInitiated()">
<PageLoader :title="`Finder din nærmeste afdeling...`" @click="debugSetLocationData"/> <PageLoader :title="`Finder din nærmeste afdeling...`"/>
<PosDepartmentStepMobile1Location/> <PosDepartmentStepMobile1Location
:maximum-age="autoSelectGeolocationMaximumAge"
:timeout="autoSelectGeolocationTimeout"
/>
<div
v-if="manualDepartmentSelectionAvailable && !autoSelectDepartmentResolved"
class="redirect-manual-selector"
data-testid="redirect-manual-department-selector"
>
<button
v-if="!manualDepartmentSelectionOpen"
type="button"
class="button is-primary is-medium"
data-testid="redirect-manual-department-button"
@click="openManualDepartmentSelection"
>
<span class="icon">
<i class="fas fa-building"></i>
</span>
<span>{{ $t("redirect.mobile_department_auto_select.manual_button") }}</span>
</button>
<div
v-else
class="redirect-manual-selector__panel"
data-testid="redirect-manual-department-options"
>
<p class="redirect-manual-selector__title">
{{ $t("redirect.mobile_department_auto_select.manual_title") }}
</p>
<p
v-if="accessibleDepartmentsForManualSelection.length === 0"
class="redirect-manual-selector__status"
data-testid="redirect-manual-department-loading"
>
{{ $t("redirect.mobile_department_auto_select.manual_loading") }}
</p>
<div v-else class="redirect-manual-selector__options">
<button
v-for="department in accessibleDepartmentsForManualSelection"
:key="department.id"
type="button"
class="button is-light redirect-manual-selector__option"
:data-testid="`redirect-manual-department-option-${department.id}`"
@click="selectManualDepartment(department)"
>
{{ $t("redirect.mobile_department_auto_select.use_department", { name: department.name }) }}
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.redirect-manual-selector {
position: fixed;
right: 1rem;
bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 1rem;
z-index: 100001;
display: flex;
justify-content: center;
pointer-events: none;
}
.redirect-manual-selector > * {
pointer-events: auto;
}
.redirect-manual-selector__panel {
width: min(100%, 28rem);
padding: 1rem;
border-radius: 8px;
background: rgba(17, 24, 39, 0.95);
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.25);
color: #fff;
}
.redirect-manual-selector__title {
margin-bottom: 0.75rem;
font-size: 1rem;
font-weight: 700;
text-align: center;
}
.redirect-manual-selector__status {
margin: 0;
color: rgba(255, 255, 255, 0.82);
text-align: center;
}
.redirect-manual-selector__options {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
.redirect-manual-selector__option {
width: 100%;
min-height: 2.75rem;
white-space: normal;
}
</style> </style>
@@ -40,6 +40,7 @@ const currentRoute = useRoute();
const currentRouter = useRouter(); const currentRouter = useRouter();
const toast = useAppToast(); const toast = useAppToast();
const { fitView, getViewport, zoomIn, zoomOut, zoomTo } = useVueFlow({ id: "self-serve-studio-flow" }); const { fitView, getViewport, zoomIn, zoomOut, zoomTo } = useVueFlow({ id: "self-serve-studio-flow" });
const PATH_OUTCOMES_CASE_LIMIT = 2048;
const parseIntOrZero = (value) => { const parseIntOrZero = (value) => {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
@@ -899,6 +900,7 @@ const pathOutcomesRequestPayload = computed(() => {
config_source: simulatorForm.value.config_source || "draft", config_source: simulatorForm.value.config_source || "draft",
hardware_mode: hardwareMode, hardware_mode: hardwareMode,
include_hardware: hardwareMode !== "none", include_hardware: hardwareMode !== "none",
path_sample_limit: PATH_OUTCOMES_CASE_LIMIT,
}; };
}); });
const syncScopeFromRoute = () => { const syncScopeFromRoute = () => {
@@ -18,7 +18,7 @@ const {
} = useWashDepartments({ includeLanes: true }); } = useWashDepartments({ includeLanes: true });
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value)); const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords); const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value); const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true; const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
@@ -67,20 +67,28 @@ const selfServeUnavailableMessage = computed(() => {
}); });
const getDepartmentDistance = (department) => { const getDepartmentDistance = (department) => {
if (!locations.location.value?.coords) { const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
if (!currentCoords) {
return null; return null;
} }
return locations.getDistance( const departmentCoords = locations.normalizeCoordinatePair(
{
latitude: locations.location.value.coords.latitude,
longitude: locations.location.value.coords.longitude,
},
{ {
latitude: department.latitude, latitude: department.latitude,
longitude: department.longitude, longitude: department.longitude,
} },
{ allowZeroPair: false }
); );
if (!departmentCoords) {
return null;
}
const distance = locations.getDistance(
currentCoords,
departmentCoords
);
return Number.isFinite(distance) ? distance : null;
}; };
onMounted(async () => { onMounted(async () => {
@@ -716,7 +716,7 @@ const applyResolvedVehicleTypeSelection = () => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value); const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords); const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const canUseDepartmentHeaderSelection = computed( const canUseDepartmentHeaderSelection = computed(
() => () =>
@@ -1369,7 +1369,7 @@ const applyServerActiveWash = async (activeWash: any) => {
}; };
const restoreServerActiveWash = async () => { const restoreServerActiveWash = async () => {
if (isRestoringServerActiveWash.value) { if (isMyWashStartUnmounted.value || isRestoringServerActiveWash.value) {
return; return;
} }
@@ -1385,6 +1385,10 @@ const restoreServerActiveWash = async () => {
isRestoringServerActiveWash.value = true; isRestoringServerActiveWash.value = true;
try { try {
const activeWash = await fetchServerActiveWash(); const activeWash = await fetchServerActiveWash();
if (isMyWashStartUnmounted.value) {
return;
}
if (activeWash) { if (activeWash) {
shouldRestoreServerActiveWash.value = false; shouldRestoreServerActiveWash.value = false;
await applyServerActiveWash(activeWash); await applyServerActiveWash(activeWash);
+10 -4
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, Locator, test } from "@playwright/test";
import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js"; import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects"; import { isDesktopProject } from "./support/projects";
@@ -8,6 +8,11 @@ const json = (body: unknown, status = 200) => ({
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
async function triggerMenuHover(locator: Locator) {
await locator.dispatchEvent("mouseenter");
await locator.dispatchEvent("mouseover");
}
test.describe("Admin POS drafts", () => { test.describe("Admin POS drafts", () => {
test("assigns a draft order to a customer, invoice collection, and recalculated pricing", async ({ test("assigns a draft order to a customer, invoice collection, and recalculated pricing", async ({
page, page,
@@ -341,6 +346,7 @@ test.describe("Admin POS drafts", () => {
test("accepts a self-serve wash draft from the actions menu", async ({ page }, testInfo) => { test("accepts a self-serve wash draft from the actions menu", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only"); test.skip(!isDesktopProject(testInfo), "Desktop only");
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
await page.setViewportSize({ width: 1900, height: 900 }); await page.setViewportSize({ width: 1900, height: 900 });
await seedAuthenticatedState(page); await seedAuthenticatedState(page);
@@ -580,13 +586,13 @@ test.describe("Admin POS drafts", () => {
await expect(settingsRoot.getByRole("button", { name: /Godkend selvbetjent vask/ })).toBeVisible(); await expect(settingsRoot.getByRole("button", { name: /Godkend selvbetjent vask/ })).toBeVisible();
await settingsRoot.getByTestId("action-settings-wheel-section-attachments").hover(); await triggerMenuHover(settingsRoot.getByTestId("action-settings-wheel-section-attachments"));
await settingsRoot.getByTestId("action-settings-wheel-attachment-row-401").hover(); await triggerMenuHover(settingsRoot.getByTestId("action-settings-wheel-attachment-row-401"));
await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("Selvbetjent vask"); await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("Selvbetjent vask");
await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("#424242"); await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("#424242");
await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("Driver One"); await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("Driver One");
await settingsRoot.getByTestId("action-settings-wheel-section-order").hover(); await triggerMenuHover(settingsRoot.getByTestId("action-settings-wheel-section-order"));
await settingsRoot.getByRole("button", { name: /Godkend selvbetjent vask/ }).click(); await settingsRoot.getByRole("button", { name: /Godkend selvbetjent vask/ }).click();
await expect(page.locator(".swal2-popup")).toBeVisible(); await expect(page.locator(".swal2-popup")).toBeVisible();
await page.locator(".swal2-confirm").click(); await page.locator(".swal2-confirm").click();
+31 -12
View File
@@ -1,4 +1,4 @@
import { test, expect, Page } from "@playwright/test"; import { test, expect, Locator, Page } from "@playwright/test";
import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js"; import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
const POS_PERMISSIONS = [ const POS_PERMISSIONS = [
@@ -24,6 +24,19 @@ function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
async function triggerMenuHover(locator: Locator) {
await locator.dispatchEvent("mouseenter");
await locator.dispatchEvent("mouseover");
}
async function hoverMenuTarget(page: Page, locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
await page.mouse.move((box?.x ?? 0) + (box?.width ?? 0) / 2, (box?.y ?? 0) + (box?.height ?? 0) / 2);
await triggerMenuHover(locator);
}
function jsonResponse(body: unknown, status = 200) { function jsonResponse(body: unknown, status = 200) {
return { return {
status, status,
@@ -1591,7 +1604,9 @@ test.describe("Admin POS Orders - desktop settings", () => {
test("shows a paperclip attachment control, all attachment actions, and a hover preview when the order has attachments", async ({ test("shows a paperclip attachment control, all attachment actions, and a hover preview when the order has attachments", async ({
page, page,
}) => { }, testInfo) => {
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
await page.goto("/admin/12/modules/pos/orders"); await page.goto("/admin/12/modules/pos/orders");
const attachmentDropdown = getVisibleTestId(page, "pos-order-list-attachments-54518"); const attachmentDropdown = getVisibleTestId(page, "pos-order-list-attachments-54518");
@@ -1617,7 +1632,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(attachmentItems.nth(2)).toContainText(/safety-seal\.pdf/i); await expect(attachmentItems.nth(2)).toContainText(/safety-seal\.pdf/i);
const hoveredAttachmentItem = page.getByTestId("pos-order-list-attachment-item-54518-301"); const hoveredAttachmentItem = page.getByTestId("pos-order-list-attachment-item-54518-301");
await hoveredAttachmentItem.hover(); await triggerMenuHover(hoveredAttachmentItem);
const dropdownContent = attachmentDropdown.locator(".dropdown-content"); const dropdownContent = attachmentDropdown.locator(".dropdown-content");
const previewPanel = page.getByTestId("pos-order-list-attachment-preview-54518"); const previewPanel = page.getByTestId("pos-order-list-attachment-preview-54518");
@@ -2608,7 +2623,9 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
test("shows desktop categories with left-side flyout submenus and attachment actions on wide viewports", async ({ test("shows desktop categories with left-side flyout submenus and attachment actions on wide viewports", async ({
page, page,
}) => { }, testInfo) => {
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
await page.setViewportSize({ width: 1900, height: 900 }); await page.setViewportSize({ width: 1900, height: 900 });
await page.goto("/admin/12/modules/pos/orders"); await page.goto("/admin/12/modules/pos/orders");
await expect(page.locator("table")).toBeVisible(); await expect(page.locator("table")).toBeVisible();
@@ -2629,7 +2646,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
await expect(dropdownContent).toHaveCSS("z-index", "4002"); await expect(dropdownContent).toHaveCSS("z-index", "4002");
await expect(customerSection.locator(".fa-chevron-left")).toBeVisible(); await expect(customerSection.locator(".fa-chevron-left")).toBeVisible();
await customerSection.hover(); await triggerMenuHover(customerSection);
const customerSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-customer"); const customerSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-customer");
await expect(customerSubmenu).toBeVisible(); await expect(customerSubmenu).toBeVisible();
@@ -2647,7 +2664,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
(sectionsRailBox?.x ?? 0) - 4 (sectionsRailBox?.x ?? 0) - 4
); );
await rulesSection.hover(); await triggerMenuHover(rulesSection);
const rulesSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-rules"); const rulesSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-rules");
const invoiceAllOrdersIndividuallyToggle = dropdownContent.getByTestId( const invoiceAllOrdersIndividuallyToggle = dropdownContent.getByTestId(
@@ -2690,13 +2707,13 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
await expect(flyout).toBeVisible(); await expect(flyout).toBeVisible();
await shortcutsSection.hover(); await triggerMenuHover(shortcutsSection);
const shortcutsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-shortcuts"); const shortcutsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-shortcuts");
await expect(shortcutsSubmenu).toBeVisible(); await expect(shortcutsSubmenu).toBeVisible();
await expect(shortcutsSubmenu.locator("button.dropdown-item-action")).toHaveCount(5); await expect(shortcutsSubmenu.locator("button.dropdown-item-action")).toHaveCount(5);
await vehicleSection.hover(); await triggerMenuHover(vehicleSection);
const vehicleSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-vehicle"); const vehicleSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-vehicle");
await expect(vehicleSubmenu).toBeVisible(); await expect(vehicleSubmenu).toBeVisible();
@@ -2707,7 +2724,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
expect(flyoutBoxAfter).not.toBeNull(); expect(flyoutBoxAfter).not.toBeNull();
expect(Math.abs((flyoutBoxAfter?.height ?? 0) - (flyoutBoxBefore?.height ?? 0))).toBeLessThanOrEqual(1); expect(Math.abs((flyoutBoxAfter?.height ?? 0) - (flyoutBoxBefore?.height ?? 0))).toBeLessThanOrEqual(1);
await attachmentsSection.hover(); await triggerMenuHover(attachmentsSection);
const attachmentsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-attachments"); const attachmentsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-attachments");
const attachmentRows = attachmentsSubmenu.locator('[data-testid^="action-settings-wheel-attachment-row-"]'); const attachmentRows = attachmentsSubmenu.locator('[data-testid^="action-settings-wheel-attachment-row-"]');
@@ -2715,7 +2732,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
await expect(attachmentRows).toHaveCount(3); await expect(attachmentRows).toHaveCount(3);
const firstAttachmentRow = attachmentRows.first(); const firstAttachmentRow = attachmentRows.first();
await firstAttachmentRow.hover(); await triggerMenuHover(firstAttachmentRow);
const attachmentPanel = dropdownContent.getByTestId("action-settings-wheel-attachment-panel"); const attachmentPanel = dropdownContent.getByTestId("action-settings-wheel-attachment-panel");
const previewContainer = dropdownContent.getByTestId("action-settings-wheel-attachment-preview"); const previewContainer = dropdownContent.getByTestId("action-settings-wheel-attachment-preview");
@@ -2748,7 +2765,9 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
await expect(attachmentsSection).toBeVisible(); await expect(attachmentsSection).toBeVisible();
}); });
test("shows email notification actions and resends booking completion confirmation", async ({ page }) => { test("shows email notification actions and resends booking completion confirmation", async ({ page }, testInfo) => {
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
const baseFixture = createPosFixture(); const baseFixture = createPosFixture();
await mockApi(page, { await mockApi(page, {
authenticated: true, authenticated: true,
@@ -2796,7 +2815,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
const emailSection = dropdownContent.getByTestId("action-settings-wheel-section-email-notifications"); const emailSection = dropdownContent.getByTestId("action-settings-wheel-section-email-notifications");
await expect(emailSection).toBeVisible(); await expect(emailSection).toBeVisible();
await emailSection.hover(); await hoverMenuTarget(page, emailSection);
const emailSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-email-notifications"); const emailSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-email-notifications");
await expect(emailSubmenu).toBeVisible(); await expect(emailSubmenu).toBeVisible();
+36 -2
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js"; import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
const adminPermissions = ["admin", "department_access_1", "department_access_2"]; const adminPermissions = ["admin", "department_access_1", "department_access_2"];
@@ -20,6 +20,40 @@ test("mobile redirect waits for delayed departments before selecting nearest POS
latitude: 55.6761, latitude: 55.6761,
longitude: 12.5683, longitude: 12.5683,
}); });
await page.addInitScript(() => {
const location = {
coords: {
latitude: 55.6761,
longitude: 12.5683,
accuracy: 1,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
},
timestamp: Date.now(),
};
const notify = (success: PositionCallback) => {
window.setTimeout(() => {
success({
...location,
timestamp: Date.now(),
} as GeolocationPosition);
}, 50);
};
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: {
getCurrentPosition: notify,
watchPosition: (success: PositionCallback) => {
notify(success);
return 1;
},
clearWatch: () => {},
},
});
});
await seedAuthenticatedState(page, "default-mobile-redirect-token"); await seedAuthenticatedState(page, "default-mobile-redirect-token");
await mockApi(page, { await mockApi(page, {
@@ -28,7 +62,7 @@ test("mobile redirect waits for delayed departments before selecting nearest POS
}); });
let departmentsRequestCount = 0; let departmentsRequestCount = 0;
await page.route(/\/departments(?:\?.*)?$/i, async (route) => { await page.route(apiPathPattern("/departments"), async (route) => {
departmentsRequestCount += 1; departmentsRequestCount += 1;
if (departmentsRequestCount === 1) { if (departmentsRequestCount === 1) {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
+93
View File
@@ -3560,6 +3560,99 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page); await waitForStepReset(page);
}); });
test("prompts for a required product note before completing mobile order items", async ({ page }) => {
const orderId = 9415;
const baseFixture = createMobilePosFixture();
const product27 = {
id: 27,
name: "Ekstraordinær pr. 10 min inkl. kemi",
description: "Extraordinary service requiring an item note",
price: 125,
subscription_allowed: true,
category: 8,
piktogram: "27",
apply_category_discount: false,
requires_note: false,
is_wash: false,
display_in_booking_form: true,
order_priority: 5,
addons: [],
};
const primaryProduct = {
...fixtureProduct(53),
addons: [
...fixtureProduct(53).addons,
{
id: product27.id,
name: product27.name,
price: product27.price,
product: { ...product27 },
quantity: 1,
min: 0,
max: -1,
},
],
};
const fixture = createMobilePosFixture({
products: baseFixture.products
.map((product) => {
if (Number(product.id) !== 53) {
return product;
}
return primaryProduct;
})
.concat(product27),
ordersById: {
[orderId]: buildRegularOrder(orderId),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-product-27-note-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "PRODUCT-27-NOTE",
primaryItem: primaryProduct,
vehicleType: 53,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-addon-27-value")).toHaveText("1", { timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("pos-mobile-product-note-input").fill("Cancelled note");
await page.getByTestId("pos-mobile-product-note-cancel").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 1_000 }).toBe(0);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0);
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("pos-mobile-product-note-input").fill("Extra chemical treatment on left side");
await page.getByTestId("pos-mobile-product-note-confirm").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
const product27Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 27);
expect(product27Create?.notes).toBe("Extra chemical treatment on left side");
await waitForStepReset(page);
});
test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => { test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => {
const orderId = 9405; const orderId = 9405;
const fixture = createMobilePosFixture({ const fixture = createMobilePosFixture({
+111 -19
View File
@@ -15,6 +15,8 @@ const POS_STEP_TIMEOUT = 20_000;
const visualSnapshotOptions = (maxDiffPixels = 300, maxDiffPixelRatio = 0.02) => const visualSnapshotOptions = (maxDiffPixels = 300, maxDiffPixelRatio = 0.02) =>
process.platform === "win32" ? { maxDiffPixels } : { maxDiffPixelRatio, threshold: 0.35 }; process.platform === "win32" ? { maxDiffPixels } : { maxDiffPixelRatio, threshold: 0.35 };
const VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(); const VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions();
const LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(3500, 0.04);
const LINUX_CONTAINER_MOBILE_POS_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(3000, 0.04);
const RELAXED_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(1000, 0.03); const RELAXED_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(1000, 0.03);
const MOBILE_PAYMENT_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(300, 0.05); const MOBILE_PAYMENT_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(300, 0.05);
@@ -307,6 +309,19 @@ async function resetScrollableAncestor(locator) {
}); });
} }
async function stabilizeElementScreenshotHeight(locator, height) {
await locator.evaluate((element, targetHeight) => {
element.style.height = `${targetHeight}px`;
element.style.overflow = "hidden";
}, height);
}
async function stabilizeElementScreenshotMinHeight(locator, height) {
await locator.evaluate((element, targetHeight) => {
element.style.minHeight = `${targetHeight}px`;
}, height);
}
function getVisibleTestId(page, testId) { function getVisibleTestId(page, testId) {
return page.locator(`[data-testid="${testId}"]:visible`).first(); return page.locator(`[data-testid="${testId}"]:visible`).first();
} }
@@ -377,7 +392,15 @@ test.describe("POS visuals", () => {
await expect(page.getByTestId("pos-recent-scan-row-801")).toBeVisible(); await expect(page.getByTestId("pos-recent-scan-row-801")).toBeVisible();
await expect(stepOne.getByText(/^Booket$/)).toBeVisible(); await expect(stepOne.getByText(/^Booket$/)).toBeVisible();
await expect(stepOne).not.toContainText("Booket (fremt. opdat.)"); await expect(stepOne).not.toContainText("Booket (fremt. opdat.)");
await expect(stepOne).toHaveScreenshot("pos-step-1-desktop.png", VISUAL_SNAPSHOT_OPTIONS); const isWebKitDesktop = testInfo.project.name === "webkit-desktop";
const isFirefoxDesktop = testInfo.project.name === "firefox-desktop";
await expectClippedLocatorScreenshot(page, stepOne, "pos-step-1-desktop.png", {
width: 860,
height: isFirefoxDesktop ? 496 : isWebKitDesktop ? 492 : 490,
maxDiffPixels: 3500,
maxDiffPixelRatio: 0.04,
expandViewportForClip: true,
});
}); });
test("desktop step 1 expanded recent scan snapshot", async ({ page }, testInfo) => { test("desktop step 1 expanded recent scan snapshot", async ({ page }, testInfo) => {
@@ -415,10 +438,32 @@ test.describe("POS visuals", () => {
await expect(page.getByTestId("pos-recent-scan-details-801")).toContainText("Captur"); await expect(page.getByTestId("pos-recent-scan-details-801")).toContainText("Captur");
await expect(page.getByTestId("pos-recent-scan-details-801")).toContainText("Kundenummer"); await expect(page.getByTestId("pos-recent-scan-details-801")).toContainText("Kundenummer");
await expect(page.getByTestId("pos-step-1")).toHaveScreenshot( if (testInfo.project.name === "firefox-desktop") {
"pos-step-1-desktop-expanded-scan.png", const stepOne = page.getByTestId("pos-step-1");
RELAXED_VISUAL_SNAPSHOT_OPTIONS await stabilizeElementScreenshotHeight(stepOne, 652);
); await expect(stepOne).toHaveScreenshot(
"pos-step-1-desktop-expanded-scan.png",
visualSnapshotOptions(30000, 0.05)
);
} else if (testInfo.project.name === "webkit-desktop") {
await expectClippedLocatorScreenshot(
page,
page.getByTestId("pos-step-1"),
"pos-step-1-desktop-expanded-scan.png",
{
width: 852,
height: 646,
maxDiffPixels: 3000,
maxDiffPixelRatio: 0.05,
expandViewportForClip: true,
}
);
} else {
await expect(page.getByTestId("pos-step-1")).toHaveScreenshot(
"pos-step-1-desktop-expanded-scan.png",
RELAXED_VISUAL_SNAPSHOT_OPTIONS
);
}
}); });
test("desktop step 1 customer snapshot", async ({ page }, testInfo) => { test("desktop step 1 customer snapshot", async ({ page }, testInfo) => {
@@ -448,7 +493,8 @@ test.describe("POS visuals", () => {
await expectClippedLocatorScreenshot(page, stepOne, "pos-step-1-customer-desktop.png", { await expectClippedLocatorScreenshot(page, stepOne, "pos-step-1-customer-desktop.png", {
width: 860, width: 860,
height: 663, height: 663,
maxDiffPixels: 2000, maxDiffPixels: 12000,
maxDiffPixelRatio: 0.04,
resetScroll: normalizeFirefoxClip, resetScroll: normalizeFirefoxClip,
expandViewportForClip: normalizeFirefoxClip, expandViewportForClip: normalizeFirefoxClip,
}); });
@@ -585,7 +631,7 @@ test.describe("POS visuals", () => {
}); });
await expect(duplicateWarning).toHaveScreenshot( await expect(duplicateWarning).toHaveScreenshot(
"pos-step-1-desktop-duplicate-warning.png", "pos-step-1-desktop-duplicate-warning.png",
visualSnapshotOptions(2000, 0.03) LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
); );
}); });
@@ -662,7 +708,19 @@ test.describe("POS visuals", () => {
"(TEST) Pleno Vognmandsforretning" "(TEST) Pleno Vognmandsforretning"
); );
await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible(); await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible();
await expect(orderDetail).toHaveScreenshot("pos-order-detail-desktop.png", VISUAL_SNAPSHOT_OPTIONS); if (testInfo.project.name === "firefox-desktop") {
await stabilizeElementScreenshotMinHeight(orderDetail, 986);
} else if (testInfo.project.name === "webkit-desktop") {
await stabilizeElementScreenshotHeight(orderDetail, 973);
}
await expect(orderDetail).toHaveScreenshot(
"pos-order-detail-desktop.png",
testInfo.project.name === "firefox-desktop"
? visualSnapshotOptions(80000, 0.1)
: testInfo.project.name === "webkit-desktop"
? LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
: VISUAL_SNAPSHOT_OPTIONS
);
}); });
test("desktop order detail required warnings snapshot", async ({ page }, testInfo) => { test("desktop order detail required warnings snapshot", async ({ page }, testInfo) => {
@@ -691,10 +749,23 @@ test.describe("POS visuals", () => {
"(TEST) Pleno Vognmandsforretning" "(TEST) Pleno Vognmandsforretning"
); );
await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible(); await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible();
await expect(orderDetail).toHaveScreenshot( if (testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop") {
"pos-order-detail-desktop-required-warnings.png", await stabilizeElementScreenshotHeight(orderDetail, testInfo.project.name === "firefox-desktop" ? 902 : 893);
VISUAL_SNAPSHOT_OPTIONS await expect(orderDetail).toHaveScreenshot(
); "pos-order-detail-desktop-required-warnings.png",
testInfo.project.name === "firefox-desktop"
? visualSnapshotOptions(60000, 0.09)
: LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
);
} else {
await expectClippedLocatorScreenshot(page, orderDetail, "pos-order-detail-desktop-required-warnings.png", {
width: 860,
height: 888,
maxDiffPixels: 3500,
maxDiffPixelRatio: 0.04,
expandViewportForClip: true,
});
}
}); });
test("desktop step 2 shared workspace snapshot", async ({ page }, testInfo) => { test("desktop step 2 shared workspace snapshot", async ({ page }, testInfo) => {
@@ -726,7 +797,15 @@ test.describe("POS visuals", () => {
await expect(clearAllAction).toBeVisible(); await expect(clearAllAction).toBeVisible();
await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction); await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction);
await resetScrollableAncestor(stepTwo); await resetScrollableAncestor(stepTwo);
await expect(stepTwo).toHaveScreenshot("pos-step-2-desktop.png", VISUAL_SNAPSHOT_OPTIONS); if (testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop") {
await stabilizeElementScreenshotHeight(stepTwo, testInfo.project.name === "firefox-desktop" ? 832 : 819);
}
await expect(stepTwo).toHaveScreenshot(
"pos-step-2-desktop.png",
testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop"
? LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
: VISUAL_SNAPSHOT_OPTIONS
);
}); });
test("desktop step 3 shared workspace snapshot", async ({ page }, testInfo) => { test("desktop step 3 shared workspace snapshot", async ({ page }, testInfo) => {
@@ -813,7 +892,8 @@ test.describe("POS visuals", () => {
}; };
await expectClippedLocatorScreenshot(page, detailItemsTable, "pos-step-4-desktop.png", { await expectClippedLocatorScreenshot(page, detailItemsTable, "pos-step-4-desktop.png", {
...(clipByProject[testInfo.project.name] || { width: 423, height: 288 }), ...(clipByProject[testInfo.project.name] || { width: 423, height: 288 }),
maxDiffPixels: 2500, maxDiffPixels: 3500,
maxDiffPixelRatio: 0.04,
}); });
}); });
@@ -898,11 +978,15 @@ test.describe("POS visuals", () => {
await expect(getVisibleTestId(page, "pos-product-card-53")).toBeVisible(); await expect(getVisibleTestId(page, "pos-product-card-53")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-item-edit-9101")).toBeVisible(); await expect(getVisibleTestId(page, "pos-order-item-edit-9101")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-total")).toContainText("1372 DKK"); await expect(getVisibleTestId(page, "pos-order-total")).toContainText("1372 DKK");
const orderWorkspace = getVisibleTestId(page, "pos-order-workspace");
if (testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop") {
await stabilizeElementScreenshotHeight(orderWorkspace, testInfo.project.name === "firefox-desktop" ? 1052 : 1249);
}
await expect(getVisibleTestId(page, "pos-order-workspace")).toHaveScreenshot( await expect(getVisibleTestId(page, "pos-order-workspace")).toHaveScreenshot(
"pos-order-detail-add-items-desktop.png", "pos-order-detail-add-items-desktop.png",
{ testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop"
...VISUAL_SNAPSHOT_OPTIONS, ? LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
} : VISUAL_SNAPSHOT_OPTIONS
); );
}); });
@@ -947,9 +1031,17 @@ test.describe("POS visuals", () => {
.getByTestId("pos-mobile-customer-banner") .getByTestId("pos-mobile-customer-banner")
.evaluate((element) => element.getBoundingClientRect().height); .evaluate((element) => element.getBoundingClientRect().height);
expect(customerBannerHeight).toBeLessThan(84); expect(customerBannerHeight).toBeLessThan(84);
const isWebKitMobile = testInfo.project.name === "webkit-mobile";
await page.getByTestId("pos-mobile-step-2").evaluate(
(element, height) => {
element.style.height = `${height}px`;
element.style.overflow = "hidden";
},
isWebKitMobile ? 1292 : 1299
);
await expect(page.getByTestId("pos-mobile-step-2")).toHaveScreenshot( await expect(page.getByTestId("pos-mobile-step-2")).toHaveScreenshot(
"pos-mobile-step-2.png", "pos-mobile-step-2.png",
VISUAL_SNAPSHOT_OPTIONS LINUX_CONTAINER_MOBILE_POS_VISUAL_SNAPSHOT_OPTIONS
); );
}); });
@@ -1163,7 +1255,7 @@ test.describe("POS visuals", () => {
await waitForOrderDetailMetadata(page); await waitForOrderDetailMetadata(page);
await expect(getVisibleTestId(page, "pos-order-panel-cart")).toHaveScreenshot( await expect(getVisibleTestId(page, "pos-order-panel-cart")).toHaveScreenshot(
"pos-order-detail-add-items-mobile.png", "pos-order-detail-add-items-mobile.png",
VISUAL_SNAPSHOT_OPTIONS LINUX_CONTAINER_MOBILE_POS_VISUAL_SNAPSHOT_OPTIONS
); );
}); });
+8 -6
View File
@@ -2381,12 +2381,14 @@ test("superusers manage release settings, assignments, integrations, and sync op
await page.getByTestId("release-target-form").getByRole("button", { name: "Test access" }).click(); await page.getByTestId("release-target-form").getByRole("button", { name: "Test access" }).click();
await expect(page.getByTestId("release-target-form")).toContainText("accessible"); await expect(page.getByTestId("release-target-form")).toContainText("accessible");
await page.getByTestId("release-target-form").getByRole("button", { name: "Save target" }).click(); await page.getByTestId("release-target-form").getByRole("button", { name: "Save target" }).click();
expect(state.targetPayloads[state.targetPayloads.length - 1].deploy_context).toMatchObject({ await expect
endpoint_mode: "manual", .poll(() => state.targetPayloads.at(-1)?.deploy_context, { timeout: 10_000 })
manual_endpoint_host: "manual-api.truckwash.io", .toMatchObject({
manual_endpoint_port: "8443", endpoint_mode: "manual",
coolify_ports_exposes: "8080", manual_endpoint_host: "manual-api.truckwash.io",
}); manual_endpoint_port: "8443",
coolify_ports_exposes: "8080",
});
await expect(page.getByTestId("release-targets-table")).toContainText("truckwash/backend-php#canary"); await expect(page.getByTestId("release-targets-table")).toContainText("truckwash/backend-php#canary");
await expect( await expect(
page.getByTestId("release-targets-table").locator('[data-testid^="release-target-actions-"]').first() page.getByTestId("release-targets-table").locator('[data-testid^="release-target-actions-"]').first()
+5 -1
View File
@@ -2610,8 +2610,11 @@ test.describe("All-in-one self-serve studio", () => {
}); });
expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4); expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4);
const isWebKitProject = testInfo.project.name.startsWith("webkit-"); const isWebKitProject = testInfo.project.name.startsWith("webkit-");
const isFirefoxProject = testInfo.project.name.startsWith("firefox-");
const isMobileProject = testInfo.project.name.includes("mobile");
const isTabletProject = testInfo.project.name.includes("tablet");
const maxFocusedSpreadX = isWebKitProject ? 1120 : 1120; const maxFocusedSpreadX = isWebKitProject ? 1120 : 1120;
const maxFocusedSpreadY = isWebKitProject ? 560 : 460; const maxFocusedSpreadY = isWebKitProject || isFirefoxProject || isMobileProject || isTabletProject ? 560 : 460;
expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX); expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX);
expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY); expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY);
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" }); await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
@@ -2963,6 +2966,7 @@ test.describe("All-in-one self-serve studio", () => {
await expect await expect
.poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states")) .poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states"))
.toBe(false); .toBe(false);
await expect.poll(() => captured.pathOutcomes[0]?.path_sample_limit).toBe(2048);
await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0); await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0);
await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2"); await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2");
await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE"); await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE");
+1 -2
View File
@@ -562,7 +562,7 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
}); });
test("vehicle next button shows a loading indicator while self-serve data loads", async ({ page }) => { test("vehicle next button is disabled while self-serve data loads", async ({ page }) => {
await mockApi(page, { await mockApi(page, {
authenticated: true, authenticated: true,
permissions: ["user"], permissions: ["user"],
@@ -587,7 +587,6 @@ test.describe("Self-serve wash", () => {
await nextButton.click(); await nextButton.click();
await expect(nextButton).toHaveClass(/is-loading/);
await expect(nextButton).toBeDisabled(); await expect(nextButton).toBeDisabled();
await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 });
}); });
+33
View File
@@ -9,6 +9,8 @@ export const DEFAULT_BOOKING_ID = 8101;
export const REGULAR_CUSTOMER_ID = 12345; export const REGULAR_CUSTOMER_ID = 12345;
export const CARD_CUSTOMER_ID = 999; export const CARD_CUSTOMER_ID = 999;
export const WASH_CERTIFICATE_PRODUCT_ID = 41; export const WASH_CERTIFICATE_PRODUCT_ID = 41;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
export const MOBILE_PERMISSIONS = ["admin", "department_access_1"]; export const MOBILE_PERMISSIONS = ["admin", "department_access_1"];
export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100; export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100;
@@ -325,6 +327,23 @@ function isWashCertificateProduct(product) {
return /vaskecertifikat|wash certificate|safety seal/i.test(String(product?.name ?? product?.product?.name ?? "")); return /vaskecertifikat|wash certificate|safety seal/i.test(String(product?.name ?? product?.product?.name ?? ""));
} }
function isEnabledFlag(value) {
return value === true || value === 1 || value === "1" || value === "true";
}
function productRequiresOrderItemNote(product) {
if (!product) {
return false;
}
const productId = Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
}
function orderContainsWashCertificate(fixture, orderId) { function orderContainsWashCertificate(fixture, orderId) {
return (fixture.orderItemsByOrderId[orderId] || []).some((item) => isWashCertificateProduct(item?.product || item)); return (fixture.orderItemsByOrderId[orderId] || []).some((item) => isWashCertificateProduct(item?.product || item));
} }
@@ -1960,6 +1979,20 @@ export async function mockMobilePosApi(page, fixture) {
); );
return; return;
} }
if (productRequiresOrderItemNote(product) && String(body.notes ?? "").trim() === "") {
await route.fulfill(
json(
{
success: false,
data: {
message: "Notes is required for this product",
},
},
400
)
);
return;
}
const orderItemId = fixture.nextOrderItemId++; const orderItemId = fixture.nextOrderItemId++;
const item = buildOrderItem( const item = buildOrderItem(
product, product,
+450 -2
View File
@@ -2,8 +2,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const { axiosMock } = vi.hoisted(() => ({ const { axiosMock, fetchMock } = vi.hoisted(() => ({
axiosMock: vi.fn(), axiosMock: vi.fn(),
fetchMock: vi.fn(),
})); }));
vi.mock("axios", () => ({ vi.mock("axios", () => ({
@@ -40,9 +41,27 @@ const createDeferred = () => {
return { promise, resolve, reject }; return { promise, resolve, reject };
}; };
const createFetchResponse = ({ body = "", headers = {}, status = 200, statusText = "OK" } = {}) => {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [String(key).toLowerCase(), value])
);
return {
ok: status >= 200 && status < 300,
status,
statusText,
headers: {
get: vi.fn((name) => normalizedHeaders[String(name).toLowerCase()] ?? null),
},
text: vi.fn(() => Promise.resolve(body)),
};
};
describe("authenticatedRequest", () => { describe("authenticatedRequest", () => {
beforeEach(() => { beforeEach(() => {
axiosMock.mockReset(); axiosMock.mockReset();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
__resetRequestQueueForTests(); __resetRequestQueueForTests();
__resetReleaseTimelineForTests(); __resetReleaseTimelineForTests();
__configureRequestQueueForTests({ __configureRequestQueueForTests({
@@ -76,11 +95,193 @@ describe("authenticatedRequest", () => {
expect(result).toBe(response); expect(result).toBe(response);
expect(catchCallable).not.toHaveBeenCalled(); expect(catchCallable).not.toHaveBeenCalled();
expect(requestQueueState.batchCompleted).toBe(1); expect(requestQueueState.batchCompleted).toBe(0);
expect(requestQueueState.batchFailed).toBe(0); expect(requestQueueState.batchFailed).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner).toEqual(
expect.objectContaining({
url: expect.stringContaining("/modules/scanner/lpr"),
success: true,
statusCode: 200,
})
);
expect(requestQueueState.errorRequests).toHaveLength(0); expect(requestQueueState.errorRequests).toHaveLength(0);
}); });
it("passes scanner lpr FormData through without generic network accounting", async () => {
const response = {
status: 200,
data: {
success: true,
data: {
license_plate_number: "AB12345",
},
},
};
const payload = new FormData();
payload.append("image", new Blob(["frame"], { type: "image/jpeg" }), "license-plate.jpg");
axiosMock.mockResolvedValueOnce(response);
await authenticatedRequest("/modules/scanner/lpr", "POST", payload);
const axiosConfig = axiosMock.mock.calls[0]?.[0];
expect(axiosConfig).toEqual(
expect.objectContaining({
method: "POST",
data: payload,
__skipRequestQueue: true,
})
);
expect(axiosConfig.headers["Content-Type"]).toBeUndefined();
expect(requestQueueState.networkTotals.outgoingRequests).toBe(0);
expect(requestQueueState.networkTotals.outgoingBytes).toBe(0);
expect(requestQueueState.networkTotals.ingoingResponses).toBe(0);
expect(requestQueueState.networkTotals.ingoingBytes).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner?.url).toContain("/modules/scanner/lpr");
});
it("passes scanner lpr raw image content type through without generic network accounting", async () => {
const response = {
status: 200,
data: {
success: true,
data: {
license_plate_number: "AB12345",
},
},
};
const payload = new Blob(["frame"], { type: "image/jpeg" });
axiosMock.mockResolvedValueOnce(response);
await authenticatedRequest("/modules/scanner/lpr", "POST", payload, null, null, {
headers: {
"Content-Type": "image/jpeg",
},
});
const axiosConfig = axiosMock.mock.calls[0]?.[0];
expect(axiosConfig).toEqual(
expect.objectContaining({
method: "POST",
data: payload,
__skipRequestQueue: true,
})
);
expect(axiosConfig.headers).toEqual(
expect.objectContaining({
"Content-Type": "image/jpeg",
})
);
expect(requestQueueState.networkTotals.outgoingRequests).toBe(0);
expect(requestQueueState.networkTotals.ingoingResponses).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner?.url).toContain("/modules/scanner/lpr");
});
it("uses fetch transport for scanner raw image requests when requested", async () => {
const payload = new Blob(["frame"], { type: "image/jpeg" });
const signal = new AbortController().signal;
fetchMock.mockResolvedValueOnce(
createFetchResponse({
body: JSON.stringify({
success: true,
data: {
license_plate_number: "AB12345",
},
}),
headers: {
"content-type": "application/json",
"server-timing": "lpr_total;dur=17.000",
},
})
);
const result = await authenticatedRequest("/modules/scanner/lpr", "POST", payload, null, null, {
headers: {
"Content-Type": "image/jpeg",
},
signal,
transport: "fetch",
});
expect(result.data).toEqual({
success: true,
data: {
license_plate_number: "AB12345",
},
});
expect(axiosMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/modules/scanner/lpr"),
expect.objectContaining({
body: payload,
method: "POST",
signal,
headers: expect.objectContaining({
Authorization: "Bearer token",
"Content-Type": "image/jpeg",
}),
})
);
expect(requestQueueState.networkTotals.outgoingRequests).toBe(0);
expect(requestQueueState.recentRequests).toHaveLength(0);
expect(requestQueueState.requestInsights.scanner?.serverTiming).toBe("lpr_total;dur=17.000");
});
it("rejects failed fetch transport responses with an axios-like response", async () => {
fetchMock.mockResolvedValueOnce(
createFetchResponse({
body: JSON.stringify({
success: false,
data: {
message: "Plate Recognizer unavailable.",
},
}),
headers: {
"content-type": "application/json",
"server-timing": "lpr_total;dur=44.000",
},
status: 503,
statusText: "Service Unavailable",
})
);
const catchCallable = vi.fn();
await expect(
authenticatedRequest("/modules/scanner/lpr", "POST", new Blob(["frame"]), catchCallable, null, {
headers: {
"Content-Type": "image/jpeg",
},
transport: "fetch",
})
).rejects.toMatchObject({
name: "AxiosError",
response: {
status: 503,
statusText: "Service Unavailable",
data: {
success: false,
data: {
message: "Plate Recognizer unavailable.",
},
},
},
});
expect(catchCallable).toHaveBeenCalledWith(
expect.objectContaining({
name: "AxiosError",
response: expect.objectContaining({
status: 503,
}),
})
);
expect(requestQueueState.errorRequests[0].serverTiming).toBe("lpr_total;dur=44.000");
});
it("continues to reject scanner errors", async () => { it("continues to reject scanner errors", async () => {
const error = { const error = {
response: { response: {
@@ -238,4 +439,251 @@ describe("authenticatedRequest", () => {
expect(requestQueueState.batchCompleted).toBe(3); expect(requestQueueState.batchCompleted).toBe(3);
expect(requestQueueState.batchFailed).toBe(0); expect(requestQueueState.batchFailed).toBe(0);
}); });
it("keeps self-serve hardware commands serial without blocking ordinary POST requests", async () => {
const hardwareOne = createDeferred();
const hardwareTwo = createDeferred();
const ordinaryPost = createDeferred();
axiosMock.mockImplementation(({ url }) => {
if (url.includes("/modules/self-serve/lane/command")) {
return hardwareOne.promise;
}
if (url.includes("/modules/self-serve/lane/relay/machine/enable")) {
return hardwareTwo.promise;
}
if (url.endsWith("/orders")) {
return ordinaryPost.promise;
}
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
});
const request1 = authenticatedRequest("/modules/self-serve/lane/command", "post", {
lane_id: 7,
command: "STOP",
});
const request2 = authenticatedRequest("/modules/self-serve/lane/relay/machine/enable", "post", {
lane_id: 7,
});
const request3 = authenticatedRequest("/orders", "post", {
reference: "ordinary-post",
});
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
expect.stringContaining("/modules/self-serve/lane/command"),
expect.stringMatching(/\/orders$/),
]);
expect(requestQueueState.active).toBe(2);
expect(requestQueueState.pending).toBe(1);
ordinaryPost.resolve({ status: 200, data: { id: 1 } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
hardwareOne.resolve({ status: 200, data: { ok: true } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(3);
expect(axiosMock.mock.calls[2][0].url).toContain("/modules/self-serve/lane/relay/machine/enable");
hardwareTwo.resolve({ status: 200, data: { ok: true } });
await expect(Promise.all([request1, request2, request3])).resolves.toHaveLength(3);
});
it("serializes POS scanner requests without blocking Stripe invoices or ordinary POS order mutations", async () => {
const scannerOne = createDeferred();
const scannerTwo = createDeferred();
const stripeInvoice = createDeferred();
const orderCreate = createDeferred();
const scannerResponses = [scannerOne, scannerTwo];
axiosMock.mockImplementation(({ url }) => {
if (url.includes("/modules/scanner/lpr")) {
return scannerResponses.shift()?.promise;
}
if (url.includes("/modules/stripe/invoice")) {
return stripeInvoice.promise;
}
if (url.endsWith("/orders")) {
return orderCreate.promise;
}
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
});
const request1 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" });
const request2 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-two" });
const request3 = authenticatedRequest("/modules/stripe/invoice", "post", { order_id: 42, email: "a@example.test" });
const request4 = authenticatedRequest("/orders", "post", { department_id: 3, reference: "pos-order" });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(3);
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
expect.stringContaining("/modules/scanner/lpr"),
expect.stringContaining("/modules/stripe/invoice"),
expect.stringMatching(/\/orders$/),
]);
expect(requestQueueState.active).toBe(2);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.activeRequests.map((request) => request.queueGroup)).toEqual(["POS_STRIPE", null]);
expect(requestQueueState.activeRequests.some((request) => request.queueGroup === "POS_SCANNER")).toBe(false);
scannerOne.resolve({ status: 200, data: { success: true } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(4);
expect(axiosMock.mock.calls[3][0].url).toContain("/modules/scanner/lpr");
scannerTwo.resolve({ status: 200, data: { success: true } });
stripeInvoice.resolve({ status: 200, data: { id: "in_1" } });
orderCreate.resolve({ status: 200, data: { id: 42 } });
await expect(Promise.all([request1, request2, request3, request4])).resolves.toHaveLength(4);
});
it("drops queued scanner LPR requests when their signal aborts before Axios starts", async () => {
const scannerOne = createDeferred();
const scannerTwo = createDeferred();
axiosMock.mockImplementation(({ data }) => {
if (data?.base64_image === "image-one") {
return scannerOne.promise;
}
if (data?.base64_image === "image-two") {
return scannerTwo.promise;
}
return Promise.reject(new Error("stale scanner frame should not start"));
});
const request1 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" });
const request2 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-two" });
const controller = new AbortController();
const staleRequest = authenticatedRequest(
"/modules/scanner/lpr",
"post",
{ base64_image: "stale-image" },
null,
null,
{ signal: controller.signal }
);
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
controller.abort();
await expect(staleRequest).rejects.toMatchObject({ name: "AbortError" });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
expect(requestQueueState.batchFailed).toBe(0);
expect(requestQueueState.errorRequests).toHaveLength(0);
scannerOne.resolve({ status: 200, data: { success: true } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.pending).toBe(0);
scannerTwo.resolve({ status: 200, data: { success: true } });
await expect(Promise.all([request1, request2])).resolves.toHaveLength(2);
await flushManyMicrotasks();
expect(requestQueueState.batchCompleted).toBe(0);
expect(requestQueueState.batchFailed).toBe(0);
});
it("does not record active scanner LPR aborts as failed API requests", async () => {
axiosMock.mockImplementation(
({ signal }) =>
new Promise((resolve, reject) => {
signal.addEventListener("abort", () => {
const error = new Error("canceled");
error.code = "ERR_CANCELED";
reject(error);
});
})
);
const controller = new AbortController();
const request = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" }, null, null, {
signal: controller.signal,
});
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
controller.abort();
await expect(request).rejects.toMatchObject({ code: "ERR_CANCELED" });
await flushManyMicrotasks();
expect(requestQueueState.active).toBe(0);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.batchTotal).toBe(0);
expect(requestQueueState.batchFailed).toBe(0);
expect(requestQueueState.errorRequests).toHaveLength(0);
});
it("does not retry POS Stripe invoice mutations", async () => {
__configureRequestQueueForTests({
retryByStatusCode: { 500: 1 },
retryDelayBaseMs: 0,
retryDelayMaxMs: 0,
retryDelayJitterMs: 0,
});
axiosMock.mockRejectedValue({
response: {
status: 500,
data: { message: "temporary Stripe failure" },
},
message: "Request failed",
});
await expect(
authenticatedRequest("/modules/stripe/invoice", "post", {
order_id: 42,
email: "a@example.test",
})
).rejects.toMatchObject({ response: { status: 500 } });
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.batchFailed).toBe(1);
});
it("does not retry non-idempotent self-serve hardware mutations", async () => {
__configureRequestQueueForTests({
retryByStatusCode: { 500: 1 },
retryDelayBaseMs: 0,
retryDelayMaxMs: 0,
retryDelayJitterMs: 0,
});
axiosMock.mockRejectedValue({
response: {
status: 500,
data: { message: "temporary backend failure" },
},
message: "Request failed",
});
await expect(
authenticatedRequest("/modules/self-serve/lane/command", "post", {
lane_id: 7,
command: "STOP",
})
).rejects.toMatchObject({ response: { status: 500 } });
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.batchFailed).toBe(1);
});
}); });
+38
View File
@@ -228,6 +228,44 @@ describe("axios request queue interceptor", () => {
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5, 6]); expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5, 6]);
}); });
it("allows multiple ordinary POST requests by default to use available PHP workers", async () => {
__resetAxiosRequestQueueInstallerForTests();
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
installAxiosRequestQueue();
const allDeferred = Array.from({ length: 5 }, () => createDeferred());
const pendingAdapters = [...allDeferred];
const adapter = () => pendingAdapters.shift()?.promise;
const requests = Array.from({ length: 5 }, (_, index) =>
axios({
url: `/queue-post-${index + 1}`,
method: "POST",
adapter,
})
);
await flushMicrotasks();
expect(requestQueueState.active).toBe(4);
expect(requestQueueState.pending).toBe(1);
for (let index = 0; index < 4; index += 1) {
allDeferred[index].resolve(createResponse({ id: index + 1 }));
}
await flushManyMicrotasks();
expect(requestQueueState.active).toBe(1);
expect(requestQueueState.pending).toBe(0);
allDeferred[4].resolve(createResponse({ id: 5 }));
const responses = await Promise.all(requests);
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5]);
});
it("retries configured response status codes", async () => { it("retries configured response status codes", async () => {
__configureRequestQueueForTests({ __configureRequestQueueForTests({
maxConcurrentGet: 1, maxConcurrentGet: 1,
+210 -17
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { defineComponent, h, nextTick } from "vue"; import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-router", () => { vi.mock("vue-router", () => {
const push = vi.fn(); const push = vi.fn();
@@ -32,8 +32,28 @@ vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmen
return { return {
default: defineComponent({ default: defineComponent({
name: "MockPosDepartmentStepMobile1Location", name: "MockPosDepartmentStepMobile1Location",
setup() { props: {
return () => h("div", { "data-testid": "location-probe" }); enableHighAccuracy: {
type: Boolean,
default: true,
},
maximumAge: {
type: Number,
default: 30000,
},
timeout: {
type: Number,
default: 27000,
},
},
setup(props) {
return () =>
h("div", {
"data-testid": "location-probe",
"data-enable-high-accuracy": String(props.enableHighAccuracy),
"data-maximum-age": String(props.maximumAge),
"data-timeout": String(props.timeout),
});
}, },
}), }),
}; };
@@ -67,6 +87,20 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
const locations = { const locations = {
location, location,
defaultTimeout, defaultTimeout,
normalizeCoordinatePair: (coordinates, { allowZeroPair = true } = {}) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
},
set: (newLocation) => { set: (newLocation) => {
location.value = newLocation; location.value = newLocation;
}, },
@@ -129,7 +163,18 @@ import {
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"; } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { __sessionState } from "@/components/session/token/SessionUser.vue"; import { __sessionState } from "@/components/session/token/SessionUser.vue";
const mountDefaultPage = () => mount(DefaultPage); let wrapper = null;
const mountDefaultPage = () => {
wrapper = mount(DefaultPage, {
global: {
mocks: {
$t: (key, params = {}) => (params.name ? `${key}:${params.name}` : key),
},
},
});
return wrapper;
};
const setAdminSession = (permissions = ["admin"]) => { const setAdminSession = (permissions = ["admin"]) => {
__sessionState.initiated.value = true; __sessionState.initiated.value = true;
@@ -138,9 +183,21 @@ const setAdminSession = (permissions = ["admin"]) => {
__sessionState.token.value = "unit-token"; __sessionState.token.value = "unit-token";
}; };
const setLocation = ({ latitude, longitude, timestamp = Date.now() }) => {
__locationRef.value = {
coords: {
latitude,
longitude,
},
timestamp: new Date(timestamp),
locatedAt: timestamp,
};
};
describe("DefaultPage mobile department redirect", () => { describe("DefaultPage mobile department redirect", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-11T10:00:00.000Z"));
__routerPush.mockReset(); __routerPush.mockReset();
__getDepartmentsMock.mockReset(); __getDepartmentsMock.mockReset();
__getDepartmentsMock.mockResolvedValue(undefined); __getDepartmentsMock.mockResolvedValue(undefined);
@@ -151,16 +208,21 @@ describe("DefaultPage mobile department redirect", () => {
setAdminSession(["admin", "department_access_1", "department_access_2"]); setAdminSession(["admin", "department_access_1", "department_access_2"]);
}); });
afterEach(() => {
wrapper?.unmount();
wrapper = null;
vi.clearAllTimers();
vi.useRealTimers();
});
it("routes when geolocation arrives before departments finish loading", async () => { it("routes when geolocation arrives before departments finish loading", async () => {
mountDefaultPage(); mountDefaultPage();
await nextTick(); await nextTick();
__locationRef.value = { setLocation({
coords: { latitude: 55.5,
latitude: 55.5, longitude: 12.5,
longitude: 12.5, });
},
};
await nextTick(); await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } }); expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } });
@@ -175,24 +237,155 @@ describe("DefaultPage mobile department redirect", () => {
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } }); expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } });
}); });
it("requests a fresh geolocation fix for mobile department auto-selection", async () => {
const mounted = mountDefaultPage();
await nextTick();
const probe = mounted.find('[data-testid="location-probe"]');
expect(probe.attributes("data-enable-high-accuracy")).toBe("true");
expect(probe.attributes("data-maximum-age")).toBe("0");
expect(probe.attributes("data-timeout")).toBe("27000");
});
it("shows manual department selection after five seconds", async () => {
const mounted = mountDefaultPage();
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(false);
await vi.advanceTimersByTimeAsync(4999);
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(true);
});
it("lets the user manually select a department after GPS wait", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
const mounted = mountDefaultPage();
await nextTick();
await vi.advanceTimersByTimeAsync(5000);
await nextTick();
await mounted.get('[data-testid="redirect-manual-department-button"]').trigger("click");
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-options"]').exists()).toBe(true);
setLocation({
latitude: 55.458,
longitude: 12.182,
});
await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
await vi.advanceTimersByTimeAsync(27000);
await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "admin" });
await mounted.get('[data-testid="redirect-manual-department-option-6"]').trigger("click");
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
});
it("ignores stale cached location and waits for a fresh Roskilde location", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
setLocation({
latitude: 55.458,
longitude: 12.182,
timestamp: Date.now() - 30000,
});
mountDefaultPage();
await nextTick();
expect(__locationRef.value).toBeNull();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
await vi.advanceTimersByTimeAsync(100);
setLocation({
latitude: 55.642,
longitude: 12.08,
});
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
});
it("does not redirect from a location timestamp older than the auto-select attempt", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
const staleTimestamp = Date.now() - 1000;
mountDefaultPage();
await nextTick();
setLocation({
latitude: 55.458,
longitude: 12.182,
timestamp: staleTimestamp,
});
await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
await vi.advanceTimersByTimeAsync(27000);
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "admin" });
});
it("routes immediately when only one accessible department exists", async () => {
setAdminSession(["admin", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
mountDefaultPage();
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
});
it("falls back after timeout even when location exists but nearest cannot be resolved", async () => { it("falls back after timeout even when location exists but nearest cannot be resolved", async () => {
__departmentsRef.value = [ __departmentsRef.value = [
{ id: 1, name: "Missing coordinates" }, { id: 1, name: "Missing coordinates" },
{ id: 2, name: "Also missing coordinates" }, { id: 2, name: "Also missing coordinates" },
]; ];
__locationRef.value = {
coords: {
latitude: 55.5,
longitude: 12.5,
},
};
mountDefaultPage(); mountDefaultPage();
await nextTick(); await nextTick();
setLocation({
latitude: 55.5,
longitude: 12.5,
});
await nextTick();
expect(__routerPush).not.toHaveBeenCalled(); expect(__routerPush).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500); await vi.advanceTimersByTimeAsync(27000);
await nextTick(); await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "admin" }); expect(__routerPush).toHaveBeenCalledWith({ name: "admin" });
+8 -3
View File
@@ -19,8 +19,8 @@ const pressShift = () => {
}; };
describe("FrontendMaintenanceMenu", () => { describe("FrontendMaintenanceMenu", () => {
it("opens after three Shift presses and runs the force update cleanup after confirmation", async () => { it("opens after three Shift presses and runs the force update cleanup after in-app confirmation", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true); const confirmSpy = vi.spyOn(window, "confirm");
mount(FrontendMaintenanceMenu, { mount(FrontendMaintenanceMenu, {
global: { global: {
plugins: [i18n], plugins: [i18n],
@@ -39,7 +39,12 @@ describe("FrontendMaintenanceMenu", () => {
document.body.querySelector("[data-testid='frontend-maintenance-force-clear']").click(); document.body.querySelector("[data-testid='frontend-maintenance-force-clear']").click();
await flushMicrotasks(); await flushMicrotasks();
expect(window.confirm).toHaveBeenCalledTimes(1); expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
document.body.querySelector("[data-testid='local-data-reset-confirm']").click();
await flushMicrotasks();
expect(confirmSpy).not.toHaveBeenCalled();
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1); expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
}); });
}); });
+47 -5
View File
@@ -6,8 +6,9 @@ import {
invalidateFrontendCachesAfterError, invalidateFrontendCachesAfterError,
} from "@/services/frontendMaintenance.js"; } from "@/services/frontendMaintenance.js";
const installBrowserMaintenanceMocks = () => { const installBrowserMaintenanceMocks = ({ indexedDatabaseNames = [] } = {}) => {
const deletedCaches = []; const deletedCaches = [];
const deletedIndexedDatabases = [];
const update = vi.fn(async () => true); const update = vi.fn(async () => true);
const unregister = vi.fn(async () => true); const unregister = vi.fn(async () => true);
const getRegistrations = vi.fn(async () => [{ update, unregister }]); const getRegistrations = vi.fn(async () => [{ update, unregister }]);
@@ -18,6 +19,15 @@ const installBrowserMaintenanceMocks = () => {
return true; return true;
}), }),
}; };
const indexedDB = {
databases: vi.fn(async () => indexedDatabaseNames.map((name) => ({ name }))),
deleteDatabase: vi.fn((databaseName) => {
deletedIndexedDatabases.push(databaseName);
const request = {};
queueMicrotask(() => request.onsuccess?.());
return request;
}),
};
Object.defineProperty(window, "caches", { Object.defineProperty(window, "caches", {
configurable: true, configurable: true,
@@ -27,8 +37,12 @@ const installBrowserMaintenanceMocks = () => {
configurable: true, configurable: true,
value: { getRegistrations }, value: { getRegistrations },
}); });
Object.defineProperty(window, "indexedDB", {
configurable: true,
value: indexedDB,
});
return { caches, deletedCaches, getRegistrations, unregister, update }; return { caches, deletedCaches, deletedIndexedDatabases, getRegistrations, indexedDB, unregister, update };
}; };
describe("frontend maintenance", () => { describe("frontend maintenance", () => {
@@ -39,6 +53,9 @@ describe("frontend maintenance", () => {
afterEach(() => { afterEach(() => {
__resetFrontendMaintenanceForTests(); __resetFrontendMaintenanceForTests();
vi.restoreAllMocks(); vi.restoreAllMocks();
Reflect.deleteProperty(window, "caches");
Reflect.deleteProperty(window, "indexedDB");
Reflect.deleteProperty(navigator, "serviceWorker");
}); });
it("invalidates browser caches and asks service workers to update after frontend errors", async () => { it("invalidates browser caches and asks service workers to update after frontend errors", async () => {
@@ -66,8 +83,10 @@ describe("frontend maintenance", () => {
expect(mocks.update).toHaveBeenCalledTimes(1); expect(mocks.update).toHaveBeenCalledTimes(1);
}); });
it("clears local state except the session token, unregisters service workers, clears caches, and reloads", async () => { it("clears all local state, unregisters service workers, clears caches, deletes IndexedDB, and reloads", async () => {
const mocks = installBrowserMaintenanceMocks(); const mocks = installBrowserMaintenanceMocks({
indexedDatabaseNames: ["workbox-expiration", "truckwash-offline"],
});
const reload = vi.fn(); const reload = vi.fn();
localStorage.setItem("token", "secret-token"); localStorage.setItem("token", "secret-token");
localStorage.setItem("draft", "local-value"); localStorage.setItem("draft", "local-value");
@@ -76,10 +95,33 @@ describe("frontend maintenance", () => {
const result = await forceFrontendUpdateAndClearLocal({ reload }); const result = await forceFrontendUpdateAndClearLocal({ reload });
expect(result.cacheNames).toEqual(["pleno-api-cache", "pleno-website-cache"]); expect(result.cacheNames).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(result.indexedDatabaseNames).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(mocks.unregister).toHaveBeenCalledTimes(1); expect(mocks.unregister).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBe("secret-token"); expect(mocks.deletedCaches).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(mocks.deletedIndexedDatabases).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(localStorage.getItem("token")).toBeNull();
expect(localStorage.getItem("draft")).toBeNull(); expect(localStorage.getItem("draft")).toBeNull();
expect(sessionStorage.getItem("draft")).toBeNull(); expect(sessionStorage.getItem("draft")).toBeNull();
expect(reload).toHaveBeenCalledTimes(1); expect(reload).toHaveBeenCalledTimes(1);
}); });
it("falls back to known IndexedDB names when database enumeration is unavailable", async () => {
const mocks = installBrowserMaintenanceMocks();
delete mocks.indexedDB.databases;
const result = await forceFrontendUpdateAndClearLocal({ reload: vi.fn() });
expect(result.indexedDatabaseNames).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
expect(mocks.deletedIndexedDatabases).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
});
}); });
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
enableAutoUnmount(afterEach);
const flush = async () => {
await Promise.resolve();
await nextTick();
};
const clickBodyButton = async (testId) => {
await flush();
document.body.querySelector(`[data-testid='${testId}']`).click();
await flush();
};
describe("LocalDataResetDialog", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("emits dismiss and model update when Nej is pressed", async () => {
const wrapper = mount(LocalDataResetDialog, {
attachTo: document.body,
props: {
modelValue: true,
},
});
await clickBodyButton("local-data-reset-cancel");
expect(wrapper.emitted("dismiss")).toHaveLength(1);
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
});
it("emits confirm when Ja, ryd alt is pressed", async () => {
const wrapper = mount(LocalDataResetDialog, {
attachTo: document.body,
props: {
modelValue: true,
},
});
await clickBodyButton("local-data-reset-confirm");
expect(wrapper.emitted("confirm")).toHaveLength(1);
});
});
+891
View File
@@ -0,0 +1,891 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
buildLPRFrameFingerprint,
buildLPRFrameFingerprintKey,
captureVideoFrameBlobForLPR,
getCenteredFocusSourceRect,
getConstrainedFrameSize,
getLPRFrameSourceRect,
getLPRFrameTargetSize,
getVisualFingerprintDistance,
getVisibleCoverSourceRect,
isVideoFrameReadyForLPR,
LPR_CAMERA_VIDEO_HEIGHT,
LPR_CAMERA_VIDEO_WIDTH,
LPR_FRAME_FILE_NAME,
LPR_FRAME_FOCUS_ASPECT_RATIO,
LPR_FRAME_JPEG_QUALITY,
LPR_FRAME_MIME_TYPE,
LPR_FRAME_SCANNER_FOCUS_SCALE,
LPR_FRAME_SCANNER_MAX_SIZE,
LPR_FRAME_SCANNER_JPEG_QUALITY,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
describe("LPR frame capture", () => {
it("constrains 16:9 camera frames before encoding", () => {
expect(getConstrainedFrameSize(1920, 1080)).toEqual({
width: 1024,
height: 576,
});
});
it("uses the full camera frame when rendered video dimensions are unavailable", () => {
expect(getVisibleCoverSourceRect(1280, 720, 0, 0)).toEqual({
height: 720,
width: 1280,
x: 0,
y: 0,
});
});
it("checks video readiness before camera LPR capture starts", () => {
expect(
isVideoFrameReadyForLPR({
readyState: 1,
videoHeight: 720,
videoWidth: 1280,
})
).toBe(false);
expect(
isVideoFrameReadyForLPR({
readyState: 2,
videoHeight: 0,
videoWidth: 1280,
})
).toBe(false);
expect(
isVideoFrameReadyForLPR({
readyState: 2,
videoHeight: 720,
videoWidth: 1280,
})
).toBe(true);
});
it("crops a landscape camera frame to the visible portrait preview before encoding", () => {
expect(getVisibleCoverSourceRect(1280, 720, 390, 844)).toEqual({
height: 720,
width: 333,
x: 474,
y: 0,
});
expect(getConstrainedFrameSize(333, 720)).toEqual({
width: 266,
height: 576,
});
});
it("crops the visible preview to the centered scanner focus area before encoding", () => {
expect(
getCenteredFocusSourceRect({
height: 1080,
width: 1920,
x: 0,
y: 0,
})
).toEqual({
height: 1080,
width: 1920,
x: 0,
y: 0,
});
expect(getLPRFrameSourceRect(1280, 720, 390, 844)).toEqual({
height: 159,
width: 283,
x: 499,
y: 281,
});
expect(getLPRFrameSourceRect(1280, 720, 390, 844, { focusCrop: false })).toEqual({
height: 720,
width: 333,
x: 474,
y: 0,
});
});
it("keeps the live stream cap aligned with attachment preview max while reducing scanner crop pixels", () => {
expect(LPR_CAMERA_VIDEO_WIDTH).toBe(1024);
expect(LPR_CAMERA_VIDEO_HEIGHT).toBe(576);
expect(getLPRFrameSourceRect(LPR_CAMERA_VIDEO_WIDTH, LPR_CAMERA_VIDEO_HEIGHT, 390, 844)).toEqual({
height: 128,
width: 226,
x: 399,
y: 224,
});
expect(
getLPRFrameTargetSize(
getLPRFrameSourceRect(LPR_CAMERA_VIDEO_WIDTH, LPR_CAMERA_VIDEO_HEIGHT, 390, 844, { focusCrop: false }),
{ focusCrop: false }
)
).toEqual({
height: 576,
width: 266,
});
});
it("can tighten the centered scanner crop while keeping a margin around the outline", () => {
expect(LPR_FRAME_FOCUS_ASPECT_RATIO).toBe(16 / 9);
expect(LPR_FRAME_SCANNER_FOCUS_SCALE).toBe(0.85);
expect(
getCenteredFocusSourceRect(
{
height: 720,
width: 333,
x: 474,
y: 0,
},
LPR_FRAME_FOCUS_ASPECT_RATIO,
LPR_FRAME_SCANNER_FOCUS_SCALE
)
).toEqual({
height: 159,
width: 283,
x: 499,
y: 281,
});
expect(getLPRFrameSourceRect(1280, 720, 390, 844, { focusScale: 1 })).toEqual({
height: 187,
width: 333,
x: 474,
y: 267,
});
});
it("anchors the scanner crop to a viewport focus rect when available", () => {
expect(
getLPRFrameSourceRect(1280, 720, 390, 844, {
focusViewportRect: {
height: 234,
width: 234,
x: 78,
y: 205,
},
})
).toEqual({
height: 159,
width: 283,
x: 499,
y: 195,
});
expect(
getLPRFrameSourceRect(1280, 720, 390, 844, {
focusViewportRect: {
height: 234,
width: 234,
x: 78,
y: -100,
},
})
).toEqual({
height: 159,
width: 283,
x: 499,
y: 0,
});
});
it("uses a smaller max size for scanner LPR frames than preview frames", () => {
expect(
getLPRFrameTargetSize({
height: 1080,
width: 1440,
x: 240,
y: 0,
})
).toEqual({
height: 288,
width: LPR_FRAME_SCANNER_MAX_SIZE,
});
expect(
getLPRFrameTargetSize(
{
height: 720,
width: 333,
x: 474,
y: 0,
},
{ focusCrop: false }
)
).toEqual({
height: 576,
width: 266,
});
});
it("captures a focused downscaled JPEG blob frame for multipart LPR uploads", async () => {
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
captureTimings: {
drawMs: expect.any(Number),
encodeMs: expect.any(Number),
visualFingerprintMs: expect.any(Number),
},
filename: LPR_FRAME_FILE_NAME,
fingerprint: `384x216:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 216,
mimeType: LPR_FRAME_MIME_TYPE,
width: 384,
});
expect(canvas.width).toBe(384);
expect(canvas.height).toBe(216);
expect(frame.captureDurationMs).toBeGreaterThanOrEqual(0);
expect(frame.captureTimings.drawMs).toBeGreaterThanOrEqual(0);
expect(frame.captureTimings.encodeMs).toBeGreaterThanOrEqual(0);
expect(frame.captureTimings.visualFingerprintMs).toBeGreaterThanOrEqual(0);
expect(LPR_FRAME_SCANNER_JPEG_QUALITY).toBe(0.6);
expect(canvas.getContext).toHaveBeenCalledWith("2d", {
alpha: false,
desynchronized: true,
});
expect(drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), LPR_FRAME_MIME_TYPE, LPR_FRAME_SCANNER_JPEG_QUALITY);
});
it("notifies after drawing the current video frame before JPEG encoding starts", async () => {
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
const events = [];
const drawImage = vi.fn(() => {
events.push("draw");
});
const toBlob = vi.fn((callback) => {
events.push("encode");
callback(blob);
});
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const onFrameDrawn = vi.fn(() => {
events.push("drawn-callback");
});
await captureVideoFrameBlobForLPR(video, canvas, { onFrameDrawn });
expect(onFrameDrawn).toHaveBeenCalledTimes(1);
expect(events).toEqual(["draw", "drawn-callback", "encode"]);
});
it("reuses the 2d encoding context for repeated captures on the same canvas", async () => {
const blob = new Blob(["cached-context-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
await captureVideoFrameBlobForLPR(video, canvas);
await captureVideoFrameBlobForLPR(video, canvas);
expect(canvas.getContext).toHaveBeenCalledTimes(1);
expect(drawImage).toHaveBeenCalledTimes(2);
});
it("uses a reusable OffscreenCanvas encoder when the browser supports it", async () => {
const blob = new Blob(["offscreen-frame"], { type: LPR_FRAME_MIME_TYPE });
const offscreenCanvases = [];
const offscreenConstructor = vi.fn(function FakeOffscreenCanvas(width, height) {
this.width = width;
this.height = height;
this.drawImage = vi.fn();
this.getContext = vi.fn(() => ({
drawImage: this.drawImage,
}));
this.convertToBlob = vi.fn(() => Promise.resolve(blob));
offscreenCanvases.push(this);
});
const fallbackDrawImage = vi.fn();
const fallbackToBlob = vi.fn();
const fallbackCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fallbackDrawImage,
})),
toBlob: fallbackToBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
vi.stubGlobal("OffscreenCanvas", offscreenConstructor);
try {
const frame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
const secondFrame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `384x216:${blob.size}`,
height: 216,
mimeType: LPR_FRAME_MIME_TYPE,
width: 384,
});
expect(secondFrame).toMatchObject({
blob,
fingerprint: `384x216:${blob.size}`,
});
expect(offscreenConstructor).toHaveBeenCalledTimes(1);
expect(offscreenConstructor).toHaveBeenCalledWith(1, 1);
expect(offscreenCanvases[0].width).toBe(384);
expect(offscreenCanvases[0].height).toBe(216);
expect(offscreenCanvases[0].drawImage).toHaveBeenCalledTimes(2);
expect(offscreenCanvases[0].drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(offscreenCanvases[0].convertToBlob).toHaveBeenCalledWith({
type: LPR_FRAME_MIME_TYPE,
quality: LPR_FRAME_SCANNER_JPEG_QUALITY,
});
expect(fallbackCanvas.width).toBe(0);
expect(fallbackCanvas.height).toBe(0);
expect(fallbackCanvas.getContext).not.toHaveBeenCalled();
expect(fallbackDrawImage).not.toHaveBeenCalled();
expect(fallbackToBlob).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it("falls back to the DOM canvas when OffscreenCanvas cannot encode a blob", async () => {
const blob = new Blob(["fallback-frame"], { type: LPR_FRAME_MIME_TYPE });
const offscreenDrawImage = vi.fn();
const offscreenConstructor = vi.fn(function FakeOffscreenCanvas(width, height) {
this.width = width;
this.height = height;
this.getContext = vi.fn(() => ({
drawImage: offscreenDrawImage,
}));
});
const fallbackDrawImage = vi.fn();
const fallbackToBlob = vi.fn((callback) => callback(blob));
const fallbackCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fallbackDrawImage,
})),
toBlob: fallbackToBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
vi.stubGlobal("OffscreenCanvas", offscreenConstructor);
try {
const frame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
const secondFrame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
expect(frame).toMatchObject({
blob,
fingerprint: `384x216:${blob.size}`,
height: 216,
width: 384,
});
expect(secondFrame).toMatchObject({
blob,
fingerprint: `384x216:${blob.size}`,
});
expect(offscreenConstructor).toHaveBeenCalledTimes(1);
expect(offscreenDrawImage).toHaveBeenCalledTimes(1);
expect(offscreenDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(fallbackCanvas.width).toBe(384);
expect(fallbackCanvas.height).toBe(216);
expect(fallbackDrawImage).toHaveBeenCalledTimes(2);
expect(fallbackDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(fallbackToBlob).toHaveBeenCalledTimes(2);
expect(fallbackToBlob).toHaveBeenCalledWith(
expect.any(Function),
LPR_FRAME_MIME_TYPE,
LPR_FRAME_SCANNER_JPEG_QUALITY
);
} finally {
vi.unstubAllGlobals();
}
});
it("adds a stable visual fingerprint when canvas pixels can be sampled", async () => {
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4);
for (let index = 0; index < 8 * 8; index += 1) {
const offset = index * 4;
const value = index % 8 < 4 ? 30 : 220;
pixels[offset] = value;
pixels[offset + 1] = value;
pixels[offset + 2] = value;
pixels[offset + 3] = 255;
}
const getImageData = vi.fn(() => ({
data: pixels,
}));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData,
})),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
expect(frame.visualFingerprint).toMatch(/^[0-9a-f]{16}$/);
expect(frame.visualFingerprint).toBe("0f0f0f0f0f0f0f0f");
expect(fingerprintCanvas.width).toBe(8);
expect(fingerprintCanvas.height).toBe(8);
expect(fingerprintDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 8, 8);
expect(getImageData).toHaveBeenCalledTimes(1);
expect(getImageData).toHaveBeenCalledWith(0, 0, 8, 8);
expect(getVisualFingerprintDistance(frame.visualFingerprint, frame.visualFingerprint)).toBe(0);
expect(getVisualFingerprintDistance("0000000000000000", "000000000000000f")).toBe(4);
});
it("reuses the visual fingerprint context for repeated scene checks", async () => {
const blob = new Blob(["visual-context-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4).fill(200);
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData: vi.fn(() => ({
data: pixels,
})),
})),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
expect(fingerprintCanvas.getContext).toHaveBeenCalledTimes(1);
expect(fingerprintDrawImage).toHaveBeenCalledTimes(2);
});
it("can skip full canvas capture before drawing a visually unchanged candidate", async () => {
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4).fill(200);
const shouldEncode = vi.fn(() => false);
const toBlob = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData: vi.fn(() => ({
data: pixels,
})),
})),
};
const video = {
videoHeight: 6,
videoWidth: 8,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
focusScale: 1,
shouldEncode,
visualFingerprintCanvas: fingerprintCanvas,
});
expect(frame).toBeNull();
expect(canvas.width).toBe(0);
expect(canvas.height).toBe(0);
expect(drawImage).not.toHaveBeenCalled();
expect(fingerprintDrawImage).toHaveBeenCalledWith(video, 0, 1, 8, 5, 0, 0, 8, 8);
expect(shouldEncode).toHaveBeenCalledWith({
height: 5,
visualFingerprint: expect.stringMatching(/^[0-9a-f]{16}$/),
width: 8,
});
expect(toBlob).not.toHaveBeenCalled();
});
it("can defer the visual fingerprint until a posted frame is known to need it", async () => {
const blob = new Blob(["deferred-visual"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4);
for (let index = 0; index < 8 * 8; index += 1) {
const offset = index * 4;
const value = index % 8 < 4 ? 30 : 220;
pixels[offset] = value;
pixels[offset + 1] = value;
pixels[offset + 2] = value;
pixels[offset + 3] = 255;
}
const getImageData = vi.fn(() => ({
data: pixels,
}));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData,
})),
};
const shouldBuildVisualFingerprint = vi.fn(() => false);
const shouldEncode = vi.fn(() => true);
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
shouldBuildVisualFingerprint,
shouldEncode,
visualFingerprintCanvas: fingerprintCanvas,
});
expect(frame.visualFingerprint).toBeUndefined();
expect(frame.getVisualFingerprint).toEqual(expect.any(Function));
expect(shouldBuildVisualFingerprint).toHaveBeenCalledTimes(1);
expect(shouldEncode).toHaveBeenCalledWith({
height: 216,
width: 384,
});
expect(fingerprintDrawImage).not.toHaveBeenCalled();
expect(frame.getVisualFingerprint()).toBe("0f0f0f0f0f0f0f0f");
expect(fingerprintDrawImage).toHaveBeenCalledWith(canvas, 0, 0, 384, 216, 0, 0, 8, 8);
expect(getImageData).toHaveBeenCalledTimes(1);
expect(frame.getVisualFingerprint()).toBe("0f0f0f0f0f0f0f0f");
expect(getImageData).toHaveBeenCalledTimes(1);
});
it("captures only the centered focus area from the visible portrait preview for multipart LPR uploads", async () => {
const blob = new Blob(["portrait-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
clientHeight: 844,
clientWidth: 390,
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `283x159:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 159,
width: 283,
});
expect(canvas.width).toBe(283);
expect(canvas.height).toBe(159);
expect(drawImage).toHaveBeenCalledWith(video, 499, 281, 283, 159, 0, 0, 283, 159);
});
it("uses supplied viewport dimensions without reading rendered video layout", async () => {
const blob = new Blob(["cached-viewport-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const getClientHeight = vi.fn(() => 0);
const getClientWidth = vi.fn(() => 0);
const video = {
get clientHeight() {
return getClientHeight();
},
get clientWidth() {
return getClientWidth();
},
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
viewportHeight: 844,
viewportWidth: 390,
});
expect(frame).toMatchObject({
blob,
fingerprint: `283x159:${blob.size}`,
height: 159,
width: 283,
});
expect(getClientHeight).not.toHaveBeenCalled();
expect(getClientWidth).not.toHaveBeenCalled();
expect(drawImage.mock.calls[0][0]).toBe(video);
expect(drawImage.mock.calls[0].slice(1)).toEqual([499, 281, 283, 159, 0, 0, 283, 159]);
});
it("captures from the scanner outline center when a focus rect is supplied", async () => {
const blob = new Blob(["focused-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const video = {
clientHeight: 844,
clientWidth: 390,
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
focusViewportRect: {
height: 234,
width: 234,
x: 78,
y: 205,
},
});
expect(frame).toMatchObject({
blob,
fingerprint: `283x159:${blob.size}`,
height: 159,
width: 283,
});
expect(drawImage).toHaveBeenCalledWith(video, 499, 195, 283, 159, 0, 0, 283, 159);
});
it("can capture the full visible preview crop for non-LPR attachment frames", async () => {
const blob = new Blob(["attachment-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
clientHeight: 844,
clientWidth: 390,
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, { focusCrop: false });
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `266x576:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 576,
width: 266,
});
expect(canvas.width).toBe(266);
expect(canvas.height).toBe(576);
expect(drawImage).toHaveBeenCalledWith(video, 474, 0, 333, 720, 0, 0, 266, 576);
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), LPR_FRAME_MIME_TYPE, LPR_FRAME_JPEG_QUALITY);
});
it("does not reset canvas dimensions when the target frame size is unchanged", async () => {
const blob = new Blob(["same-size-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const widthSetter = vi.fn();
const heightSetter = vi.fn();
const canvas = {
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
Object.defineProperty(canvas, "width", {
get: () => 384,
set: widthSetter,
});
Object.defineProperty(canvas, "height", {
get: () => 216,
set: heightSetter,
});
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `384x216:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 216,
width: 384,
});
expect(widthSetter).not.toHaveBeenCalled();
expect(heightSetter).not.toHaveBeenCalled();
expect(drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
});
it("does not wait for content fingerprint bytes before returning a captured frame", async () => {
const pendingArrayBuffer = new Promise(() => {});
const blob = {
size: 123,
type: LPR_FRAME_MIME_TYPE,
slice: vi.fn(() => ({
arrayBuffer: vi.fn(() => pendingArrayBuffer),
})),
};
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: "384x216:123",
getContentFingerprint: expect.any(Function),
height: 216,
width: 384,
});
expect(blob.slice).not.toHaveBeenCalled();
const contentFingerprintPromise = frame.getContentFingerprint();
expect(blob.slice).toHaveBeenCalled();
expect(frame.getContentFingerprint()).toBe(contentFingerprintPromise);
});
it("samples duplicate content fingerprint offsets only once for small blobs", async () => {
const slice = vi.fn(() => ({
arrayBuffer: vi.fn(() => Promise.resolve(new Uint8Array([1, 2, 3, 4]).buffer)),
}));
const blob = {
size: 4,
slice,
};
await expect(buildLPRFrameFingerprint(blob, 300, 225)).resolves.toMatch(/^300x225:4:[0-9a-f]{8}$/);
expect(slice).toHaveBeenCalledTimes(1);
expect(slice).toHaveBeenCalledWith(0, 4);
});
it("builds stable blob fingerprints without using capture time", async () => {
const first = new Blob(["same-size-a"], { type: LPR_FRAME_MIME_TYPE });
const second = new Blob(["same-size-a"], { type: LPR_FRAME_MIME_TYPE });
const changed = new Blob(["same-size-b"], { type: LPR_FRAME_MIME_TYPE });
await expect(buildLPRFrameFingerprint(second, 1024, 576)).resolves.toBe(
await buildLPRFrameFingerprint(first, 1024, 576)
);
await expect(buildLPRFrameFingerprint(changed, 1024, 576)).resolves.not.toBe(
await buildLPRFrameFingerprint(first, 1024, 576)
);
expect(buildLPRFrameFingerprintKey(first, 1024, 576)).toBe(`1024x576:${first.size}`);
});
});
+191
View File
@@ -0,0 +1,191 @@
// @vitest-environment jsdom
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import MobileFooter from "@/components/viewport/page/footers/MobileFooter.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
enableAutoUnmount(afterEach);
const mocks = vi.hoisted(() => {
const route = {
path: "/user/wash/start",
fullPath: "/user/wash/start",
};
return {
route,
push: vi.fn(),
};
});
vi.mock("vue-router", () => ({
useRouter: () => ({
currentRoute: {
value: mocks.route,
},
push: mocks.push,
}),
}));
vi.mock("@/services/frontendMaintenance.js", () => ({
forceFrontendUpdateAndClearLocal: vi.fn(async () => true),
}));
vi.mock("buefy", async () => {
const { defineComponent, h } = await import("vue");
const BButton = defineComponent({
name: "MockBButton",
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => {
const { type: _type, iconPack: _iconPack, expanded: _expanded, size: _size, ...buttonAttrs } = attrs;
return h(
"button",
{
...buttonAttrs,
type: "button",
disabled: Boolean(attrs.disabled),
},
slots.default ? slots.default() : []
);
};
},
});
const Passthrough = defineComponent({
name: "MockPassthrough",
setup(_props, { slots }) {
return () => h("div", slots.default ? slots.default() : []);
},
});
const BIcon = defineComponent({
name: "MockBIcon",
props: {
icon: {
type: String,
default: "",
},
},
setup(props) {
return () => h("span", { "data-testid": `mock-icon-${props.icon}` });
},
});
return {
BButton,
BField: Passthrough,
BIcon,
};
});
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
await nextTick();
};
const mountFooter = () =>
mount(MobileFooter, {
attachTo: document.body,
});
const getHomeButton = (wrapper) => wrapper.get("[data-testid='mobile-footer-home']");
const dispatchHomePointerEvent = async (wrapper, eventName) => {
const event = new Event(eventName, {
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, "button", {
value: 0,
});
Object.defineProperty(event, "pointerType", {
value: "touch",
});
getHomeButton(wrapper).element.dispatchEvent(event);
await nextTick();
};
const clickDialogButton = async (testId) => {
await flushMicrotasks();
document.body.querySelector(`[data-testid='${testId}']`).click();
await flushMicrotasks();
};
const holdHomeFor = async (wrapper, durationMs) => {
await dispatchHomePointerEvent(wrapper, "pointerdown");
await vi.advanceTimersByTimeAsync(durationMs);
await nextTick();
};
describe("MobileFooter", () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = "";
mocks.route.path = "/user/wash/start";
mocks.route.fullPath = "/user/wash/start";
mocks.push.mockReset();
vi.mocked(forceFrontendUpdateAndClearLocal).mockClear();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
it("navigates to /user on a short Hjem click", async () => {
const wrapper = mountFooter();
await dispatchHomePointerEvent(wrapper, "pointerdown");
await dispatchHomePointerEvent(wrapper, "pointerup");
await getHomeButton(wrapper).trigger("click");
expect(mocks.push).toHaveBeenCalledTimes(1);
expect(mocks.push).toHaveBeenCalledWith("/user");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
});
it("does not open the reset dialog when Hjem is held for less than 5 seconds", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 4999);
await dispatchHomePointerEvent(wrapper, "pointerup");
await vi.advanceTimersByTimeAsync(1);
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
});
it("opens the reset dialog after a 5 second Hjem hold and suppresses navigation", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await dispatchHomePointerEvent(wrapper, "pointerup");
await getHomeButton(wrapper).trigger("click");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
expect(mocks.push).not.toHaveBeenCalled();
});
it("closes the reset dialog without cleanup when Nej is pressed", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await flushMicrotasks();
await clickDialogButton("local-data-reset-cancel");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
expect(forceFrontendUpdateAndClearLocal).not.toHaveBeenCalled();
});
it("runs the full local data cleanup when Ja, ryd alt is pressed", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await flushMicrotasks();
await clickDialogButton("local-data-reset-confirm");
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
});
});
+10
View File
@@ -32,6 +32,16 @@ vi.mock("@/composables/useWashDepartments", () => ({
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
locations: { locations: {
location: mocks.locationRef, location: mocks.locationRef,
normalizeCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude) ? { latitude, longitude } : null;
},
hasValidCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude);
},
getDistance: mocks.getDistance, getDistance: mocks.getDistance,
}, },
})); }));
+5
View File
@@ -140,6 +140,11 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
}, },
}, },
}, },
hasValidCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude);
},
}, },
})); }));
@@ -38,6 +38,11 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
return { return {
locations: { locations: {
location: ref(null), location: ref(null),
normalizeCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude) ? { latitude, longitude } : null;
},
getDistance: () => 0, getDistance: () => 0,
}, },
}; };
File diff suppressed because it is too large Load Diff
+252 -2
View File
@@ -410,28 +410,227 @@ describe("RequestQueueProgress", () => {
const wrapper = mount(RequestQueueProgress); const wrapper = mount(RequestQueueProgress);
const pingRequest = createDeferred(); const pingRequest = createDeferred();
const bookingsRequest = createDeferred(); const bookingsRequest = createDeferred();
const scannerRequest = createDeferred();
const requestOne = enqueueRequest(() => pingRequest.promise, { method: "GET", url: "/ping" }); const requestOne = enqueueRequest(() => pingRequest.promise, { method: "GET", url: "/ping" });
const requestTwo = enqueueRequest(() => bookingsRequest.promise, { method: "GET", url: "/order-bookings" }); const requestTwo = enqueueRequest(() => bookingsRequest.promise, { method: "GET", url: "/order-bookings" });
const requestThree = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
await flushManyMicrotasks(); await flushManyMicrotasks();
await triggerShiftTriplePress(); await triggerShiftTriplePress();
await flushManyMicrotasks(); await flushManyMicrotasks();
const scannerServerTiming =
"lpr_client_capture;dur=12.345, lpr_client_preflight;dur=1.500, lpr_client_visual_fingerprint;dur=0.750, lpr_client_draw;dur=2.250, lpr_client_encode;dur=8.500, lpr_client_frame_width;dur=300.000, lpr_client_frame_height;dur=225.000, lpr_client_frame_bytes;dur=12345.000, lpr_cache_miss;dur=1.000, lpr_cache;dur=0.750, lpr_local;dur=2.500, lpr_upstream_processing;dur=30.250, lpr_total;dur=42.500, lpr_upstream_total;dur=40.000";
pingRequest.resolve({ status: 200 }); pingRequest.resolve({ status: 200 });
bookingsRequest.resolve({ status: 200 }); bookingsRequest.resolve({ status: 200 });
await Promise.all([requestOne, requestTwo]); scannerRequest.resolve({
status: 200,
headers: {
"server-timing": scannerServerTiming,
},
});
await Promise.all([requestOne, requestTwo, requestThree]);
await flushManyMicrotasks(); await flushManyMicrotasks();
const insights = wrapper.get("[data-testid='request-queue-bottom-request-insights']"); const insights = wrapper.get("[data-testid='request-queue-bottom-request-insights']");
expect(insights.text()).toContain("Ping"); expect(insights.text()).toContain("Ping");
expect(insights.text()).toContain("Bookings"); expect(insights.text()).toContain("Bookings");
expect(insights.text()).toContain("ms"); expect(insights.text()).toContain("Scanner");
expect(insights.text()).toContain("browser");
expect(insights.text()).toContain("cap 12.345 ms");
expect(insights.text()).toContain("prep 1.5 ms");
expect(insights.text()).toContain("vf 0.75 ms");
expect(insights.text()).toContain("draw 2.25 ms");
expect(insights.text()).toContain("enc 8.5 ms");
expect(insights.text()).toContain("img 300x225");
expect(insights.text()).toContain("bytes 12.1 KB");
expect(insights.text()).toContain("cache miss");
expect(insights.text()).toContain("cache 0.75 ms");
expect(insights.text()).toContain("local 2.5 ms");
expect(insights.text()).toContain("proc 30.25 ms");
expect(insights.text()).toContain("up 40 ms");
expect(insights.text()).toContain("srv 42.5 ms");
const pingInsight = wrapper.get("[data-testid='request-queue-insight-ping']"); const pingInsight = wrapper.get("[data-testid='request-queue-insight-ping']");
const bookingsInsight = wrapper.get("[data-testid='request-queue-insight-bookings']"); const bookingsInsight = wrapper.get("[data-testid='request-queue-insight-bookings']");
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(pingInsight.text()).toMatch(/now|s ago|m ago/); expect(pingInsight.text()).toMatch(/now|s ago|m ago/);
expect(bookingsInsight.text()).toMatch(/now|s ago|m ago/); expect(bookingsInsight.text()).toMatch(/now|s ago|m ago/);
expect(scannerInsight.text()).toMatch(/now|s ago|m ago/);
expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe(
scannerServerTiming
);
});
it("shows scanner cache hits without upstream timing", async () => {
const wrapper = mount(RequestQueueProgress);
const scannerRequest = createDeferred();
const request = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
const scannerServerTiming =
"lpr_client_capture;dur=9.500, lpr_cache_hit;dur=1.000, lpr_cache;dur=0.430, lpr_local;dur=1.200, lpr_total;dur=1.200";
scannerRequest.resolve({
status: 200,
headers: {
"server-timing": scannerServerTiming,
},
});
await request;
await flushManyMicrotasks();
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(scannerInsight.text()).toContain("cache hit");
expect(scannerInsight.text()).toContain("cache 0.43 ms");
expect(scannerInsight.text()).toContain("local 1.2 ms");
expect(scannerInsight.text()).toContain("srv 1.2 ms");
expect(scannerInsight.text()).not.toContain("up ");
expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe(
scannerServerTiming
);
});
it("shows scanner insights when successful frames skip the generic recent request list", async () => {
const wrapper = mount(RequestQueueProgress);
const staleRecentRequest = createDeferred();
const scannerRequest = createDeferred();
const staleRequest = enqueueRequest(() => staleRecentRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
const request = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
insightKey: "scanner",
recordRecentOnSuccess: false,
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
staleRecentRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_client_capture;dur=99.000, lpr_total;dur=120.000",
},
});
await staleRequest;
await flushManyMicrotasks();
vi.advanceTimersByTime(5);
const scannerServerTiming = "lpr_client_capture;dur=8.000, lpr_total;dur=24.000";
scannerRequest.resolve({
status: 200,
headers: {
"server-timing": scannerServerTiming,
},
});
await request;
await flushManyMicrotasks();
expect(requestQueueState.recentRequests).toHaveLength(1);
expect(requestQueueState.recentRequests[0].serverTiming).toContain("dur=99.000");
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(scannerInsight.text()).toContain("browser");
expect(scannerInsight.text()).toContain("cap 8 ms");
expect(scannerInsight.text()).toContain("srv 24 ms");
expect(scannerInsight.text()).not.toContain("99 ms");
expect(scannerInsight.get(".request-queue-progress__bottom-request-latency").attributes("title")).toBe(
scannerServerTiming
);
});
it("updates scanner request insights without replacing the insights container", async () => {
const firstScannerRequest = createDeferred();
const secondScannerRequest = createDeferred();
const firstRequest = enqueueRequest(() => firstScannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
insightKey: "scanner",
recordRecentOnSuccess: false,
});
await flushManyMicrotasks();
firstScannerRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_total;dur=24.000",
},
});
await firstRequest;
await flushManyMicrotasks();
const requestInsightsRef = requestQueueState.requestInsights;
expect(requestInsightsRef.scanner?.serverTiming).toBe("lpr_total;dur=24.000");
const secondRequest = enqueueRequest(() => secondScannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
insightKey: "scanner",
recordRecentOnSuccess: false,
});
await flushManyMicrotasks();
secondScannerRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_total;dur=18.000",
},
});
await secondRequest;
await flushManyMicrotasks();
expect(requestQueueState.requestInsights).toBe(requestInsightsRef);
expect(requestQueueState.requestInsights.scanner?.serverTiming).toBe("lpr_total;dur=18.000");
});
it("shows scanner queue wait separately from browser request time", async () => {
const wrapper = mount(RequestQueueProgress);
const blockingRequest = createDeferred();
const scannerRequest = createDeferred();
const requestOne = enqueueRequest(() => blockingRequest.promise, { method: "POST", url: "/blocking-post" });
const requestTwo = enqueueRequest(() => scannerRequest.promise, {
method: "POST",
url: "/modules/scanner/lpr",
});
await flushManyMicrotasks();
await triggerShiftTriplePress();
await flushManyMicrotasks();
vi.advanceTimersByTime(37);
blockingRequest.resolve({ status: 200 });
await requestOne;
await flushManyMicrotasks();
vi.advanceTimersByTime(63);
scannerRequest.resolve({
status: 200,
headers: {
"server-timing": "lpr_total;dur=21.000",
},
});
await requestTwo;
await flushManyMicrotasks();
const scannerInsight = wrapper.get("[data-testid='request-queue-insight-scanner']");
expect(scannerInsight.text()).toContain("browser 63 ms");
expect(scannerInsight.text()).toContain("queue 37 ms");
expect(scannerInsight.text()).toContain("net 42 ms");
expect(scannerInsight.text()).toContain("srv 21 ms");
}); });
it("does not emit recursive update errors while processing many requests", async () => { it("does not emit recursive update errors while processing many requests", async () => {
@@ -458,6 +657,57 @@ describe("RequestQueueProgress", () => {
expect(hasRecursiveError).toBe(false); expect(hasRecursiveError).toBe(false);
}); });
it("stores server timing headers with completed and failed request entries", async () => {
__configureRequestQueueForTests({
errorHistoryLimit: 2,
retryByStatusCode: {},
});
const completed = enqueueRequest(
async () => ({
status: 200,
headers: {
"server-timing": "lpr_total;dur=42.500",
},
data: { ok: true },
}),
{
method: "POST",
url: "/modules/scanner/lpr",
}
);
const failed = enqueueRequest(
async () => {
throw {
message: "Scanner failed",
response: {
status: 500,
headers: {
"Server-Timing": "lpr_upstream;dur=450.000",
},
data: { ok: false },
},
};
},
{
method: "POST",
url: "/modules/scanner/lpr",
}
).catch((error) => error);
await Promise.all([completed, failed]);
await flushManyMicrotasks();
expect(requestQueueState.recentRequests).toHaveLength(2);
expect(requestQueueState.recentRequests.find((request) => request.success === true)?.serverTiming).toBe(
"lpr_total;dur=42.500"
);
expect(requestQueueState.recentRequests.find((request) => request.success === false)?.serverTiming).toBe(
"lpr_upstream;dur=450.000"
);
expect(requestQueueState.errorRequests[0].serverTiming).toBe("lpr_upstream;dur=450.000");
});
it("stores errors with request and response payloads and respects configured error limit", async () => { it("stores errors with request and response payloads and respects configured error limit", async () => {
__configureRequestQueueForTests({ __configureRequestQueueForTests({
errorHistoryLimit: 1, errorHistoryLimit: 1,
@@ -0,0 +1,882 @@
// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
import ScannerCamera from "@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue";
import { captureVideoFrameBlobForLPR } from "@/components/viewport/page/templates/scanner/lprFrameCapture";
const mocks = vi.hoisted(() => ({
captureVideoFrameBlobForLPR: vi.fn(),
getLPRFrameSourceRect: vi.fn(),
getLPRFrameTargetSize: vi.fn(),
getUserMedia: vi.fn(),
isVideoFrameReadyForLPR: vi.fn(),
trackStop: vi.fn(),
videoTrack: null,
}));
vi.mock("@/components/viewport/page/templates/scanner/lprFrameCapture", () => ({
captureVideoFrameBlobForLPR: mocks.captureVideoFrameBlobForLPR,
getLPRFrameSourceRect: mocks.getLPRFrameSourceRect,
getLPRFrameTargetSize: mocks.getLPRFrameTargetSize,
isVideoFrameReadyForLPR: mocks.isVideoFrameReadyForLPR,
LPR_CAMERA_FRAME_RATE: 5,
LPR_CAMERA_VIDEO_HEIGHT: 576,
LPR_CAMERA_VIDEO_WIDTH: 1024,
}));
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
camera: {
getImageCaptureDelay: vi.fn((isFirstCapture) => (isFirstCapture ? 25 : 1000)),
getZoom: vi.fn(() => 1),
},
isCameraMounted: { value: false },
}));
const setDocumentVisibility = (visibilityState) => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: visibilityState,
});
};
const overconstrainedError = (constraint) => ({
constraint,
name: "OverconstrainedError",
});
describe("ScannerCamera capture gating", () => {
beforeEach(() => {
vi.useFakeTimers();
setDocumentVisibility("visible");
mocks.captureVideoFrameBlobForLPR.mockReset();
mocks.getLPRFrameSourceRect.mockReset();
mocks.getLPRFrameSourceRect.mockReturnValue({ height: 1, width: 1, x: 0, y: 0 });
mocks.getLPRFrameTargetSize.mockReset();
mocks.getLPRFrameTargetSize.mockReturnValue({ width: 1, height: 1 });
mocks.captureVideoFrameBlobForLPR.mockResolvedValue({
blob: new Blob(["frame"], { type: "image/jpeg" }),
filename: "frame.jpg",
fingerprint: "frame",
height: 1,
mimeType: "image/jpeg",
width: 1,
});
mocks.isVideoFrameReadyForLPR.mockReset();
mocks.isVideoFrameReadyForLPR.mockReturnValue(true);
mocks.getUserMedia.mockReset();
mocks.trackStop.mockReset();
mocks.videoTrack = {
enabled: true,
kind: "video",
stop: mocks.trackStop,
};
mocks.getUserMedia.mockResolvedValue({
getTracks: () => [mocks.videoTrack],
getVideoTracks: () => [mocks.videoTrack],
});
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: mocks.getUserMedia,
},
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value: vi.fn(() => Promise.resolve()),
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
setDocumentVisibility("visible");
vi.useRealTimers();
vi.restoreAllMocks();
});
it("does not encode frames while capture is disabled", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: false,
},
});
await flushPromises();
vi.advanceTimersByTime(100);
await flushPromises();
expect(mocks.getUserMedia).toHaveBeenCalledTimes(1);
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
wrapper.unmount();
});
it("pauses frame capture while the page is hidden and resumes when visible", async () => {
setDocumentVisibility("hidden");
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(2000);
await flushPromises();
expect(mocks.getUserMedia).toHaveBeenCalledTimes(1);
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
setDocumentVisibility("visible");
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
vi.advanceTimersByTime(0);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(999);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
it("does not emit an in-flight captured frame after the page becomes hidden", async () => {
let resolveFrame;
mocks.captureVideoFrameBlobForLPR.mockReturnValueOnce(
new Promise((resolve) => {
resolveFrame = resolve;
})
);
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
setDocumentVisibility("hidden");
document.dispatchEvent(new Event("visibilitychange"));
resolveFrame({
blob: new Blob(["hidden-frame"], { type: "image/jpeg" }),
filename: "hidden-frame.jpg",
fingerprint: "hidden-frame",
height: 1,
mimeType: "image/jpeg",
width: 1,
});
await flushPromises();
expect(wrapper.emitted("update:frame")).toBeUndefined();
setDocumentVisibility("visible");
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
vi.advanceTimersByTime(0);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
expect(wrapper.emitted("update:frame")).toHaveLength(1);
wrapper.unmount();
});
it("encodes frames when capture is enabled", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledWith(
expect.any(HTMLVideoElement),
expect.any(HTMLCanvasElement),
{
focusCrop: true,
focusViewportRect: null,
shouldBuildVisualFingerprint: undefined,
shouldEncode: undefined,
visualFingerprintCanvas: expect.any(HTMLCanvasElement),
}
);
expect(wrapper.emitted("update:frame")?.[0]?.[0]).toMatchObject({
filename: "frame.jpg",
fingerprint: "frame",
});
wrapper.unmount();
});
it("keeps the live preview running while the current frame is encoded", async () => {
const pause = HTMLMediaElement.prototype.pause;
let resolveFrame;
mocks.captureVideoFrameBlobForLPR.mockImplementationOnce(() => {
expect(mocks.videoTrack.enabled).toBe(true);
return new Promise((resolve) => {
resolveFrame = resolve;
});
});
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
expect(mocks.videoTrack.enabled).toBe(true);
expect(pause).not.toHaveBeenCalled();
resolveFrame({
blob: new Blob(["encoded-frame"], { type: "image/jpeg" }),
filename: "encoded-frame.jpg",
fingerprint: "encoded-frame",
height: 1,
mimeType: "image/jpeg",
width: 1,
});
await flushPromises();
expect(mocks.videoTrack.enabled).toBe(true);
expect(pause).not.toHaveBeenCalled();
expect(wrapper.emitted("update:frame")?.[0]?.[0]).toMatchObject({
filename: "encoded-frame.jpg",
fingerprint: "encoded-frame",
});
wrapper.unmount();
});
it("pauses the live preview while requested and resumes it afterward", async () => {
const play = HTMLMediaElement.prototype.play;
const pause = HTMLMediaElement.prototype.pause;
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
expect(play).toHaveBeenCalledTimes(1);
expect(pause).not.toHaveBeenCalled();
await wrapper.setProps({ pausePreview: true });
await flushPromises();
expect(pause).toHaveBeenCalledTimes(1);
expect(mocks.videoTrack.enabled).toBe(false);
await wrapper.setProps({ pausePreview: false });
await flushPromises();
expect(play).toHaveBeenCalledTimes(2);
expect(mocks.videoTrack.enabled).toBe(true);
wrapper.unmount();
});
it("does not replay an already running preview after each preview-mode capture", async () => {
const play = HTMLMediaElement.prototype.play;
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
captureMode: "preview",
},
});
await flushPromises();
expect(play).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(25);
await flushPromises();
vi.advanceTimersByTime(1000);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
expect(play).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
it("does not capture frozen preview frames while the live preview is paused", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
await wrapper.setProps({ pausePreview: true });
await flushPromises();
vi.advanceTimersByTime(2000);
await flushPromises();
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
await wrapper.setProps({ pausePreview: false });
await flushPromises();
vi.advanceTimersByTime(99);
await flushPromises();
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
it("does not precompute canvas dimensions outside the capture helper", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
expect(mocks.getLPRFrameSourceRect).not.toHaveBeenCalled();
expect(mocks.getLPRFrameTargetSize).not.toHaveBeenCalled();
wrapper.unmount();
});
it("caps the requested live camera stream resolution and frame rate", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
expect(mocks.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
})
);
wrapper.unmount();
});
it("preserves the resolution cap when the camera rejects the frame-rate cap", async () => {
mocks.getUserMedia.mockRejectedValueOnce(overconstrainedError("frameRate")).mockResolvedValueOnce({
getTracks: () => [mocks.videoTrack],
getVideoTracks: () => [mocks.videoTrack],
});
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
expect(mocks.getUserMedia).toHaveBeenCalledTimes(2);
expect(mocks.getUserMedia.mock.calls[0][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
})
);
expect(mocks.getUserMedia.mock.calls[1][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
})
);
expect(mocks.getUserMedia.mock.calls[1][0].video.frameRate).not.toHaveProperty("max");
expect(mocks.getUserMedia.mock.calls[1][0].video.height).toHaveProperty("max", 576);
expect(mocks.getUserMedia.mock.calls[1][0].video.width).toHaveProperty("max", 1024);
wrapper.unmount();
});
it("preserves the frame-rate cap when the camera rejects the resolution cap", async () => {
mocks.getUserMedia.mockRejectedValueOnce(overconstrainedError("width")).mockResolvedValueOnce({
getTracks: () => [mocks.videoTrack],
getVideoTracks: () => [mocks.videoTrack],
});
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
expect(mocks.getUserMedia).toHaveBeenCalledTimes(2);
expect(mocks.getUserMedia.mock.calls[0][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
})
);
expect(mocks.getUserMedia.mock.calls[1][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
height: { ideal: 576 },
width: { ideal: 1024 },
}),
})
);
expect(mocks.getUserMedia.mock.calls[1][0].video.frameRate).toHaveProperty("max", 5);
expect(mocks.getUserMedia.mock.calls[1][0].video.height).not.toHaveProperty("max");
expect(mocks.getUserMedia.mock.calls[1][0].video.width).not.toHaveProperty("max");
wrapper.unmount();
});
it("uses the visible preview crop when capture mode is preview", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
captureMode: "preview",
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledWith(
expect.any(HTMLVideoElement),
expect.any(HTMLCanvasElement),
{
focusCrop: false,
focusViewportRect: null,
shouldBuildVisualFingerprint: undefined,
shouldEncode: undefined,
visualFingerprintCanvas: null,
}
);
wrapper.unmount();
});
it("passes a video-relative focus rect when a scanner target is provided", async () => {
const getFocusViewportRect = vi.fn(() => ({
height: 90,
width: 100,
x: 10,
y: 20,
}));
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
getFocusViewportRect,
},
});
await flushPromises();
vi.spyOn(wrapper.get("video").element, "getBoundingClientRect").mockReturnValue({
bottom: 854,
height: 844,
left: 5,
right: 395,
toJSON: () => ({}),
top: 10,
width: 390,
x: 5,
y: 10,
});
vi.advanceTimersByTime(25);
await flushPromises();
expect(getFocusViewportRect).toHaveBeenCalled();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledWith(
expect.any(HTMLVideoElement),
expect.any(HTMLCanvasElement),
{
focusCrop: true,
focusViewportRect: {
height: 90,
width: 100,
x: 5,
y: 10,
},
shouldBuildVisualFingerprint: undefined,
shouldEncode: undefined,
viewportHeight: 844,
viewportWidth: 390,
visualFingerprintCanvas: expect.any(HTMLCanvasElement),
}
);
wrapper.unmount();
});
it("reuses cached video-relative focus geometry until viewport geometry changes", async () => {
const getFocusViewportRect = vi.fn(() => ({
height: 90,
width: 100,
x: 10,
y: 20,
}));
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
getFocusViewportRect,
},
});
await flushPromises();
const videoRectSpy = vi.spyOn(wrapper.get("video").element, "getBoundingClientRect").mockReturnValue({
bottom: 854,
height: 844,
left: 5,
right: 395,
toJSON: () => ({}),
top: 10,
width: 390,
x: 5,
y: 10,
});
vi.advanceTimersByTime(25);
await flushPromises();
vi.advanceTimersByTime(1000);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
expect(getFocusViewportRect).toHaveBeenCalledTimes(1);
expect(videoRectSpy).toHaveBeenCalledTimes(1);
expect(captureVideoFrameBlobForLPR.mock.calls[1][2].focusViewportRect).toEqual({
height: 90,
width: 100,
x: 5,
y: 10,
});
expect(captureVideoFrameBlobForLPR.mock.calls[1][2]).toEqual(
expect.objectContaining({
viewportHeight: 844,
viewportWidth: 390,
})
);
getFocusViewportRect.mockReturnValue({
height: 90,
width: 100,
x: 30,
y: 45,
});
videoRectSpy.mockReturnValue({
bottom: 859,
height: 844,
left: 8,
right: 398,
toJSON: () => ({}),
top: 15,
width: 390,
x: 8,
y: 15,
});
window.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(1000);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(3);
expect(getFocusViewportRect).toHaveBeenCalledTimes(2);
expect(videoRectSpy).toHaveBeenCalledTimes(2);
expect(captureVideoFrameBlobForLPR.mock.calls[2][2].focusViewportRect).toEqual({
height: 90,
width: 100,
x: 22,
y: 30,
});
expect(captureVideoFrameBlobForLPR.mock.calls[2][2]).toEqual(
expect.objectContaining({
viewportHeight: 844,
viewportWidth: 390,
})
);
wrapper.unmount();
});
it("uses the shorter first-capture delay before the recurring capture interval", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(24);
await flushPromises();
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(999);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
it("uses a custom recurring capture interval when provided", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
captureIntervalMs: 350,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(349);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
await wrapper.setProps({ captureIntervalMs: 200 });
await flushPromises();
vi.advanceTimersByTime(199);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(3);
wrapper.unmount();
});
it("does not schedule another recurring capture while frame encoding is still settling", async () => {
let resolveSlowFrame;
mocks.captureVideoFrameBlobForLPR
.mockResolvedValueOnce({
blob: new Blob(["first"], { type: "image/jpeg" }),
filename: "first.jpg",
fingerprint: "first",
height: 1,
mimeType: "image/jpeg",
width: 1,
})
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSlowFrame = resolve;
})
)
.mockResolvedValue({
blob: new Blob(["next"], { type: "image/jpeg" }),
filename: "next.jpg",
fingerprint: "next",
height: 1,
mimeType: "image/jpeg",
width: 1,
});
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(5000);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
resolveSlowFrame({
blob: new Blob(["slow"], { type: "image/jpeg" }),
filename: "slow.jpg",
fingerprint: "slow",
height: 1,
mimeType: "image/jpeg",
width: 1,
});
await flushPromises();
vi.advanceTimersByTime(999);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(3);
wrapper.unmount();
});
it("captures immediately and restarts the interval when capture is re-enabled", async () => {
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: false,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
await wrapper.setProps({ captureEnabled: true });
await flushPromises();
vi.advanceTimersByTime(0);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(999);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
it("retries shortly when the first capture fires before the video frame is ready", async () => {
mocks.isVideoFrameReadyForLPR.mockReturnValue(false);
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
mocks.isVideoFrameReadyForLPR.mockReturnValue(true);
vi.advanceTimersByTime(99);
await flushPromises();
expect(captureVideoFrameBlobForLPR).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(999);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
it("captures from the video loadeddata event before the first-capture timer fires", async () => {
mocks.isVideoFrameReadyForLPR.mockReturnValue(false);
const wrapper = mountWithApp(ScannerCamera, {
props: {
captureEnabled: true,
},
});
await flushPromises();
mocks.isVideoFrameReadyForLPR.mockReturnValue(true);
await wrapper.get("video").trigger("loadeddata");
vi.advanceTimersByTime(0);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(25);
await flushPromises();
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
});
@@ -150,6 +150,8 @@ describe("self-serve studio task editing", () => {
"const machineStartPathResultList = computed(() => pathResultList.value.filter(pathCaseShowsMachineStartTask))" "const machineStartPathResultList = computed(() => pathResultList.value.filter(pathCaseShowsMachineStartTask))"
); );
expect(source).toContain("const pathCaseList = computed(() => machineStartPathResultList.value)"); expect(source).toContain("const pathCaseList = computed(() => machineStartPathResultList.value)");
expect(source).toContain("const PATH_OUTCOMES_CASE_LIMIT = 2048");
expect(source).toContain("path_sample_limit: PATH_OUTCOMES_CASE_LIMIT");
expect(source).toContain('data-testid="studio-path-hidden-non-machine-start-cases"'); expect(source).toContain('data-testid="studio-path-hidden-non-machine-start-cases"');
}); });
+225
View File
@@ -49,6 +49,231 @@ describe("useSelfServeLogic", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
const makeRule = ({ id = 1, conditionId = 100, objectType = "question", objectId = 1, type = "IS_TRUE" } = {}) => ({
id,
condition_id: conditionId,
object_type: objectType,
object_id: objectId,
type,
name: `${type}-${id}`,
});
const applyEvaluationState = (logic, { answers = {}, rules = [], tasks = [], conditions = [] } = {}) => {
logic.answers.value = answers;
logic.rules.value = rules;
logic.tasks.value = tasks;
logic.conditions.value = conditions;
};
describe("condition evaluation engine", () => {
it.each([
["IS_TRUE", true, true],
["IS_TRUE", false, false],
["IS_TRUE", null, false],
["IS_TRUE", undefined, false],
["IS_FALSE", false, true],
["IS_FALSE", true, false],
["IS_FALSE", null, false],
["IS_FALSE", undefined, false],
["IS_TRUE_OR_NOT_SET", true, true],
["IS_TRUE_OR_NOT_SET", false, false],
["IS_TRUE_OR_NOT_SET", null, true],
["IS_TRUE_OR_NOT_SET", undefined, true],
["IS_FALSE_OR_NOT_SET", false, true],
["IS_FALSE_OR_NOT_SET", true, false],
["IS_FALSE_OR_NOT_SET", null, true],
["IS_FALSE_OR_NOT_SET", undefined, true],
["IS_SET", true, true],
["IS_SET", false, true],
["IS_SET", null, false],
["IS_SET", undefined, false],
["UNKNOWN_RULE", true, false],
])("evaluates %s for %s answers", (type, answerValue, expected) => {
const logic = useSelfServeLogic();
const answers = {};
if (answerValue !== undefined) {
answers[1] = answerValue;
}
const rule = makeRule({ type });
applyEvaluationState(logic, {
answers,
rules: [rule],
});
expect(logic.evaluateRule(rule)).toBe(expected);
expect(logic.evaluateCondition(100)).toBe(expected);
});
it("treats an empty condition id as satisfied", () => {
const logic = useSelfServeLogic();
expect(logic.evaluateCondition(null)).toBe(true);
expect(logic.evaluateCondition(0)).toBe(true);
});
it("rejects conditions with no rules when evaluated directly", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
conditions: [{ id: 100, name: "No rules" }],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(999)).toBe(false);
});
it("evaluates nested condition references", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
],
});
expect(
logic.evaluateRule(makeRule({ conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }))
).toBe(true);
expect(logic.evaluateCondition(100)).toBe(true);
});
it("detects direct condition cycles", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
rules: [makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 100, type: "IS_TRUE" })],
});
expect(logic.evaluateCondition(100)).toBe(false);
});
it("detects cycles across nested condition references", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectType: "condition", objectId: 100, type: "IS_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(200)).toBe(false);
});
it("returns true for IS_TRUE_OR_ANY_TRUE when the nested condition is true", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(true);
});
it("returns true for IS_TRUE_OR_ANY_TRUE when any nested rule is true", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true, 2: false },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
makeRule({ id: 3, conditionId: 200, objectId: 2, type: "IS_FALSE" }),
],
});
expect(logic.evaluateCondition(200)).toBe(false);
expect(logic.evaluateCondition(100)).toBe(true);
});
it("returns false for IS_TRUE_OR_ANY_TRUE when direct question answer is not true", () => {
const logic = useSelfServeLogic();
const rule = makeRule({ type: "IS_TRUE_OR_ANY_TRUE" });
applyEvaluationState(logic, {
answers: { 1: false },
rules: [rule],
});
expect(logic.evaluateRule(rule)).toBe(false);
expect(logic.evaluateCondition(100)).toBe(false);
});
it("returns false for IS_TRUE_OR_ANY_TRUE when nested rules are all false or missing", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
makeRule({ id: 3, conditionId: 300, objectType: "condition", objectId: 999, type: "IS_TRUE_OR_ANY_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(300)).toBe(false);
});
it("keeps tasks with no condition or missing condition rules active by default", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
tasks: [
{ id: 1, task_id: 1, task: "No condition", order_priority: 3, condition_id: null },
{ id: 2, task_id: 2, task: "Zero condition", order_priority: 2, condition_id: 0 },
{ id: 3, task_id: 3, task: "Missing rules", order_priority: 1, condition_id: 999 },
],
});
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2, 1]);
expect(logic.isTaskActive(999)).toBe(false);
});
it("filters and sorts active tasks based on evaluated conditions", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true, 2: false, 3: null },
rules: [
makeRule({ id: 1, conditionId: 100, objectId: 1, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 2, type: "IS_TRUE" }),
makeRule({ id: 3, conditionId: 300, objectId: 3, type: "IS_TRUE_OR_NOT_SET" }),
],
tasks: [
{
id: 1,
task_id: 1,
task: "Hidden false condition",
order_priority: 1,
condition_id: 200,
services: ["HIDDEN"],
},
{
id: 2,
task_id: 2,
task: "Active null condition",
order_priority: 3,
condition_id: 300,
services: ["OPTIONAL"],
},
{
id: 3,
task_id: 3,
task: "Active true condition",
order_priority: 2,
condition_id: 100,
services: ["MACHINE"],
},
],
});
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2]);
expect(logic.isTaskActive(1)).toBe(false);
expect(logic.isTaskActive(2)).toBe(true);
expect(logic.activeTaskServices.value).toEqual(["MACHINE", "OPTIONAL"]);
});
});
it("uses task attachments supplied by preview and summary data", async () => { it("uses task attachments supplied by preview and summary data", async () => {
const taskAttachments = [ const taskAttachments = [
{ id: 201, content: { other: "manual.pdf" }, download_link: "https://cdn.example.test/manual.pdf" }, { id: 201, content: { other: "manual.pdf" }, download_link: "https://cdn.example.test/manual.pdf" },
+114
View File
@@ -23,6 +23,20 @@ vi.mock("@/components/pagination/departmentTabs.vue", () => ({
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
locations: { locations: {
location: mocks.locationRef, location: mocks.locationRef,
normalizeCoordinatePair: (coordinates, { allowZeroPair = true } = {}) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
},
getDistance: mocks.getDistance, getDistance: mocks.getDistance,
}, },
})); }));
@@ -217,6 +231,106 @@ describe("useWashDepartments", () => {
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false); expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
}); });
it("treats non-finite browser coordinates as missing instead of distance-selecting a department", async () => {
mocks.locationRef.value = {
coords: {
latitude: Number.POSITIVE_INFINITY,
longitude: Number.POSITIVE_INFINITY,
},
};
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Fallback",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: true,
},
{
id: 2,
name: "Other",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: true }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(mocks.getDistance).not.toHaveBeenCalled();
expect(departments.nearestDepartment.value).toMatchObject({ id: 1, name: "Fallback", distance: null });
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
});
it("skips default zero department coordinates when selecting by GPS", async () => {
mocks.getDistance.mockImplementation((_from, to) => {
if (to.latitude === 55.7) {
return 12;
}
return 1;
});
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Default coordinate row",
address: "A",
latitude: 0,
longitude: 0,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: true,
},
{
id: 2,
name: "Real coordinate row",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: true }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(mocks.getDistance.mock.calls.every(([, to]) => to.latitude === 55.7 && to.longitude === 12.6)).toBe(true);
expect(departments.nearestDepartment.value).toMatchObject({
id: 2,
name: "Real coordinate row",
distance: 12,
});
expect(departments.departmentSelectionStrategy.value).toBe("distance");
});
it("pushes departments with default zero coordinates after valid coordinates when sorting by distance", async () => {
mocks.getDistance.mockImplementation((_from, to) => {
if (to.latitude === 55.7) {
return 12;
}
if (to.latitude === 55.8) {
return 8;
}
return 1;
});
const departments = mountDepartments({ includeLanes: true });
const unsortedDepartments = [
{ id: 1, name: "Default coordinate row", latitude: 0, longitude: 0 },
{ id: 2, name: "Farther valid row", latitude: 55.7, longitude: 12.6 },
{ id: 3, name: "Nearer valid row", latitude: 55.8, longitude: 12.7 },
];
expect(departments.orderDepartmentsByDistance(unsortedDepartments).map((department) => department.id)).toEqual([
3, 2, 1,
]);
});
it("clears the selected department for empty department responses", async () => { it("clears the selected department for empty department responses", async () => {
mocks.getDepartmentsGuest.mockResolvedValueOnce([ mocks.getDepartmentsGuest.mockResolvedValueOnce([
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true }, { id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },