diff --git a/tests/browser/ocr-smoke.js b/tests/browser/ocr-smoke.js index 026f624..12c7e20 100644 --- a/tests/browser/ocr-smoke.js +++ b/tests/browser/ocr-smoke.js @@ -2,6 +2,42 @@ import { createOcrService } from "../../tools/shared/ocr.js"; const output = document.querySelector("#result"); const requestsBefore = performance.getEntriesByType("resource").map((entry) => entry.name); + +async function verifyCategoryAvailability() { + const loadCategory = async (pathname) => { + const response = await fetch(pathname); + if (!response.ok) throw new Error(`${pathname} returned HTTP ${response.status}`); + return new DOMParser().parseFromString(await response.text(), "text/html"); + }; + const findOcrCard = (documentObject) => documentObject + .querySelector('[data-i18n="tools.imageToText"]') + ?.closest("a.category-tool"); + const cardPath = (card, categoryPath) => new URL(card.getAttribute("href"), new URL(categoryPath, location.origin)).pathname; + + const [imageDocument, scanDocument] = await Promise.all([ + loadCategory("/tools/image/"), + loadCategory("/tools/scan/"), + ]); + const imageCard = findOcrCard(imageDocument); + const scanCard = findOcrCard(scanDocument); + if (!imageCard || !scanCard) throw new Error("Image to Text must be linked from both categories"); + if (!imageCard.querySelector(".status--available") || !scanCard.querySelector(".status--available")) { + throw new Error("Image to Text must be available in both categories"); + } + const imagePath = cardPath(imageCard, "/tools/image/"); + const scanPath = cardPath(scanCard, "/tools/scan/"); + if (imagePath !== "/tools/image/to-text/" || scanPath !== imagePath) { + throw new Error(`Category routes differ: ${imagePath}, ${scanPath}`); + } + const routeResponse = await fetch(scanPath); + if (!routeResponse.ok) throw new Error(`${scanPath} returned HTTP ${routeResponse.status}`); + const disabledControl = scanDocument.querySelector('[data-i18n="categories.scan.documentTitle"]')?.closest("article.category-tool"); + if (!disabledControl?.querySelector('[data-i18n="tools.comingSoon"]')) { + throw new Error("Document Scanner must remain disabled"); + } + return { imagePath, scanPath, disabledControl: true }; +} + const canvas = document.createElement("canvas"); canvas.width = 720; canvas.height = 220; @@ -15,12 +51,13 @@ context.fillText("HELLO", 120, 145); const image = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); const service = createOcrService(); try { + const categoryAvailability = await verifyCategoryAvailability(); const result = await service.recognizeImage(image, { language: "eng" }); const requests = performance.getEntriesByType("resource").map((entry) => entry.name).slice(requestsBefore.length); const externalRequests = requests.filter((value) => new URL(value).origin !== location.origin); if (!/HELLO/i.test(result.text)) throw new Error(`Unexpected OCR text: ${result.text}`); if (externalRequests.length) throw new Error(`External requests: ${externalRequests.join(", ")}`); - window.__ocrSmokeResult = { ok: true, text: result.text.trim(), requests, externalRequests }; + window.__ocrSmokeResult = { ok: true, text: result.text.trim(), requests, externalRequests, categoryAvailability }; output.textContent = `PASS: ${result.text.trim()}`; } catch (error) { window.__ocrSmokeResult = { ok: false, error: error.message, cause: error.cause?.message || null }; diff --git a/tests/category-availability.test.mjs b/tests/category-availability.test.mjs index f1ac444..e92be42 100644 --- a/tests/category-availability.test.mjs +++ b/tests/category-availability.test.mjs @@ -26,6 +26,11 @@ function assertRoutesExist(categoryPage, routes) { } } +function linkedCard(list, titleKey) { + return [...list.matchAll(/([\s\S]*?)<\/a>/g)] + .find((match) => match[2].includes(`data-i18n="${titleKey}"`)); +} + const imageHtml = read("tools/image/index.html"); const imageList = categoryList(imageHtml); const imageRoutes = ["./converter/", "./resize/", "./compress/", "./metadata/", "./to-text/"]; @@ -35,6 +40,29 @@ assert.equal((imageList.match(/status--available/g) || []).length, 5); assert.doesNotMatch(imageHtml, /<\/ul>\s*
  • /, "Image metadata card must remain inside the semantic list"); assertRoutesExist("tools/image/index.html", imageRoutes); +const imageOcrCard = linkedCard(imageList, "tools.imageToText"); +assert.ok(imageOcrCard, "Image category must link Image to Text"); +assert.match(imageOcrCard[2], /status--available/); +assert.doesNotMatch(imageOcrCard[2], /tools\.comingSoon|/g) || []).length, 1); +assert.equal((scanList.match(/tools\.comingSoon/g) || []).length, 1); +assert.match(scanList, /data-i18n="categories\.scan\.documentTitle"/); + const privacyHtml = read("tools/privacy/index.html"); const privacyList = categoryList(privacyHtml); const privacyRoutes = ["../image/metadata/", "../pdf/metadata/"]; @@ -50,13 +78,13 @@ const pdfList = categoryList(read("tools/pdf/index.html")); assert.equal(linkedRoutes(pdfList).length, 6, "Every PDF production card must remain linked"); assert.equal((pdfList.match(/status--available/g) || []).length, 6); -for (const category of ["pdf", "image"]) { +for (const category of ["pdf", "image", "scan"]) { const html = read(`tools/${category}/index.html`); assert.match(html, /data-i18n="categories\.localNote">Available tools process file contents locally in browser memory\./); assert.doesNotMatch(html, /Planned items are clearly marked/); } -for (const category of ["scan", "media"]) { +for (const category of ["media"]) { const plannedList = categoryList(read(`tools/${category}/index.html`)); assert.equal(linkedRoutes(plannedList).length, 0, `${category} must remain planned`); assert.equal((plannedList.match(/
    /g) || []).length, 2); @@ -68,6 +96,10 @@ for (const [language, catalog] of Object.entries(translations)) { assert.doesNotMatch(catalog.categories.localNote, /planned|geplant|prévu|planificad|準備中|준비 중/i, `${language} production category note mentions planned work`); assert.equal(typeof catalog.privacyHub.imageDescription, "string", `${language} image scope is missing`); assert.equal(typeof catalog.privacyHub.pdfDescription, "string", `${language} PDF scope is missing`); + assert.equal(typeof catalog.tools.imageToText, "string", `${language} Image to Text title is missing`); + assert.equal(typeof catalog.categories.image.toText, "string", `${language} Image to Text description is missing`); + assert.notEqual(catalog.tools.imageToText, "tools.imageToText", `${language} Image to Text title exposes a raw key`); + assert.notEqual(catalog.categories.image.toText, "categories.image.toText", `${language} Image to Text description exposes a raw key`); } console.log("Category availability, semantic list, production route, and planned-state checks passed."); diff --git a/tools/scan/index.html b/tools/scan/index.html index 3cb2acc..8e88532 100644 --- a/tools/scan/index.html +++ b/tools/scan/index.html @@ -5,20 +5,20 @@ - + - + - + Scan and OCR tools — Secure Tools -

    Tool category

    Scan & OCR tools

    Turn scans into useful documents while keeping source material on your device.

    • Coming soon

      Image to text

      Recognize text from an image locally.

    • Coming soon

      Document scanner

      Crop and clean photographed documents.

    This category is planned. No unavailable item is presented as working.

    +

    Tool category

    Scan & OCR tools

    Turn scans into useful documents while keeping source material on your device.

    Available tools process file contents locally in browser memory.