Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/159-ten-locales.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"mobile": minor
---

Clause-②: no
Metadata-protocol: none

The native screens (server picker, confirm step, loading and offline pages)
now ship in the same ten languages as the Console UI, under the same codes:
en, zh, ja, ko, de, fr, es, pt, ru, ar. The app picks the first of the phone's
preferred languages that it supports, with the Console's matching rule (a
region tag falls back to its base language: `pt-BR` → `pt`, `zh-Hans-CN` →
`zh`; Traditional Chinese → `zh`, as in the Console), else English. A
phone-language change is picked up when the app next comes to the foreground.
Under Arabic the native screens lay out right-to-left from the first render.
There is no in-app language picker: the phone setting decides, and a language
stored by an earlier in-app picker is no longer read. The QR scanner's strings
and the iOS camera permission prompt are localized in all ten languages too
(Traditional Chinese phones get the Simplified Chinese prompt, matching the
app's own strings).
155 changes: 155 additions & 0 deletions __tests__/components/RtlLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Native screens lay out right-to-left under `ar`, as the Console does, and
* left-to-right under every other shell language. The direction is Yoga's
* `direction` style on each screen root, so it applies on first render and
* follows a language change without a restart.
*/
import React from "react";
import { act, render, screen } from "@testing-library/react-native";
import { StyleSheet } from "react-native";
// Types for the matchers jest.config registers through setupFilesAfterEnv.
import type {} from "@testing-library/react-native/extend-expect";
import i18n from "~/lib/i18n";
import { layoutDirection } from "~/lib/rtl";

jest.mock("expo-router", () => ({
useRouter: () => ({
dismissTo: jest.fn(),
push: jest.fn(),
replace: jest.fn(),
// Nothing behind the picker: with a saved server, "Back to <host>" shows.
canGoBack: () => false,
}),
useLocalSearchParams: () => ({}),
}));

jest.mock("react-native-safe-area-context", () => {
const { View } = require("react-native");
return { SafeAreaView: View };
});

import ServerConfigScreen from "~/app/server-config";
import { OfflineFallback } from "~/components/shell/OfflineFallback";
import { useServerStore } from "~/stores/server-store";

/** The direction on the outermost host view the screen renders. */
function rootDirection(): unknown {
const tree = screen.toJSON();
const root = Array.isArray(tree) ? tree[0] : tree;
return StyleSheet.flatten(root?.props.style)?.direction;
}

const offline = (
<OfflineFallback
failure={{ kind: "network" }}
serverUrl="https://erp.example.com"
onRetry={jest.fn()}
onChangeServer={jest.fn()}
/>
);

/**
* Cold-load cost, paid once outside any test's 5 s budget — the same warm-up as
* ServerConfigScreen.test.tsx: react-native's lazy component getters are
* required and transformed on the first render, which on a cold CI transform
* cache exceeded the per-test timeout.
*/
const COLD_LOAD_TIMEOUT_MS = 30_000;
beforeAll(() => {
render(<ServerConfigScreen />).unmount();
render(offline).unmount();
}, COLD_LOAD_TIMEOUT_MS);

afterEach(async () => {
useServerStore.setState({ serverUrl: null });
await act(async () => {
await i18n.changeLanguage("en");
});
});

describe("layoutDirection", () => {
it("is rtl for Arabic and ltr for the other nine shell languages", () => {
expect(layoutDirection("ar")).toBe("rtl");
for (const code of ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru"]) {
expect(layoutDirection(code)).toBe("ltr");
}
});
});

describe("server picker", () => {
it("renders left-to-right under en", () => {
render(<ServerConfigScreen />);
expect(rootDirection()).toBe("ltr");
});

it("renders right-to-left, in Arabic, under ar", async () => {
await act(async () => {
await i18n.changeLanguage("ar");
});
render(<ServerConfigScreen />);
expect(rootDirection()).toBe("rtl");
expect(screen.getByText("الاتصال بالخادم")).toBeTruthy();
});

it("flips direction live when the language changes while mounted", async () => {
render(<ServerConfigScreen />);
expect(rootDirection()).toBe("ltr");
await act(async () => {
await i18n.changeLanguage("ar");
});
expect(rootDirection()).toBe("rtl");
await act(async () => {
await i18n.changeLanguage("ja");
});
expect(rootDirection()).toBe("ltr");
expect(screen.getByText("サーバーに接続")).toBeTruthy();
});
});

describe("server picker controls under ar", () => {
it("keeps the scan button and the way-back button inside the right-to-left root, in Arabic", async () => {
useServerStore.setState({ serverUrl: "https://erp.example.com" });
await act(async () => {
await i18n.changeLanguage("ar");
});
render(<ServerConfigScreen />);

expect(rootDirection()).toBe("rtl");
const scan = screen.getByTestId("server-scan");
expect(scan.props.accessibilityLabel).toBe("مسح رمز QR");
expect(screen.getByTestId("server-back-to-saved")).toHaveTextContent(
"العودة إلى erp.example.com"
);
// Neither control, nor anything between it and the root, pins a
// physical left/right or its own direction, so the root's rtl reaches it.
for (const id of ["server-scan", "server-back-to-saved"]) {
let node = screen.getByTestId(id).parent;
while (node) {
const style = StyleSheet.flatten(node.props.style) ?? {};
if (style.direction !== undefined) {
expect({ id, direction: style.direction }).toEqual({ id, direction: "rtl" });
}
for (const key of ["left", "right", "marginLeft", "marginRight", "paddingLeft", "paddingRight"]) {
expect({ id, key, value: (style as Record<string, unknown>)[key] }).toEqual({
id,
key,
value: undefined,
});
}
node = node.parent;
}
}
});
});

describe("offline page", () => {
it("renders right-to-left under ar and left-to-right under en", async () => {
render(offline);
expect(rootDirection()).toBe("ltr");
await act(async () => {
await i18n.changeLanguage("ar");
});
expect(rootDirection()).toBe("rtl");
expect(screen.getByText("أنت غير متصل")).toBeTruthy();
});
});
Loading
Loading