diff --git a/tools/v2/individual/email-translator/docs/CORE_CONTRACT.md b/tools/v2/individual/email-translator/docs/CORE_CONTRACT.md new file mode 100644 index 00000000..bd01f277 --- /dev/null +++ b/tools/v2/individual/email-translator/docs/CORE_CONTRACT.md @@ -0,0 +1,72 @@ +# Email Translator Core Contract + +This document describes the folder-local Email Translator core engine. The +implementation is deterministic and offline: it uses a local phrasebook and word +maps for reviewable behavior, and it does not call live translation APIs, LLMs, +mailboxes, databases, wallets, Stellar services, or production systems. + +## Inputs + +`translateEmail(request, options)` accepts: + +- `id`: stable local request identifier. +- `sourceText`: required email body text. +- `sourceLanguage`: language code or `auto`. +- `targetLanguage`: required language code. + +Supported language codes are `en`, `es`, `fr`, `de`, and `pt`. The local mock +provider intentionally supports only a small set of pairs. Unsupported pairs +return a structured error instead of making a network call. + +`options.now` fixes the generated timestamp for deterministic tests. +`options.maxTextChars` clips large source text for local review. + +## Outputs + +Successful calls return: + +- `status: "ready"` +- `isLoading: false` +- `error: null` +- `result.provider: "local-deterministic-phrasebook"` +- `result.sourceLanguage` and `result.targetLanguage` +- `result.detectedLanguage` and `result.detectionConfidence` +- `result.translatedText` +- `result.segments` preserving paragraph separators for future UI rendering +- `result.untranslatedTerms` for reviewer follow-up +- `result.warnings` for clipping or preserved terms +- `result.metrics` with character counts, word counts, replacements, and + confidence +- `result.reviewRequired` when confidence is low or terms remain untranslated + +## Loading State + +`createEmailTranslatorLoadingState(message)` returns: + +- `status: "loading"` +- `isLoading: true` +- `error: null` +- `result: null` +- `message`: caller-visible progress text. + +## Error State + +Invalid requests do not throw. They return: + +- `status: "error"` +- `isLoading: false` +- `error.code: "email-translator-error"` +- `error.messages`: validation or translation contract messages. +- `result: null` +- `requestId`: local request id when available. + +The validator rejects empty source text, unsupported languages, overly large +input, and active markup such as script tags or `javascript:` URLs. + +## Local Review Command + +Run from the repository root: + +```bash +node --test tools/v2/individual/email-translator/tests/email-translator-core.test.mjs +``` diff --git a/tools/v2/individual/email-translator/fixtures/translation-core-fixtures.json b/tools/v2/individual/email-translator/fixtures/translation-core-fixtures.json new file mode 100644 index 00000000..4a358e3c --- /dev/null +++ b/tools/v2/individual/email-translator/fixtures/translation-core-fixtures.json @@ -0,0 +1,47 @@ +{ + "tool": "email-translator", + "runContext": { + "now": "2026-07-01T00:00:00.000Z", + "source": "synthetic-folder-local-fixture" + }, + "sourceRequests": [ + { + "id": "translation-en-es-001", + "sourceLanguage": "en", + "targetLanguage": "es", + "sourceText": "Hello team,\n\nPlease review the meeting notes and send the invoice tomorrow.\n\nThank you" + }, + { + "id": "translation-en-fr-002", + "sourceLanguage": "auto", + "targetLanguage": "fr", + "sourceText": "Good morning,\n\nPlease confirm the next steps for the project update." + }, + { + "id": "translation-es-en-003", + "sourceLanguage": "auto", + "targetLanguage": "en", + "sourceText": "Hola equipo,\n\nPor favor revise la factura y el plazo." + } + ], + "expectedOutcomes": [ + { + "id": "translation-en-es-001", + "sourceLanguage": "en", + "targetLanguage": "es", + "requiredText": ["Hola", "equipo", "factura"] + }, + { + "id": "translation-en-fr-002", + "sourceLanguage": "en", + "targetLanguage": "fr", + "requiredText": ["Bonjour", "Veuillez confirmer", "prochaines etapes"] + }, + { + "id": "translation-es-en-003", + "sourceLanguage": "es", + "targetLanguage": "en", + "requiredText": ["Hello", "Please review", "invoice", "deadline"] + } + ] +} diff --git a/tools/v2/individual/email-translator/index.mjs b/tools/v2/individual/email-translator/index.mjs new file mode 100644 index 00000000..aac19693 --- /dev/null +++ b/tools/v2/individual/email-translator/index.mjs @@ -0,0 +1,11 @@ +export { + SUPPORTED_LANGUAGES, + createEmailTranslatorErrorState, + createEmailTranslatorLoadingState, + detectLanguage, + emailTranslatorCore, + getSupportedLanguagePairs, + translateEmail, + translateEmailBatch, + validateTranslationRequest, +} from "./services/email-translator.service.mjs"; diff --git a/tools/v2/individual/email-translator/services/email-translator.service.mjs b/tools/v2/individual/email-translator/services/email-translator.service.mjs new file mode 100644 index 00000000..fe0eaa49 --- /dev/null +++ b/tools/v2/individual/email-translator/services/email-translator.service.mjs @@ -0,0 +1,665 @@ +const DEFAULT_OPTIONS = { + now: "2026-07-01T00:00:00.000Z", + maxTextChars: 6000, +}; + +const SUPPORTED_LANGUAGES = { + en: "English", + es: "Spanish", + fr: "French", + de: "German", + pt: "Portuguese", +}; + +const LANGUAGE_HINTS = { + en: ["the", "please", "thank", "meeting", "invoice", "deadline", "review", "hello"], + es: ["hola", "gracias", "por favor", "reunion", "factura", "plazo", "revision"], + fr: ["bonjour", "merci", "s'il vous plait", "reunion", "facture", "delai", "examen"], + de: ["hallo", "danke", "bitte", "besprechung", "rechnung", "frist", "prufung"], + pt: ["ola", "obrigado", "por favor", "reuniao", "fatura", "prazo", "revisao"], +}; + +const PHRASEBOOK = { + "en:es": [ + ["Hello", "Hola"], + ["Hi", "Hola"], + ["Good morning", "Buenos dias"], + ["Good afternoon", "Buenas tardes"], + ["Please review", "Por favor revise"], + ["Please confirm", "Por favor confirme"], + ["Thank you", "Gracias"], + ["Best regards", "Saludos cordiales"], + ["meeting notes", "notas de la reunion"], + ["invoice", "factura"], + ["deadline", "plazo"], + ["next steps", "proximos pasos"], + ["project update", "actualizacion del proyecto"], + ], + "en:fr": [ + ["Hello", "Bonjour"], + ["Hi", "Bonjour"], + ["Good morning", "Bonjour"], + ["Good afternoon", "Bon apres-midi"], + ["Please review", "Veuillez examiner"], + ["Please confirm", "Veuillez confirmer"], + ["Thank you", "Merci"], + ["Best regards", "Cordialement"], + ["meeting notes", "notes de reunion"], + ["invoice", "facture"], + ["deadline", "delai"], + ["next steps", "prochaines etapes"], + ["project update", "mise a jour du projet"], + ], + "en:de": [ + ["Hello", "Hallo"], + ["Hi", "Hallo"], + ["Good morning", "Guten Morgen"], + ["Good afternoon", "Guten Tag"], + ["Please review", "Bitte prufen"], + ["Please confirm", "Bitte bestatigen"], + ["Thank you", "Danke"], + ["Best regards", "Mit freundlichen Grussen"], + ["meeting notes", "Besprechungsnotizen"], + ["invoice", "Rechnung"], + ["deadline", "Frist"], + ["next steps", "nachste Schritte"], + ["project update", "Projektaktualisierung"], + ], + "es:en": [ + ["Hola", "Hello"], + ["Gracias", "Thank you"], + ["Por favor revise", "Please review"], + ["Por favor confirme", "Please confirm"], + ["factura", "invoice"], + ["plazo", "deadline"], + ["reunion", "meeting"], + ["proximos pasos", "next steps"], + ], + "fr:en": [ + ["Bonjour", "Hello"], + ["Merci", "Thank you"], + ["Veuillez examiner", "Please review"], + ["Veuillez confirmer", "Please confirm"], + ["facture", "invoice"], + ["delai", "deadline"], + ["reunion", "meeting"], + ["prochaines etapes", "next steps"], + ], + "de:en": [ + ["Hallo", "Hello"], + ["Danke", "Thank you"], + ["Bitte prufen", "Please review"], + ["Bitte bestatigen", "Please confirm"], + ["Rechnung", "invoice"], + ["Frist", "deadline"], + ["Besprechung", "meeting"], + ["nachste Schritte", "next steps"], + ], + "pt:en": [ + ["Ola", "Hello"], + ["Obrigado", "Thank you"], + ["Por favor revise", "Please review"], + ["Por favor confirme", "Please confirm"], + ["fatura", "invoice"], + ["prazo", "deadline"], + ["reuniao", "meeting"], + ["proximos passos", "next steps"], + ], +}; + +const WORD_MAPS = { + "en:es": { + approve: "aprobar", + attached: "adjunto", + copy: "copia", + draft: "borrador", + email: "correo", + file: "archivo", + hello: "hola", + invoice: "factura", + meeting: "reunion", + notes: "notas", + plan: "plan", + please: "por favor", + review: "revise", + send: "envie", + team: "equipo", + thanks: "gracias", + tomorrow: "manana", + }, + "en:fr": { + approve: "approuver", + attached: "joint", + copy: "copie", + draft: "brouillon", + email: "courriel", + file: "fichier", + hello: "bonjour", + invoice: "facture", + meeting: "reunion", + notes: "notes", + plan: "plan", + please: "veuillez", + review: "examiner", + send: "envoyer", + team: "equipe", + thanks: "merci", + tomorrow: "demain", + }, + "en:de": { + approve: "genehmigen", + attached: "angehangt", + copy: "kopie", + draft: "entwurf", + email: "email", + file: "datei", + hello: "hallo", + invoice: "rechnung", + meeting: "besprechung", + notes: "notizen", + plan: "plan", + please: "bitte", + review: "prufen", + send: "senden", + team: "team", + thanks: "danke", + tomorrow: "morgen", + }, + "es:en": { + aprobar: "approve", + archivo: "file", + borrador: "draft", + correo: "email", + equipo: "team", + factura: "invoice", + gracias: "thanks", + hola: "hello", + manana: "tomorrow", + notas: "notes", + plan: "plan", + reunion: "meeting", + revise: "review", + }, + "fr:en": { + approuver: "approve", + bonjour: "hello", + brouillon: "draft", + courriel: "email", + demain: "tomorrow", + equipe: "team", + examiner: "review", + facture: "invoice", + fichier: "file", + joint: "attached", + merci: "thanks", + notes: "notes", + plan: "plan", + reunion: "meeting", + }, + "de:en": { + angehangt: "attached", + besprechung: "meeting", + bitte: "please", + danke: "thanks", + datei: "file", + email: "email", + entwurf: "draft", + genehmigen: "approve", + hallo: "hello", + kopie: "copy", + morgen: "tomorrow", + notizen: "notes", + plan: "plan", + prufen: "review", + rechnung: "invoice", + senden: "send", + team: "team", + }, + "pt:en": { + arquivo: "file", + email: "email", + equipe: "team", + fatura: "invoice", + notas: "notes", + obrigado: "thanks", + ola: "hello", + plano: "plan", + prazo: "deadline", + reuniao: "meeting", + revisar: "review", + }, +}; + +function normalizeLanguageCode(value) { + const code = String(value ?? "") + .trim() + .toLowerCase(); + if (!code || code === "auto") { + return "auto"; + } + return code.slice(0, 2); +} + +function normalizeText(value) { + return String(value ?? "") + .replace(/\r\n/g, "\n") + .replace(/[ \t]+/g, " ") + .trim(); +} + +function clipText(value, limit) { + const text = normalizeText(value); + if (text.length <= limit) { + return { + text, + clipped: false, + }; + } + + return { + text: text.slice(0, limit).trimEnd(), + clipped: true, + }; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function countWords(value) { + const matches = String(value).match(/[A-Za-z0-9']+/g); + return matches ? matches.length : 0; +} + +function splitSegments(text) { + return text.split(/(\n{2,})/).map((part, index) => ({ + id: `segment-${index + 1}`, + kind: /^\n+$/.test(part) ? "separator" : "text", + sourceText: part, + })); +} + +function preserveCase(source, translated) { + if (!source) { + return translated; + } + + if (source === source.toUpperCase()) { + return translated.toUpperCase(); + } + + if (source.charAt(0) === source.charAt(0).toUpperCase()) { + return translated.charAt(0).toUpperCase() + translated.slice(1); + } + + return translated; +} + +function replacePhrases(text, phrasePairs) { + let translated = text; + let replacements = 0; + + for (const [source, target] of phrasePairs) { + const pattern = new RegExp(`\\b${escapeRegExp(source)}\\b`, "gi"); + translated = translated.replace(pattern, (match) => { + replacements += 1; + return preserveCase(match, target); + }); + } + + return { + text: translated, + replacements, + }; +} + +function replaceWords(text, wordMap) { + const untranslatedTerms = new Set(); + let replacements = 0; + + const translated = text.replace(/\b[A-Za-z']+\b/g, (word) => { + const normalized = word.toLowerCase(); + const translatedWord = wordMap[normalized]; + + if (!translatedWord) { + if (word.length > 3) { + untranslatedTerms.add(normalized); + } + return word; + } + + replacements += 1; + return preserveCase(word, translatedWord); + }); + + return { + text: translated, + replacements, + untranslatedTerms: [...untranslatedTerms].slice(0, 12), + }; +} + +function scoreLanguage(text, languageCode) { + const lowered = text.toLowerCase(); + return LANGUAGE_HINTS[languageCode].reduce((score, hint) => { + return lowered.includes(hint) ? score + 1 : score; + }, 0); +} + +function detectLanguage(text) { + const normalized = normalizeText(text); + if (!normalized) { + return { + language: "unknown", + confidence: 0, + scores: {}, + }; + } + + const scores = Object.fromEntries( + Object.keys(SUPPORTED_LANGUAGES).map((languageCode) => [ + languageCode, + scoreLanguage(normalized, languageCode), + ]), + ); + const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); + const [language, score] = sorted[0]; + const total = Object.values(scores).reduce((sum, value) => sum + value, 0); + + if (score === 0 || total === 0) { + return { + language: "unknown", + confidence: 0.25, + scores, + }; + } + + return { + language, + confidence: Math.min(0.98, Math.max(0.5, score / total)), + scores, + }; +} + +function getPair(sourceLanguage, targetLanguage) { + return `${sourceLanguage}:${targetLanguage}`; +} + +function getSupportedLanguagePairs() { + return Object.keys(PHRASEBOOK).sort(); +} + +function validateTranslationRequest(request, options = {}) { + const maxTextChars = options.maxTextChars ?? DEFAULT_OPTIONS.maxTextChars; + const errors = []; + + if (!request || typeof request !== "object") { + return { + valid: false, + errors: [ + { + code: "invalid-request", + message: "Translation request must be an object.", + }, + ], + }; + } + + const sourceText = String(request.sourceText ?? request.text ?? "").trim(); + if (!sourceText) { + errors.push({ + code: "empty-source-text", + message: "sourceText is required.", + }); + } + + if (/ maxTextChars * 2) { + errors.push({ + code: "source-text-too-long", + message: "sourceText is too long for the local Email Translator contract.", + }); + } + + const targetLanguage = normalizeLanguageCode(request.targetLanguage); + if (targetLanguage === "auto" || !SUPPORTED_LANGUAGES[targetLanguage]) { + errors.push({ + code: "unsupported-target-language", + message: "A supported targetLanguage is required.", + }); + } + + const requestedSourceLanguage = normalizeLanguageCode(request.sourceLanguage); + if ( + requestedSourceLanguage !== "auto" && + requestedSourceLanguage !== "unknown" && + !SUPPORTED_LANGUAGES[requestedSourceLanguage] + ) { + errors.push({ + code: "unsupported-source-language", + message: "sourceLanguage must be auto or a supported language code.", + }); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +function createEmailTranslatorLoadingState(message = "Translating email") { + return { + status: "loading", + isLoading: true, + error: null, + result: null, + message, + }; +} + +function createEmailTranslatorErrorState(errors, requestId = null) { + const normalizedErrors = Array.isArray(errors) ? errors : [errors]; + + return { + status: "error", + isLoading: false, + error: { + code: "email-translator-error", + messages: normalizedErrors.map((error) => + typeof error === "string" ? error : error.message || "Unknown translation error", + ), + }, + result: null, + requestId, + }; +} + +function translateSegment(segment, phrasePairs, wordMap) { + if (segment.kind === "separator") { + return { + ...segment, + translatedText: segment.sourceText, + replacements: 0, + untranslatedTerms: [], + }; + } + + const phraseResult = replacePhrases(segment.sourceText, phrasePairs); + const wordResult = replaceWords(phraseResult.text, wordMap); + + return { + ...segment, + translatedText: wordResult.text, + replacements: phraseResult.replacements + wordResult.replacements, + untranslatedTerms: wordResult.untranslatedTerms, + }; +} + +function translateEmail(request, options = {}) { + const settings = { + ...DEFAULT_OPTIONS, + ...options, + }; + const validation = validateTranslationRequest(request, settings); + + if (!validation.valid) { + return createEmailTranslatorErrorState(validation.errors, request?.id ?? null); + } + + const sourceText = clipText(request.sourceText ?? request.text, settings.maxTextChars); + const targetLanguage = normalizeLanguageCode(request.targetLanguage); + const requestedSourceLanguage = normalizeLanguageCode(request.sourceLanguage); + const detection = detectLanguage(sourceText.text); + const sourceLanguage = + requestedSourceLanguage === "auto" || requestedSourceLanguage === "unknown" + ? detection.language + : requestedSourceLanguage; + + if (!SUPPORTED_LANGUAGES[sourceLanguage]) { + return createEmailTranslatorErrorState( + [ + { + code: "unknown-source-language", + message: "The source language could not be detected from the provided text.", + }, + ], + request.id ?? null, + ); + } + + if (sourceLanguage === targetLanguage) { + return createEmailTranslatorErrorState( + [ + { + code: "same-language", + message: "sourceLanguage and targetLanguage must be different.", + }, + ], + request.id ?? null, + ); + } + + const pair = getPair(sourceLanguage, targetLanguage); + const phrasePairs = PHRASEBOOK[pair]; + const wordMap = WORD_MAPS[pair]; + + if (!phrasePairs || !wordMap) { + return createEmailTranslatorErrorState( + [ + { + code: "unsupported-language-pair", + message: `No local mock provider exists for ${pair}.`, + }, + ], + request.id ?? null, + ); + } + + const sourceSegments = splitSegments(sourceText.text); + const translatedSegments = sourceSegments.map((segment) => + translateSegment(segment, phrasePairs, wordMap), + ); + const translatedText = translatedSegments.map((segment) => segment.translatedText).join(""); + const replacements = translatedSegments.reduce((sum, segment) => sum + segment.replacements, 0); + const untranslatedTerms = [ + ...new Set(translatedSegments.flatMap((segment) => segment.untranslatedTerms)), + ].slice(0, 12); + const sourceWordCount = countWords(sourceText.text); + const confidence = Math.max( + 0.35, + Math.min(0.98, (replacements / Math.max(1, sourceWordCount)) * 2 + detection.confidence / 3), + ); + const warnings = []; + + if (sourceText.clipped) { + warnings.push({ + code: "source-text-clipped", + message: "sourceText was clipped to the local review limit.", + }); + } + + if (untranslatedTerms.length > 0) { + warnings.push({ + code: "untranslated-terms", + message: "Some terms were preserved because the local phrasebook has no mapping.", + }); + } + + return { + status: "ready", + isLoading: false, + error: null, + result: { + id: String(request.id ?? "translation-request"), + generatedAt: settings.now, + provider: "local-deterministic-phrasebook", + sourceLanguage, + sourceLanguageName: SUPPORTED_LANGUAGES[sourceLanguage], + targetLanguage, + targetLanguageName: SUPPORTED_LANGUAGES[targetLanguage], + detectedLanguage: detection.language, + detectionConfidence: detection.confidence, + translatedText, + segments: translatedSegments.map((segment) => ({ + id: segment.id, + sourceText: segment.sourceText, + translatedText: segment.translatedText, + replacements: segment.replacements, + })), + untranslatedTerms, + warnings, + metrics: { + sourceCharacters: sourceText.text.length, + translatedCharacters: translatedText.length, + sourceWordCount, + translatedWordCount: countWords(translatedText), + replacements, + confidence, + }, + reviewRequired: confidence < 0.75 || untranslatedTerms.length > 0, + }, + }; +} + +function translateEmailBatch(requests, options = {}) { + const sourceRequests = Array.isArray(requests) ? requests : []; + const responses = sourceRequests.map((request) => translateEmail(request, options)); + const readyResponses = responses.filter((response) => response.status === "ready"); + + return { + status: responses.some((response) => response.status === "error") ? "needs-review" : "ready", + totalRequests: sourceRequests.length, + translated: readyResponses.length, + reviewRequired: readyResponses.filter((response) => response.result.reviewRequired).length, + errors: responses.filter((response) => response.status === "error").length, + responses, + }; +} + +export { + SUPPORTED_LANGUAGES, + createEmailTranslatorErrorState, + createEmailTranslatorLoadingState, + detectLanguage, + getSupportedLanguagePairs, + translateEmail, + translateEmailBatch, + validateTranslationRequest, +}; + +export const emailTranslatorCore = { + supportedLanguages: SUPPORTED_LANGUAGES, + createEmailTranslatorErrorState, + createEmailTranslatorLoadingState, + detectLanguage, + getSupportedLanguagePairs, + translateEmail, + translateEmailBatch, + validateTranslationRequest, +}; diff --git a/tools/v2/individual/email-translator/tests/email-translator-core.test.mjs b/tools/v2/individual/email-translator/tests/email-translator-core.test.mjs new file mode 100644 index 00000000..755cb026 --- /dev/null +++ b/tools/v2/individual/email-translator/tests/email-translator-core.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + createEmailTranslatorLoadingState, + detectLanguage, + getSupportedLanguagePairs, + translateEmail, + translateEmailBatch, + validateTranslationRequest, +} from "../index.mjs"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const fixturePath = join(currentDir, "..", "fixtures", "translation-core-fixtures.json"); +const supportedLanguages = new Set(["en", "es", "fr", "de", "pt"]); + +async function loadFixture() { + const raw = await readFile(fixturePath, "utf8"); + return JSON.parse(raw); +} + +test("translation fixture follows the folder-local request contract", async () => { + const fixture = await loadFixture(); + + assert.equal(fixture.tool, "email-translator"); + assert.match(fixture.runContext.now, /^\d{4}-\d{2}-\d{2}T/); + assert.ok(Array.isArray(fixture.sourceRequests), "sourceRequests must be an array"); + assert.ok(Array.isArray(fixture.expectedOutcomes), "expectedOutcomes must be an array"); + assert.equal(fixture.sourceRequests.length, fixture.expectedOutcomes.length); + + for (const request of fixture.sourceRequests) { + assert.ok(request.id, "request needs a stable id"); + assert.ok(request.sourceText.length > 0, `${request.id} sourceText is required`); + assert.ok(supportedLanguages.has(request.targetLanguage), `${request.id} target is supported`); + assert.equal(request.sourceText.includes("@"), false, `${request.id} must avoid real emails`); + } +}); + +test("translateEmail produces deterministic local translations", async () => { + const fixture = await loadFixture(); + + for (const expected of fixture.expectedOutcomes) { + const request = fixture.sourceRequests.find((item) => item.id === expected.id); + const response = translateEmail(request, { now: fixture.runContext.now }); + + assert.equal(response.status, "ready"); + assert.equal(response.isLoading, false); + assert.equal(response.error, null); + assert.equal(response.result.id, expected.id); + assert.equal(response.result.generatedAt, fixture.runContext.now); + assert.equal(response.result.sourceLanguage, expected.sourceLanguage); + assert.equal(response.result.targetLanguage, expected.targetLanguage); + assert.ok(response.result.translatedText.length > 0); + assert.ok(response.result.segments.length >= 1); + assert.ok(response.result.metrics.replacements >= expected.requiredText.length); + assert.ok(response.result.metrics.confidence > 0.35); + + for (const requiredText of expected.requiredText) { + assert.ok( + response.result.translatedText.includes(requiredText), + `${expected.id} should include ${requiredText}`, + ); + } + } +}); + +test("language detection and supported pair helpers are stable", () => { + assert.equal(detectLanguage("Hola, por favor revise la factura.").language, "es"); + assert.equal(detectLanguage("Bonjour, merci pour la facture.").language, "fr"); + assert.equal(detectLanguage("Hallo, bitte prufen Sie die Rechnung.").language, "de"); + + const pairs = getSupportedLanguagePairs(); + assert.ok(pairs.includes("en:es")); + assert.ok(pairs.includes("es:en")); + assert.ok(pairs.includes("pt:en")); +}); + +test("validation and UI state helpers expose loading and error contracts", () => { + const loading = createEmailTranslatorLoadingState(); + assert.equal(loading.status, "loading"); + assert.equal(loading.isLoading, true); + assert.equal(loading.result, null); + + const validation = validateTranslationRequest({ + id: "unsafe-translation", + sourceLanguage: "en", + targetLanguage: "es", + sourceText: "", + }); + assert.equal(validation.valid, false); + assert.ok(validation.errors.some((error) => error.code === "active-content")); + + const response = translateEmail({ + id: "empty-source", + sourceLanguage: "en", + targetLanguage: "es", + sourceText: "", + }); + assert.equal(response.status, "error"); + assert.equal(response.isLoading, false); + assert.equal(response.result, null); + assert.ok(response.error.messages.some((message) => message.includes("sourceText"))); +}); + +test("translateEmailBatch summarizes translated and errored requests", async () => { + const fixture = await loadFixture(); + const batch = translateEmailBatch( + [ + ...fixture.sourceRequests, + { + id: "missing-target", + sourceLanguage: "en", + sourceText: "Hello team", + }, + ], + { now: fixture.runContext.now }, + ); + + assert.equal(batch.status, "needs-review"); + assert.equal(batch.totalRequests, fixture.sourceRequests.length + 1); + assert.equal(batch.translated, fixture.sourceRequests.length); + assert.equal(batch.errors, 1); + assert.equal(batch.responses.length, fixture.sourceRequests.length + 1); +});