From d7ff64bf4e5eff2a2b7dd9afdb035e3b1ebe729e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 21 Apr 2026 10:24:35 +0200 Subject: [PATCH] Add encoding repair utilities and tests: - Introduced `repairText` and `containsSuspiciousEncoding` utilities for handling corrupted text encodings. - Added unit tests (`encoding-repair.spec.js`) to validate encoding repair and encoding marker detection. - Integrated encoding check into unit test workflows (`text:check-encoding` and `text:fix-encoding` scripts). - Replaced corrupted strings across multiple components and locales with proper UTF-8 encodings. --- package.json | 4 +- scripts/text-encoding.mjs | 314 ++++++++ .../department/pos/PosOrderItemsCurrent.vue | 11 + .../pos/order/POSOrderCustomerWishes.vue | 15 + .../steps/mobile/PosDepartmentStepMobile1.vue | 241 +++--- .../steps/mobile/PosDepartmentStepMobile2.vue | 732 ++++++++++-------- .../PosDepartmentStepMobile2LastOrder.vue | 62 +- .../PosDepartmentStepMobile2Product.vue | 44 +- .../PosDepartmentStepMobileAttachments.vue | 252 ++++-- .../PosDepartmentStepMobileButtonClearAll.vue | 72 +- .../PosDepartmentStepMobileButtonNextStep.vue | 220 ++++-- ...epartmentStepMobilePopupSelectCustomer.vue | 73 +- .../PosDepartmentStep1MobileManualInput.vue | 198 ++--- ...epartmentStep1MobileTransactionHistory.vue | 4 + .../department/pos/SelectVehicleFormPOS.vue | 74 +- .../items/NavigationMenuItemsAdmin.vue | 42 +- .../navigation/items/adminDraftCount.js | 33 + .../request/LoadButtonWhileAwait.vue | 54 +- .../economic/exportOrderToDraftButton.vue | 14 +- .../economic/exportOrderToInvoiceButton.vue | 21 +- .../exportOrderToInvoiceStripeButton.vue | 60 +- .../page/headers/DesktopNavigationBuefy.vue | 53 +- .../generic/graphics/GenericButton.vue | 41 +- src/i18n/locales/da.json | 7 +- src/i18n/locales/de.json | 265 +++---- src/i18n/locales/en.json | 9 +- src/i18n/locales/no.json | 471 +++++------ src/i18n/locales/sv.json | 15 +- .../modules/Pos/DepartmentPosOrder.vue | 5 +- .../goals/components/GoalFormModal.vue | 245 +++--- tests/e2e/admin-department-visibility.spec.ts | 65 ++ tests/e2e/admin-pos-orders.spec.ts | 46 ++ tests/e2e/economic-queue-workflow.spec.js | 22 + tests/e2e/pos-flow.spec.js | 32 +- tests/e2e/pos-mobile-order-flow.spec.js | 126 ++- tests/e2e/support/mobilePos.js | 4 + tests/e2e/support/network.js | 100 +++ tests/unit/admin-draft-count.spec.js | 76 ++ ...tment-daily-report-object.behavior.spec.js | 6 +- ...artment-daily-report-page.behavior.spec.js | 4 +- tests/unit/encoding-repair.spec.js | 41 + 41 files changed, 2890 insertions(+), 1283 deletions(-) create mode 100644 scripts/text-encoding.mjs create mode 100644 src/components/models/navigation/items/adminDraftCount.js create mode 100644 tests/unit/admin-draft-count.spec.js create mode 100644 tests/unit/encoding-repair.spec.js diff --git a/package.json b/package.json index 896c97c9..b53a0c20 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs", "preview": "vite preview", "preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173", - "test:unit": "node scripts/run-vitest-unit-batches.mjs", + "text:fix-encoding": "node scripts/text-encoding.mjs fix", + "text:check-encoding": "node scripts/text-encoding.mjs check", + "test:unit": "npm run text:check-encoding && node scripts/run-vitest-unit-batches.mjs", "test:unit:single": "vitest run", "test:e2e": "playwright test", "test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop", diff --git a/scripts/text-encoding.mjs b/scripts/text-encoding.mjs new file mode 100644 index 00000000..a1137d71 --- /dev/null +++ b/scripts/text-encoding.mjs @@ -0,0 +1,314 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const SCAN_ROOTS = ["src", "tests"]; +const SCAN_EXTENSIONS = new Set([".vue", ".js", ".ts", ".json"]); +const CP1252_EXTRA_BYTE_BY_CHAR = new Map([ + ["€", 0x80], + ["‚", 0x82], + ["ƒ", 0x83], + ["„", 0x84], + ["…", 0x85], + ["†", 0x86], + ["‡", 0x87], + ["ˆ", 0x88], + ["‰", 0x89], + ["Š", 0x8a], + ["‹", 0x8b], + ["Œ", 0x8c], + ["Ž", 0x8e], + ["‘", 0x91], + ["’", 0x92], + ["“", 0x93], + ["”", 0x94], + ["•", 0x95], + ["–", 0x96], + ["—", 0x97], + ["˜", 0x98], + ["™", 0x99], + ["š", 0x9a], + ["›", 0x9b], + ["œ", 0x9c], + ["ž", 0x9e], + ["Ÿ", 0x9f], +]); +const SUSPICIOUS_PATTERNS = [ + /ÃÂ/g, + /Ã./g, + /Â(?=\S)/g, + /â€(?:[™œžŸ"“”‘’•–—…])/g, + /’/g, + /“/g, + /”/g, + /–/g, + /—/g, + /…/g, + /�/g, +]; +const REGIONAL_CHAR_REGEX = /[æøåÆØÅäöÄÖüÜßéÉèÈáÁàÀóÓúÚíÍñÑçÇ]/g; +const LATIN_EXTENDED_CHAR_REGEX = /[\u00C0-\u024F]/g; +const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g; +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +function countMatches(text, regex) { + const matches = text.match(regex); + return matches ? matches.length : 0; +} + +export function countSuspiciousMarkers(text) { + return SUSPICIOUS_PATTERNS.reduce((count, pattern) => count + countMatches(text, pattern), 0); +} + +export function countRegionalCharacters(text) { + return countMatches(text, REGIONAL_CHAR_REGEX); +} + +function countLatinExtendedCharacters(text) { + return countMatches(text, LATIN_EXTENDED_CHAR_REGEX); +} + +function countControlCharacters(text) { + return countMatches(text, CONTROL_CHAR_REGEX); +} + +function encodeLatin1(text) { + const bytes = []; + + for (const character of text) { + const codePoint = character.codePointAt(0); + if (codePoint > 0xff) { + return null; + } + bytes.push(codePoint); + } + + return Uint8Array.from(bytes); +} + +function encodeWindows1252(text) { + const bytes = []; + + for (const character of text) { + if (CP1252_EXTRA_BYTE_BY_CHAR.has(character)) { + bytes.push(CP1252_EXTRA_BYTE_BY_CHAR.get(character)); + continue; + } + + const codePoint = character.codePointAt(0); + if (codePoint > 0xff) { + return null; + } + bytes.push(codePoint); + } + + return Uint8Array.from(bytes); +} + +function decodeUtf8(bytes) { + try { + return utf8Decoder.decode(bytes); + } catch { + return null; + } +} + +function buildRepairCandidates(text) { + const candidateSet = new Set(); + const encoders = [encodeLatin1, encodeWindows1252]; + + for (const encoder of encoders) { + const encoded = encoder(text); + if (!encoded) { + continue; + } + + const decoded = decodeUtf8(encoded); + if (decoded && decoded !== text) { + candidateSet.add(decoded); + } + } + + return [...candidateSet]; +} + +function isRepairImprovement(before, after) { + if (!after || after === before) { + return false; + } + + const beforeSuspicious = countSuspiciousMarkers(before); + const afterSuspicious = countSuspiciousMarkers(after); + const beforeRegional = countRegionalCharacters(before); + const afterRegional = countRegionalCharacters(after); + const beforeLatinExtended = countLatinExtendedCharacters(before); + const afterLatinExtended = countLatinExtendedCharacters(after); + const beforeControls = countControlCharacters(before); + const afterControls = countControlCharacters(after); + + if (afterControls > beforeControls) { + return false; + } + + if (afterSuspicious < beforeSuspicious) { + return afterRegional >= beforeRegional || afterLatinExtended >= beforeLatinExtended || afterSuspicious === 0; + } + + return ( + afterSuspicious === beforeSuspicious && (afterRegional > beforeRegional || afterLatinExtended > beforeLatinExtended) + ); +} + +function scoreCandidate(text) { + return ( + countSuspiciousMarkers(text) * 20 + + countControlCharacters(text) * 30 - + countLatinExtendedCharacters(text) * 2 - + countRegionalCharacters(text) * 3 + ); +} + +function repairOnce(text) { + const candidates = buildRepairCandidates(text).filter((candidate) => isRepairImprovement(text, candidate)); + if (candidates.length === 0) { + return text; + } + + return candidates.reduce((bestCandidate, candidate) => { + return scoreCandidate(candidate) < scoreCandidate(bestCandidate) ? candidate : bestCandidate; + }); +} + +function repairSegment(text) { + let current = text; + + for (let pass = 0; pass < 4; pass += 1) { + const repaired = repairOnce(current); + if (repaired === current) { + break; + } + current = repaired; + } + + return current; +} + +export function repairText(text) { + const wholeTextRepair = repairSegment(text); + const linewiseRepair = wholeTextRepair + .split(/(\r?\n)/) + .map((segment) => (segment.match(/\r?\n/) ? segment : repairSegment(segment))) + .join(""); + + return repairSegment(linewiseRepair); +} + +export function containsSuspiciousEncoding(text) { + return countSuspiciousMarkers(text) > 0; +} + +function getPreview(line) { + return line.replace(/\s+/g, " ").trim().slice(0, 180); +} + +export function findSuspiciousLines(text) { + return text + .split(/\r?\n/) + .map((line, index) => ({ + lineNumber: index + 1, + preview: getPreview(line), + suspiciousCount: countSuspiciousMarkers(line), + })) + .filter((entry) => entry.suspiciousCount > 0); +} + +async function collectScanFiles(rootDir) { + const files = []; + + async function walk(currentDir) { + let entries = []; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + await walk(entryPath); + continue; + } + + if (!entry.isFile() || !SCAN_EXTENSIONS.has(path.extname(entry.name))) { + continue; + } + + files.push(entryPath); + } + } + + for (const scanRoot of SCAN_ROOTS) { + await walk(path.join(rootDir, scanRoot)); + } + + return files.sort(); +} + +async function run(mode) { + const rootDir = process.cwd(); + const files = await collectScanFiles(rootDir); + const suspiciousReports = []; + let changedFiles = 0; + + for (const filePath of files) { + const originalText = await fs.readFile(filePath, "utf8"); + const repairedText = repairText(originalText); + + if (mode === "fix" && repairedText !== originalText) { + await fs.writeFile(filePath, repairedText, "utf8"); + changedFiles += 1; + } + + const inspectedText = mode === "fix" ? repairedText : originalText; + const suspiciousLines = findSuspiciousLines(inspectedText); + if (suspiciousLines.length > 0) { + suspiciousReports.push({ + filePath: path.relative(rootDir, filePath), + lines: suspiciousLines.slice(0, 12), + }); + } + } + + if (mode === "fix") { + console.log(`Updated ${changedFiles} file(s).`); + } + + if (suspiciousReports.length > 0) { + console.error(`Detected suspicious encoding markers in ${suspiciousReports.length} file(s):`); + suspiciousReports.forEach((report) => { + console.error(`- ${report.filePath}`); + report.lines.forEach((line) => { + console.error(` ${line.lineNumber}: ${line.preview}`); + }); + }); + process.exitCode = 1; + return; + } + + console.log(`No suspicious encoding markers found across ${files.length} file(s).`); +} + +const isDirectRun = typeof process.argv[1] === "string" && pathToFileURL(process.argv[1]).href === import.meta.url; + +if (isDirectRun) { + const mode = process.argv[2] || "check"; + if (!["check", "fix"].includes(mode)) { + console.error("Usage: node scripts/text-encoding.mjs "); + process.exit(1); + } + + await run(mode); +} diff --git a/src/components/displays/department/pos/PosOrderItemsCurrent.vue b/src/components/displays/department/pos/PosOrderItemsCurrent.vue index bcd91d53..afb216c4 100644 --- a/src/components/displays/department/pos/PosOrderItemsCurrent.vue +++ b/src/components/displays/department/pos/PosOrderItemsCurrent.vue @@ -33,6 +33,7 @@ import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSetting import { customer_name, customerRequiresReferenceNumber, + fetchAttachments, customerUsesPONumbers, getOrderDetails, setOrderId, @@ -60,6 +61,14 @@ const panel_tabs = ref([ active: true, }, ]); + +const handleCustomerWishesFieldSaved = async (fieldKey) => { + if (fieldKey !== "safety_seal") { + return; + } + + await loadOrder(); +}; // Check if the size is large const isLarge = props.isLarge !== undefined ? props.isLarge : false; const isOrderDetailVariant = computed(() => props.variant === "order-detail"); @@ -237,6 +246,7 @@ const loadOrder = (candidateOrderId = activeOrderId.value ?? props.orderId) => { .then((orderDetails) => { syncRegistrationNumbers(orderDetails); loadedRegistrationNumbersForOrderId.value = normalizedOrderId; + return fetchAttachments(normalizedOrderId); }) .catch((error) => { console.error(error); @@ -902,6 +912,7 @@ const deleteOrderItem = async (orderItemId) => { v-bind:showSafetySeal="showSafetySealField" v-bind:referenceRequired="referenceRequired" v-bind:poRequired="poRequired" + v-bind:onFieldSaved="handleCustomerWishesFieldSaved" />
{ const inputId = `${testIdBase}-input`; const isEditing = ref(false); @@ -57,6 +62,11 @@ const createCustomerWishField = ({ await saveValue(value); return value; }, + onSaved: async (...args) => { + if (typeof onSaved === "function") { + await onSaved(...args); + } + }, }); const isEmpty = computed(() => isBlankPosMetadataValue(autosave.draft.value)); const hasValue = computed(() => !isEmpty.value); @@ -141,6 +151,11 @@ const safetySealField = createCustomerWishField({ warningStateWhenEmpty: null, warningIconClass: "fas fa-shield-alt", fillRow: true, + onSaved: async () => { + if (typeof props.onFieldSaved === "function") { + await props.onFieldSaved("safety_seal"); + } + }, }); const fields = computed(() => { diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue index 2eb25c87..9f0046d5 100644 --- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue +++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue @@ -1,19 +1,27 @@