import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { buildAndroidAppI18nCatalog, checkAndroidAppI18n, decodeAndroidResourceValue, escapeAndroidResourceValue, findUnusedAndroidResourceKeys, findUnlocalizedAndroidUiLiterals, renderAndroidResourceValue, selectDeterministicTranslation, selectExactArtifactTranslation, selectGeneratedTranslation, } from "../../scripts/android-app-i18n.ts"; import { NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts"; describe("Android app i18n resources", () => { it("keeps generated resources, runtime coverage, and every locale aligned", async () => { // Managed native_* rows are reconciled by the post-merge locale refresh // workflow (#111557); source PRs are validated with those rows pending. await expect(checkAndroidAppI18n({ tolerateManagedPending: true })).resolves.toBeUndefined(); const base = await readFile("apps/android/app/src/main/res/values/strings.xml", "utf8"); const wearBase = await readFile("apps/android/wear/src/main/res/values/strings.xml", "utf8"); expect(base).toContain('xmlns:tools="http://schemas.android.com/tools"'); expect(base).toMatch( /]*tools:ignore="Typos,TypographyDashes,TypographyEllipsis">/u, ); expect(wearBase).toContain('Current session'); }); it("routes compact token suffixes through generated resources", async () => { const inventory = JSON.parse(await readFile("apps/.i18n/native-source.json", "utf8")) as { entries: Array<{ kind: string; path: string; source: string }>; }; const sources = new Set(["${decimal(count / 1_000_000.0)}M", "${thousands}k"]); const entries = inventory.entries .filter( (entry) => entry.path.endsWith("/ui/chat/ChatTurnRecap.kt") && sources.has(entry.source), ) .map(({ kind, source }) => ({ kind, source })) .toSorted((left, right) => left.source.localeCompare(right.source)); expect(entries).toEqual([ { kind: "ui-call", source: "${decimal(count / 1_000_000.0)}M" }, { kind: "ui-call", source: "${thousands}k" }, ]); }); it("builds complete Wear resources for every native locale", async () => { const catalog = await buildAndroidAppI18nCatalog(); const wearResources = [...catalog.resources].filter( ([filePath]) => filePath.includes("/apps/android/wear/src/main/res/values-") && filePath.endsWith("/strings.xml"), ); const base = await readFile("apps/android/wear/src/main/res/values/strings.xml", "utf8"); const baseKeys = [...base.matchAll(/ match[1]) .toSorted(); const basePlaceholders = [...base.matchAll(/%\d+\$[a-z]/giu)] .map((match) => match[0]) .toSorted(); expect(wearResources).toHaveLength(NATIVE_I18N_LOCALES.length); for (const [, content] of wearResources) { const stringTags = [...content.matchAll(/]*>/gu)].map((match) => match[0]); const keys = stringTags .map((tag) => tag.match(/\bname="([^"]+)"/u)?.[1]) .filter((key): key is string => key !== undefined) .toSorted(); const placeholders = [...content.matchAll(/%\d+\$[a-z]/giu)] .map((match) => match[0]) .toSorted(); expect(keys).toEqual(baseKeys); expect(placeholders).toEqual(basePlaceholders); expect(content).not.toMatch(/(?:'|(?'); for (const tag of stringTags) { if (!tag.includes('translatable="false"')) { expect(tag.match(/\btools:ignore=/gu)).toHaveLength(1); expect(tag).toMatch(/\btools:ignore="[^"]*\bTypos\b[^"]*"/u); expect(tag).toMatch(/\btools:ignore="[^"]*\bTypographyDashes\b[^"]*"/u); expect(tag).toMatch(/\btools:ignore="[^"]*\bTypographyEllipsis\b[^"]*"/u); } } expect(content).toContain( 'name="open_thread" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis"', ); expect(content).toContain( 'name="show_new_messages" tools:ignore="MissingTranslation,Typos,TypographyDashes,TypographyEllipsis"', ); } }); it("builds complete third-party flavor resources for every native locale", async () => { const catalog = await buildAndroidAppI18nCatalog(); const base = await readFile( "apps/android/app/src/thirdParty/res/values/accessibility_strings.xml", "utf8", ); const resources = [...catalog.resources].filter( ([filePath]) => filePath.includes("/apps/android/app/src/thirdParty/res/values-") && filePath.endsWith("/accessibility_strings.xml"), ); expect(base).toContain('tools:ignore="MissingTranslation"'); expect(resources).toHaveLength(NATIVE_I18N_LOCALES.length); for (const [, content] of resources) { expect(content).toContain('name="accessibility_service_label"'); expect(content).toContain('name="accessibility_dev_activity_label"'); } }); it("preserves the existing Swedish app name", async () => { const strings = await readFile("apps/android/app/src/main/res/values-sv/strings.xml", "utf8"); expect(strings).toContain('OpenClaw-nod'); }); it("counts Kotlin and XML resource references", () => { expect( findUnusedAndroidResourceKeys( ["kotlin_only", "manifest_only", "values_only", "unused"], [ { path: "Example.kt", source: "R.string.kotlin_only" }, { path: "AndroidManifest.xml", source: 'android:label="@string/manifest_only" @string/values_only', }, ], ), ).toEqual(["unused"]); }); it("requires exact Android resource reference identifiers", () => { expect( findUnusedAndroidResourceKeys( ["native_status", "native_status_detail", "native_unused"], [{ path: "Example.kt", source: "R.string.native_status_detail" }], ), ).toEqual(["native_status", "native_unused"]); }); it("ignores Android resource references that only appear in comments", () => { expect( findUnusedAndroidResourceKeys( ["kotlin_comment", "block_comment", "xml_comment", "live"], [ { path: "Example.kt", source: ` // R.string.kotlin_comment /* R.string.block_comment */ val endpoint = "https://example.test" val marker = "/* not a comment */" R.string.live `, }, { path: "AndroidManifest.xml", source: "", }, ], ), ).toEqual(["kotlin_comment", "block_comment", "xml_comment"]); }); it("ignores Android resource references inside Kotlin strings", () => { expect( findUnusedAndroidResourceKeys( ["regular_string", "raw_string", "live"], [ { path: "Example.kt", source: ` val regular = "R.string.regular_string" val raw = """R.string.raw_string""" R.string.live `, }, ], ), ).toEqual(["regular_string", "raw_string"]); }); it("selects duplicate-source translations by frequency then stable text order", () => { expect(selectDeterministicTranslation("Source", ["Beta", "Alpha", "Beta"])).toBe("Beta"); expect(selectDeterministicTranslation("Source", ["Beta", "Alpha"])).toBe("Alpha"); }); it("prefers a translated candidate over repeated source fallbacks", () => { expect(selectDeterministicTranslation("Source", ["Source", "Translated", "Source"])).toBe( "Translated", ); expect(selectDeterministicTranslation("Source", ["Source", "Source"])).toBe("Source"); }); it("preserves a localized resource when translation memory retires its UI source", () => { expect(decodeAndroidResourceValue('"Sitzungen"')).toBe("Sitzungen"); expect(decodeAndroidResourceValue('"Sag \\"Hallo\\""')).toBe('Sag "Hallo"'); const existing = { source: "Sessions", translation: "Sitzungen" }; expect(selectGeneratedTranslation("Sessions", [], existing)).toBe("Sitzungen"); expect(selectGeneratedTranslation("Sessions", ["Sesiones"], existing)).toBe("Sesiones"); }); it("selects Wear translations by inventory ID instead of shared English source", () => { const artifacts = new Map([ ["native.android.phone", { source: "Connected", translated: "Phone translation" }], ["native.android.wear", { source: "Connected", translated: "Wear translation" }], ]); expect(selectExactArtifactTranslation("Connected", "native.android.wear", artifacts)).toBe( "Wear translation", ); expect(selectExactArtifactTranslation("Connected", "native.android.missing", artifacts)).toBe( "Connected", ); }); it("does not reuse a localized resource after its English source changes", () => { const existing = { source: "Sessions", translation: "Sitzungen" }; expect(selectGeneratedTranslation("Threads", [], existing)).toBe(""); }); it("preserves source argument indexes when a translation reorders interpolations", () => { expect( renderAndroidResourceValue( "$readyProviderCount of $providerCount providers ready", "$providerCount Anbieter, davon $readyProviderCount bereit", ), ).toBe("%2$s Anbieter, davon %1$s bereit"); }); it("formats nested Kotlin interpolations as single Android arguments", () => { expect( renderAndroidResourceValue( "${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens", "${device.tokens.size} Token, ${device.tokens.count { !it.revoked }} aktiv", ), ).toBe("%2$s Token, %1$s aktiv"); }); it("preserves Android resource placeholders outside Kotlin interpolation", () => { expect(escapeAndroidResourceValue("Previous %1$s")).toBe("Previous %1$s"); }); it("balances braces inside nested interpolation strings", () => { expect( renderAndroidResourceValue( '${if (connected) "{" else "}"} $count', '$count · ${if (connected) "{" else "}"}', ), ).toBe("%2$s · %1$s"); }); it("rejects repeated translation placeholders that do not match the source", () => { expect(() => renderAndroidResourceValue("$item then $item", "$item, $item und noch einmal $item"), ).toThrow("Android translation changed interpolation placeholders"); }); it("finds direct, typed, conditional, interpolated, Elvis, and accessibility literals", () => { const source = ` data class ConnectionState( val connected: Boolean, val statusText: String, ) data class SettingsToggleRow( val title: String, val subtitle: String, ) Text("Settings") Text(text = nativeStringResource("Connected")) ClawPrimaryButton(text = "Continue", onClick = {}) ClawStatusPill(text = "Working") SettingsMetric("Gateway", gatewayName) ConnectionState(connected = false, statusText = "Connecting to $host") ConnectionState(connected = true, statusText = nativeString("Connected")) SettingsToggleRow("Phone capability", "Share device data") SettingsToggleRow(nativeString("Localized capability"), nativeString("Localized detail")) Text(text = fileName ?: "Attachment") Modifier.clickable(onClickLabel = "Open detail", onClick = {}) Text(nativeString("First sentence. ") + "Second sentence.") val dynamic = Text(text = gateway.name) fun statusText(state: State): String = when (state) { State.Ready -> "Ready" State.Waiting -> nativeString("Waiting") } `; expect( findUnlocalizedAndroidUiLiterals( source, "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", ), ).toEqual([ expect.objectContaining({ source: "Settings" }), expect.objectContaining({ source: "Continue" }), expect.objectContaining({ source: "Working" }), expect.objectContaining({ source: "Gateway" }), expect.objectContaining({ source: "Connecting to $host" }), expect.objectContaining({ source: "Phone capability" }), expect.objectContaining({ source: "Share device data" }), expect.objectContaining({ source: "Attachment" }), expect.objectContaining({ source: "Open detail" }), expect.objectContaining({ source: "Second sentence." }), expect.objectContaining({ source: "Ready" }), ]); expect( findUnlocalizedAndroidUiLiterals( source, "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", ).map((finding) => finding.source), ).not.toEqual(expect.arrayContaining(["Connected", "Waiting"])); }); it("maps typed model fields across generic types and named argument omissions", () => { const source = ` data class GenericState>( val metadata: Map, val statusText: String, ) data class OptionalState( val statusText: String = "", val code: String, ) GenericState>(emptyMap(), "Generic ready") OptionalState(code = "Internal code") `; const findings = findUnlocalizedAndroidUiLiterals( source, "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", ).map((finding) => finding.source); expect(findings).toContain("Generic ready"); expect(findings).not.toContain("Internal code"); }); it("scans helpers with generic and lambda parameters", () => { const source = ` fun statusText(value: T, transform: (T) -> String): String = if (transform(value).isBlank()) "No status" else nativeString("Ready") `; const findings = findUnlocalizedAndroidUiLiterals( source, "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", ).map((finding) => finding.source); expect(findings).toContain("No status"); expect(findings).not.toContain("Ready"); }); it("finds raw strings in direct UI and presentation helpers", () => { const source = ` Text("""Direct raw copy""") fun diagnosticsReport(): String = """ Raw helper copy """.trimIndent() `; const findings = findUnlocalizedAndroidUiLiterals( source, "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", ).map((finding) => finding.source); expect(findings).toEqual( expect.arrayContaining(["Direct raw copy", expect.stringContaining("Raw helper copy")]), ); }); it("inventories command, attention, and overview model display literals", () => { const source = ` data class CommandItem( val key: String, val title: String, val subtitle: String, ) data class HomeAttentionRow( val title: String, val subtitle: String, val route: String, ) data class OverviewMetricCardSpec( val title: String, val value: String, val subtitle: String, ) CommandItem("chat", "Open Chat", "Start a conversation") CommandItem( key = "voice", title = nativeString("Start Voice"), subtitle = nativeString("Talk with OpenClaw"), ) HomeAttentionRow( title = "Gateway", subtitle = "Connect before chat, voice, and live status.", route = "gateway", ) OverviewMetricCardSpec( title = nativeString("Gateway"), value = if (connected) "Online" else nativeString("Offline"), subtitle = "All systems nominal", ) `; const findings = findUnlocalizedAndroidUiLiterals( source, "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", ).map((finding) => finding.source); expect(findings).toEqual( expect.arrayContaining([ "Open Chat", "Start a conversation", "Gateway", "Connect before chat, voice, and live status.", "Online", "All systems nominal", ]), ); expect(findings).not.toEqual( expect.arrayContaining([ "chat", "voice", "Start Voice", "Talk with OpenClaw", "Offline", "gateway", ]), ); }); it("requires exact String fields and scans multiline helper expressions", () => { const source = ` data class StringResource(val key: String) data class ResourceState(val statusText: StringResource) ResourceState(statusText = StringResource("resource_key")) fun errorText(failed: Boolean): String = if (failed) { "Failure" } else { nativeString("Ready") } fun helperText(value: String?): String = value ?: "Fallback" `; const findings = findUnlocalizedAndroidUiLiterals( source, "apps/android/app/src/main/java/ai/openclaw/app/ui/Example.kt", ).map((finding) => finding.source); expect(findings).toEqual(expect.arrayContaining(["Failure", "Fallback"])); expect(findings).not.toEqual(expect.arrayContaining(["resource_key", "Ready"])); }); it("ignores preview fixtures", () => { expect( findUnlocalizedAndroidUiLiterals( 'Text("Preview copy")', "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", ), ).toEqual([]); }); it("scans Wear presentation sources but ignores Wear screenshot fixtures", () => { const source = ` data class WearSession(val title: String) WearSession(title = "Current session") `; expect( findUnlocalizedAndroidUiLiterals( source, "apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt", ).map((finding) => finding.source), ).toContain("Current session"); expect( findUnlocalizedAndroidUiLiterals( source, "apps/android/wear/src/main/java/ai/openclaw/wear/WearScreenshotMode.kt", ), ).toEqual([]); }); it("scans flavor-specific activity surfaces", () => { expect( findUnlocalizedAndroidUiLiterals( 'Text("Developer surface")', "apps/android/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt", ).map((finding) => finding.source), ).toEqual(["Developer surface"]); }); });