diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bb624ec..85ca2394 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,7 @@ Translations happen on translate.wordpress.org, in the project `meta/contributor - **Out:** every push to trunk that touches `src/` runs [`i18n-pot.yml`](.github/workflows/i18n-pot.yml), which regenerates the `.pot` and commits it to the `translations` branch. translate.wordpress.org imports its strings from . Nothing to do by hand; run `npm run i18n:pot` from the repository root to see the same file locally, at `contributor-toolkit.pot` in the repository root. - **Back:** in the version-bump pull request for a release, run `npm run i18n:download` from the repository root. It writes a catalog to `src/languages/` for every locale at least 80% translated, removes the catalog of any locale that fell below that, and prints a table; commit `src/languages/` and paste the table into the pull request. To include strings awaiting approval, run `npm run i18n:download -- --status=current,waiting` from the repository root; approved and waiting strings count toward the same 80% cutoff and appear in the exported catalogs. Use `--status=waiting` for waiting strings only, or include `fuzzy` in the comma-separated status list. Add `--locales=de,fr` to limit downloads and removals to those locale slugs, leaving other catalogs untouched. Explicitly specifying `--status` or `--locales` (`--locale` is accepted as an alias) bypasses the 80% cutoff; locale eligibility rules still apply. Unknown flags are rejected before any catalogs change. To choose another project, pass its full path, for example `--project=meta/contributor-toolkit` (the default). Until translate.wordpress.org has the project, the script says so and changes nothing, and the release ships in English. -The app picks the catalog for the operating system's language. Right-to-left locales such as Arabic, Hebrew and Persian pass the cut-off and still do not ship, and the table names them: the page already follows a catalog's text direction, but the app's styles are not right-to-left yet, so those locales wait until they are. Every other locale over the cut-off ships, even one no operating system language selects, such as `pirate`; the table names those too. +The app picks the catalog for the language chosen in its settings, or else for the operating system's language. Right-to-left locales such as Arabic, Hebrew and Persian pass the cut-off and still do not ship, and the table names them: the page already follows a catalog's text direction, but the app's styles are not right-to-left yet, so those locales wait until they are. Every other locale over the cut-off ships, even one no operating system language selects, such as `pirate`; the table names those too. ## The documentation site diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 62997215..77f7a7b2 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -4,6 +4,7 @@ The app's settings are in one dialog, opened from the cog at the bottom right of ## General +- **Language** — which language the app is shown in: your system's language, which is the default, or one of the languages the app has a translation for. A change applies after a relaunch, which the dialog offers; relaunching stops running servers and builds, as quitting does. Translations come from [translate.wordpress.org](https://translate.wordpress.org/projects/meta/contributor-toolkit/) and ship with the app once they are mostly complete, so the list grows from release to release. - **New sites go here** — the folder new sites are created in, each in a subfolder of its own. With one set, the [create-site dialog](./creating-a-site) starts on it; you can still pick another folder for one site without changing the setting. **Forget this folder** clears it, and the dialog goes back to asking each time. A folder that no longer exists is refused when you choose it. ## Account diff --git a/src/i18n.cjs b/src/i18n.cjs index 66143cfa..8031b196 100644 --- a/src/i18n.cjs +++ b/src/i18n.cjs @@ -140,4 +140,33 @@ async function resolveCatalog(locales, dir, log = () => {}) { return null; } -module.exports = { resolveCatalog, catalogCandidates, catalogsFromNames, slugTag }; +/** + * The languages the app can be asked to show (#559): English, which is the + * source, and one for each catalog among `names`, each named in itself + * ("Deutsch", "Português do Brasil") so that someone looking for their own + * finds it whatever the app is showing. Sorted by that name. + * + * A tag with no name of its own in the ICU data is shown as the tag. The + * slugs no operating system reports (`pirate`, `art-xemoji`) are not here, + * as they are not selectable from the OS list either. + * + * @param {string[]} names File names in the catalog directory. + * @return {Array<{tag: string, label: string}>} + */ +function languageChoices(names) { + const choices = [{ tag: 'en', label: 'English' }]; + for (const tag of catalogsFromNames(names).keys()) { + if (tag === 'en') continue; + let label = tag; + try { + // ICU writes some names as they are mid-sentence ("português"); + // a list's entries are written as its first word. + const named = new Intl.DisplayNames([tag], { type: 'language' }).of(tag) || tag; + label = named.charAt(0).toLocaleUpperCase(tag) + named.slice(1); + } catch {} + choices.push({ tag, label }); + } + return choices.sort((a, b) => a.label.localeCompare(b.label, 'en')); +} + +module.exports = { resolveCatalog, catalogCandidates, catalogsFromNames, slugTag, languageChoices }; diff --git a/src/languages/README.md b/src/languages/README.md index 50da18f2..87ff88b6 100644 --- a/src/languages/README.md +++ b/src/languages/README.md @@ -4,4 +4,4 @@ One JSON file per locale, written by `npm run i18n:download` from translate.word Each file is named by translate.wordpress.org's locale slug, which is lowercase: `de.json`, `pt-br.json`, `zh-tw.json`. The format is the `jed1x` export, and `src/i18n.cjs` reads its `locale_data.messages`. -The app walks the operating system's languages in order and loads the first one with a file, trying the exact tag, then its language and region, then for a tag with a script that script's usual region, then the bare language. So `es-MX` loads `es-mx.json`, `de-AT` loads `de.json`, and `zh-Hans-US` (macOS adds the user's region) loads `zh-cn.json`. English before another language keeps the English source strings. A slug and the tag an operating system reports are matched through `Intl.getCanonicalLocales`, which covers the three-letter slugs (`bel` is `be`) and Filipino (`tl` is `fil`); Valencian's `ca-val` is the one entry kept by hand, in `src/i18n.cjs`. A `--lang` switch replaces the operating system's list. A locale with no file keeps the English source strings. +The app walks the language chosen in its settings, where one is, and then the operating system's languages in order, and loads the first one with a file, trying the exact tag, then its language and region, then for a tag with a script that script's usual region, then the bare language. So `es-MX` loads `es-mx.json`, `de-AT` loads `de.json`, and `zh-Hans-US` (macOS adds the user's region) loads `zh-cn.json`. English before another language keeps the English source strings. A slug and the tag an operating system reports are matched through `Intl.getCanonicalLocales`, which covers the three-letter slugs (`bel` is `be`) and Filipino (`tl` is `fil`); Valencian's `ca-val` is the one entry kept by hand, in `src/i18n.cjs`. A `--lang` switch replaces both the setting and the operating system's list. A locale with no file keeps the English source strings. The settings offer English and one entry per file an operating system could report, each named in its own language. diff --git a/src/main.js b/src/main.js index f72885ef..3c7be6a5 100644 --- a/src/main.js +++ b/src/main.js @@ -84,7 +84,7 @@ const DEEP_LINK_CHANNEL = 'deep-link:ticket'; // here has to know which kind it is holding. const { workItemProvider } = require('./work-item.cjs'); const { LEGACY_SITE_ERROR } = require('./renderer/legacy-site.cjs'); -const { resolveCatalog } = require('./i18n.cjs'); +const { resolveCatalog, languageChoices } = require('./i18n.cjs'); const { isPseudoLocale } = require('./renderer/pseudo-locale.cjs'); const { applyLocale } = require('./renderer/locale-setup.cjs'); const { __, setLocaleData } = require('@wordpress/i18n'); @@ -666,30 +666,84 @@ ipcMain.handle('deep-link:ready', () => { return true; }); -// The language the window shows: the first of the OS's languages that has a -// catalog. `app.getLocale()` is only the fallback, since it is Chromium's UI -// language, folded into the 55 Chromium ships (Spanish (Mexico) arrives as -// es-419, Galician as English) (#584). A `--lang` switch replaces the OS list, -// read off the switch itself so `--lang=es-MX` is not folded either; it is how -// the journeys pick a locale, the pseudo-locale included. +// The language the window shows: the one chosen in the settings (#559), put +// before the OS's languages and not in their place, so that a chosen language +// whose catalog a release has since dropped falls back to the OS's and not +// to English; or else the first of the OS's languages that has a catalog. +// `app.getLocale()` is only the fallback, since it is Chromium's UI language, +// folded into the 55 Chromium ships (Spanish (Mexico) arrives as es-419, +// Galician as English) (#584). A `--lang` switch replaces the whole list, +// read off the switch itself so `--lang=es-MX` is not folded either; it is +// how the journeys pick a locale, the pseudo-locale included, and a flag +// typed at launch is a decision. // // Resolved once: main applies it at startup for its own strings (the menu, the // native dialogs, the sentences it sends), and the window gets the same reply, -// so the two cannot end up in different languages. +// so the two cannot end up in different languages. That is also why a change +// in the settings shows after a relaunch and not before. This is the first +// read of the store, before there is a window: a store that cannot be read +// is logged and counts as no choice, since the window has to open to say so. +const LANGUAGES_DIR = path.join(__dirname, 'languages'); let localeReplyPromise = null; function localeReply() { if (!localeReplyPromise) { localeReplyPromise = (async () => { - const requested = app.commandLine.getSwitchValue('lang'); + const flag = app.commandLine.getSwitchValue('lang'); + let chosen = null; + try { + chosen = readSettings((await getStore()).get('preferences')).locale; + } catch (e) { + logError('i18n', `the settings could not be read, so no language is chosen: ${String(e && e.message ? e.message : e)}`); + } + const requested = flag || chosen || ''; if (isPseudoLocale(requested)) return { locale: requested, data: null }; - const locales = requested ? [requested] : [...app.getPreferredSystemLanguages(), app.getLocale()]; - const found = await resolveCatalog(locales, path.join(__dirname, 'languages'), (message) => logEvent('i18n', message)); + const system = [...app.getPreferredSystemLanguages(), app.getLocale()]; + const locales = flag ? [flag] : [...(chosen ? [chosen] : []), ...system]; + const found = await resolveCatalog(locales, LANGUAGES_DIR, (message) => logEvent('i18n', message)); return found ? { locale: found.locale, data: found.messages } : { locale: 'en', data: null }; })(); } return localeReplyPromise; } +// The languages the settings offer: what the build ships, read once. +let languagesPromise = null; +function languages() { + if (!languagesPromise) { + languagesPromise = fs.promises.readdir(LANGUAGES_DIR) + .catch((e) => { + logEvent('i18n', `no catalogs listed: ${e.message}`); + return []; + }) + .then((names) => languageChoices(names)); + } + return languagesPromise; +} + +ipcMain.handle('i18n:languages', async () => ({ ok: true, languages: await languages() })); + +// What the settings dialog offers after the language is changed. `quit`, not +// `exit`: the quit sweep ends every child the app started, as it does on any +// quit, and the relaunch is a quit. The new instance gets this one's +// arguments less two: a `wpct://` address a cold start was given, which is +// not a second request for its ticket, and a `--lang` switch, which would +// outrank the language just chosen. +function relaunchArgs(argv) { + return argv.slice(1).filter((arg) => !pickDeepLinkArg([arg]) && !arg.startsWith('--lang=')); +} + +// On Linux the app is an AppImage, mounted while it runs at the path the +// process was started from and gone once it quits: the new instance is +// started from the image itself. +ipcMain.handle('app:relaunch', () => { + app.relaunch({ + args: relaunchArgs(process.argv), + ...(process.env.APPIMAGE ? { execPath: process.env.APPIMAGE } : {}) + }); + app.quit(); + return { ok: true }; +}); + ipcMain.handle('i18n:locale', () => localeReply()); // Without the lock, a link clicked while the app is running starts a second copy @@ -3443,7 +3497,12 @@ ipcMain.handle('settings:set', async (_e, key, value) => { // The disk is asked only about a full path for a setting there is: what // the pure check would ask, and nothing a key that is not a setting sends. const directory = Object.hasOwn(SETTINGS, key) && typeof value === 'string' && path.isAbsolute(value) && await isDirectory(value); - const accepted = acceptSetting(key, value, { isAbsolute: path.isAbsolute, isDirectory: () => directory }); + const known = await languages(); + const accepted = acceptSetting(key, value, { + isAbsolute: path.isAbsolute, + isDirectory: () => directory, + isLanguage: (tag) => known.some((language) => language.tag === tag) + }); if (!accepted.ok) return { ok: false, error: accepted.error }; await setPreference(key, accepted.value); const s = await getStore(); diff --git a/src/preload.js b/src/preload.js index 10223ba0..7bbb835a 100644 --- a/src/preload.js +++ b/src/preload.js @@ -104,6 +104,12 @@ contextBridge.exposeInMainWorld('api', { getSettings: () => ipcRenderer.invoke('settings:get') , setSetting: (key, value) => ipcRenderer.invoke('settings:set', key, value) +, + // The languages the app can show, for the settings; and the relaunch a + // change of language takes. + listLanguages: () => ipcRenderer.invoke('i18n:languages') +, + relaunch: () => ipcRenderer.invoke('app:relaunch') , // The menu's "Settings…" asked for the dialog. subscribeSettingsOpen: (handler) => { diff --git a/src/renderer/components/settings-dialog.jsx b/src/renderer/components/settings-dialog.jsx index 8aa88f80..c4e9b39b 100644 --- a/src/renderer/components/settings-dialog.jsx +++ b/src/renderer/components/settings-dialog.jsx @@ -1,17 +1,85 @@ -import { useEffect, useId, useState } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import { __ } from '@wordpress/i18n'; -import { Button, Dialog, InputControl, Notice, Stack, Tabs, Text } from '@wordpress/ui'; -import { githubAccountLine, newSiteLocationNote } from '../settings-view.cjs'; +import { Button, Dialog, InputControl, Notice, SelectControl, Stack, Tabs, Text } from '@wordpress/ui'; +import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, SYSTEM_LANGUAGE } from '../settings-view.cjs'; import { FolderField } from './folder-field.jsx'; // A notice here is read by its role, and is not also spoken: the dialog it // is in is open and being read. const SILENT = ''; +// The language the app shows, from the ones the build has a catalog for. +// Main applies a catalog as it starts, so a change is shown after a relaunch, +// which the control offers once what is set is no longer what the window is +// in. The relaunch is a quit: running servers and builds stop, as on any. +function LanguageControl({ settings, loaded, onChange }) { + const [languages, setLanguages] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + window.api.listLanguages() + .then((res) => { if (!cancelled && res?.ok) setLanguages(res.languages); }) + .catch(() => { if (!cancelled) setLanguages([]); }); + return () => { cancelled = true; }; + }, []); + + // The entries are drawn here, each with its tag as its value, and the + // list is also handed over for the names: handed the list alone, the + // control takes each entry as its own value and tells them apart by + // identity, which a value kept elsewhere cannot match. The entries are + // made from the language the window started in and not the one set, so + // that a choice does not change them: an entry taken away under the + // control while it is choosing is reported as a second choice, of none. + const locale = settings ? settings.locale : null; + const started = loaded ? loaded.locale : null; + const items = useMemo(() => languageItems(languages, started), [languages, started]); + + const choose = async (value) => { + const result = await onChange('locale', languageValue(value)); + setError(result?.ok ? '' : (result?.error || __('Could not keep that language.'))); + }; + const relaunch = () => { + window.api.relaunch().catch(() => setError(__('The app could not relaunch. Quit it and open it again.'))); + }; + + return ( + + + {items.map((item) => ( + + {item.label} + + ))} + + {error ? ( + + {error} + + ) : null} + {languageChanged(settings, loaded) ? ( + + {__('The app shows the new language once it has relaunched. Running servers and builds stop, as they do when the app quits.')} + + + + + ) : null} + + ); +} + // The folder new sites go in. The system's dialog chooses it, main checks // it, and what main then holds is what is shown: a folder it refused is // said under the field and nothing changes. -function GeneralTab({ settings, onChange }) { +function GeneralTab({ settings, loaded, onChange }) { const [error, setError] = useState(''); const location = settings ? settings.newSiteLocation : null; @@ -30,26 +98,32 @@ function GeneralTab({ settings, onChange }) { }; return ( - - }>{__('New sites')} - - {location ? ( -
- -
- ) : null} - {error ? ( - - {error} - - ) : null} + + + }>{__('Appearance')} + + + + }>{__('New sites')} + + {location ? ( +
+ +
+ ) : null} + {error ? ( + + {error} + + ) : null} +
); } @@ -167,7 +241,7 @@ function AccountTab({ wporg }) { // The tabs and what is on each. Inside the dialog's popup, which is there // while the dialog is open and not otherwise, so every opening starts on // General with nothing typed and not yet saved. -function SettingsPanels({ settings, onChange, wporg }) { +function SettingsPanels({ settings, loaded, onChange, wporg }) { const [tab, setTab] = useState('general'); return ( @@ -180,7 +254,7 @@ function SettingsPanels({ settings, onChange, wporg }) {
- + @@ -203,11 +277,12 @@ function SettingsPanels({ settings, onChange, wporg }) { * @param {Object} props * @param {boolean} props.open Whether the dialog is open. * @param {?Object} props.settings The settings, or null while they are read. + * @param {?Object} props.loaded The settings as the window first read them, for what takes a relaunch. * @param {Function} props.onChange Changes one setting; resolves to `{ ok, settings }` or `{ ok: false, error }`. * @param {Object} props.wporg The contributor's details and how to change them. * @param {Function} props.onClose Asked for by the close button, Escape, or a press outside. */ -export function SettingsDialog({ open, settings, onChange, wporg, onClose }) { +export function SettingsDialog({ open, settings, loaded, onChange, wporg, onClose }) { return ( { if (!next) onClose(); }}> @@ -215,7 +290,7 @@ export function SettingsDialog({ open, settings, onChange, wporg, onClose }) { {__('Settings')} - + ); diff --git a/src/renderer/hooks/use-settings.jsx b/src/renderer/hooks/use-settings.jsx index 84d6e604..60534d03 100644 --- a/src/renderer/hooks/use-settings.jsx +++ b/src/renderer/hooks/use-settings.jsx @@ -3,9 +3,12 @@ import { useCallback, useEffect, useState } from 'react'; // The app's settings (#559), held once for the window for the reason the // contributor's details are: they are about the person and the machine, // not about a checkout. `settings` is null until main has answered, so a -// dialog opened before then can say it is still reading them. +// dialog opened before then can say it is still reading them. `loaded` is +// what main answered first and does not change: a setting that takes a +// relaunch, the language, is one whose value now differs from it. export function useSettings() { const [settings, setSettings] = useState(null); + const [loaded, setLoaded] = useState(null); useEffect(() => { let cancelled = false; @@ -13,6 +16,7 @@ export function useSettings() { .then((res) => { if (cancelled || !res?.ok) return; setSettings(res.settings); + setLoaded(res.settings); }) // eslint-disable-next-line no-console -- reaches the log file, see the note in useDetectedEditors. .catch((err) => console.error('Could not read the settings:', err)); @@ -35,5 +39,5 @@ export function useSettings() { return result; }, []); - return { settings, change }; + return { settings, loaded, change }; } diff --git a/src/renderer/index.jsx b/src/renderer/index.jsx index d5126e8c..bd06b695 100644 --- a/src/renderer/index.jsx +++ b/src/renderer/index.jsx @@ -132,7 +132,7 @@ function App() { const wporg = useContributorProvenance(); // The app's settings (#559), and the dialog they are changed in. The menu // asks for the dialog too, over a subscription the whole window holds. - const { settings, change: changeSetting } = useSettings(); + const { settings, loaded: loadedSettings, change: changeSetting } = useSettings(); const [settingsOpen, setSettingsOpen] = useState(false); const openSettings = useCallback(() => setSettingsOpen(true), []); const closeSettings = useCallback(() => setSettingsOpen(false), []); @@ -720,7 +720,7 @@ function App() { )} - + {/* One toast region for the window (#253, #557). In the bottom corner, diff --git a/src/renderer/settings-view.cjs b/src/renderer/settings-view.cjs index e541e212..08001a5a 100644 --- a/src/renderer/settings-view.cjs +++ b/src/renderer/settings-view.cjs @@ -42,4 +42,48 @@ function newSiteLocationNote(settings) { return __('Not set: the create-site dialog asks each time.'); } -module.exports = { githubAccountLine, newSiteLocationNote }; +// What the language control's entry for no choice is called. +const SYSTEM_LANGUAGE = 'system'; + +/** + * The entries of the language control: the system's language first, then + * the languages the app has, and the one the window started in where it is + * none of them (a catalog that is gone since, or a locale typed into the + * store), so that the control shows what is set rather than nothing. + * + * @param {?Array<{tag: string, label: string}>} languages What main offers, or null while it has not answered. + * @param {?string} locale The language the window started in, or null for the system's. + * @return {Array<{value: string, label: string}>} + */ +function languageItems(languages, locale) { + const items = [{ value: SYSTEM_LANGUAGE, label: __('Your system’s language') }]; + for (const language of languages || []) items.push({ value: language.tag, label: language.label }); + if (locale && !items.some((item) => item.value === locale)) items.push({ value: locale, label: locale }); + return items; +} + +/** + * What a choice in the language control is kept as: the system's entry is + * kept as no choice. + * + * @param {string} choice The value of the entry chosen. + * @return {?string} + */ +function languageValue(choice) { + return choice === SYSTEM_LANGUAGE ? null : choice; +} + +/** + * Whether the language the window is in is no longer the one set, so that + * the dialog offers the relaunch that applies it. + * + * @param {?Object} settings What main holds now. + * @param {?Object} loaded What main held when the window read the settings. + * @return {boolean} + */ +function languageChanged(settings, loaded) { + if (!settings || !loaded) return false; + return (settings.locale || null) !== (loaded.locale || null); +} + +module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, SYSTEM_LANGUAGE }; diff --git a/src/settings-store.js b/src/settings-store.js index edbb387e..0ecf2524 100644 --- a/src/settings-store.js +++ b/src/settings-store.js @@ -8,11 +8,11 @@ // the harness replaces this file instead of trying to intercept the ESM loader. // // The import starts on first use rather than at require time. Deferring it costs -// nothing — no handler can run before a window exists — and it keeps a rejected -// promise nobody is awaiting yet out of module load, where the app has no way to -// report it. It also keeps the ESM loader from pulling in `electron` behind -// `Module._load`'s back, which is what lets that suite require main.js outside an -// Electron process at all. +// nothing — the first use is after the app is ready, when main reads the +// language chosen (#559) — and it keeps a rejected promise nobody is awaiting +// yet out of module load, where the app has no way to report it. It also keeps +// the ESM loader from pulling in `electron` behind `Module._load`'s back, which +// is what lets that suite require main.js outside an Electron process at all. let store; let storeReady = null; diff --git a/src/settings.cjs b/src/settings.cjs index 40d395de..17d792dd 100644 --- a/src/settings.cjs +++ b/src/settings.cjs @@ -25,6 +25,18 @@ const { __ } = require('@wordpress/i18n'); * for the contributor, not for a log. */ const SETTINGS = { + // The language the app shows: one it has a catalog for, English, or + // nothing for the operating system's. The window shows a change after a + // relaunch, since main applies the catalog as it starts. + locale: { + fallback: null, + accept(value, { isLanguage }) { + if (value === null || value === undefined || value === '') return { ok: true, value: null }; + if (typeof value !== 'string') return { ok: false, error: __('Choose a language.') }; + if (!isLanguage(value)) return { ok: false, error: __('The app has no translation for that language.') }; + return { ok: true, value }; + } + }, // The folder new sites are made in, each in a subfolder of its own. Unset, // the create-site dialog asks for one every time, as it did before. The // path is kept as the system's dialog gave it: a folder's name can end in @@ -56,8 +68,10 @@ const SETTINGS = { */ function readSettings(preferences = {}) { const stored = preferences && typeof preferences === 'object' ? preferences : {}; + const text = (key) => (typeof stored[key] === 'string' && stored[key] ? stored[key] : SETTINGS[key].fallback); return { - newSiteLocation: typeof stored.newSiteLocation === 'string' && stored.newSiteLocation ? stored.newSiteLocation : SETTINGS.newSiteLocation.fallback + locale: text('locale'), + newSiteLocation: text('newSiteLocation') }; } @@ -69,6 +83,7 @@ function readSettings(preferences = {}) { * @param {Object} deps * @param {Function} deps.isAbsolute Whether a path is a full one on this platform. * @param {Function} deps.isDirectory Whether a path is a folder on this machine. + * @param {Function} deps.isLanguage Whether a tag is one of the languages the app can show. * @return {{ok: true, value: *}|{ok: false, error: string}} The value to store, or why not. */ function acceptSetting(key, value, deps) { diff --git a/tests/e2e/helpers/app.cjs b/tests/e2e/helpers/app.cjs index d272e4de..2f3579f6 100644 --- a/tests/e2e/helpers/app.cjs +++ b/tests/e2e/helpers/app.cjs @@ -195,11 +195,14 @@ class Session { /** * Seeds settings.json and launches the app. * - * @param {Object} settings Initial electron-store contents. Defaults to a - * first-launch app with no sites. - * @param {Object} [options] - * @param {string} [options.lang] The locale to launch in, in place of en-US. It - * holds across restart(). + * @param {Object} settings Initial electron-store contents. Defaults to a + * first-launch app with no sites. + * @param {Object} [options] + * @param {string|false} [options.lang] The locale to launch in, in place of en-US, + * or `false` for no `--lang` at all: the + * app then picks its language as it does + * for a contributor, from the settings + * and the OS. It holds across restart(). * @return {Promise<{app: Object, page: Object}>} The Electron app and its first window. */ async start( settings = EMPTY_SETTINGS, { lang } = {} ) { @@ -239,7 +242,9 @@ class Session { // harness use. executablePath: require( 'electron' ), args: [ - ...ELECTRON_SWITCHES.map( ( s ) => ( this.lang && s.startsWith( '--lang=' ) ? `--lang=${ this.lang }` : s ) ), + ...ELECTRON_SWITCHES + .filter( ( s ) => ! ( this.lang === false && s.startsWith( '--lang=' ) ) ) + .map( ( s ) => ( this.lang && s.startsWith( '--lang=' ) ? `--lang=${ this.lang }` : s ) ), REPO_ROOT, ], // Watching a run is only ever a question of recording it. `_electron.launch` diff --git a/tests/e2e/journeys/settings.spec.js b/tests/e2e/journeys/settings.spec.js index ab49b04b..37893e4f 100644 --- a/tests/e2e/journeys/settings.spec.js +++ b/tests/e2e/journeys/settings.spec.js @@ -19,6 +19,7 @@ const path = require( 'node:path' ); const { test, expect } = require( '../helpers/app.cjs' ); const ui = require( '../helpers/ui.cjs' ); const { makeSite } = require( '../helpers/git-site.cjs' ); +const { pseudoLocalize } = require( '../../../src/renderer/pseudo-locale.cjs' ); const openFromMenu = ( app ) => app.evaluate( ( { Menu } ) => Menu.getApplicationMenu().getMenuItemById( 'settings' ).click() ); @@ -148,3 +149,60 @@ test( 'the Account tab remembers who the contributor is the way the mentor hando await expect( username ).toHaveValue( 'janedoe' ); await expect( event ).toHaveValue( '' ); } ); + +test( 'the language set in the settings is the one the app starts in, and a change offers the relaunch that applies it', async ( { session } ) => { + // The pseudo-locale, set as a contributor would set a language: in the + // store, with no --lang to override it. It needs no catalog, so the build + // under test need not ship one. + const site = await makeSite( session ); + const { app, page } = await session.start( { ...site.settings, preferences: { locale: 'en-XA' } }, { lang: false } ); + await expect( page.locator( 'html' ) ).toHaveAttribute( 'lang', 'en-XA', { timeout: 30_000 } ); + await app.evaluate( ( { ipcMain } ) => { + ipcMain.removeHandler( 'app:relaunch' ); + ipcMain.handle( 'app:relaunch', () => { global.__e2eRelaunches = ( global.__e2eRelaunches || 0 ) + 1; return { ok: true }; } ); + } ); + + // INVARIANT — the control shows the language that is set, even one the + // build has no catalog for, and offers no relaunch while nothing changed. + await page.getByRole( 'button', { name: pseudoLocalize( 'Settings' ), exact: true } ).click(); + const dialog = page.getByRole( 'dialog', { name: pseudoLocalize( 'Settings' ), exact: true } ); + const language = dialog.getByRole( 'combobox', { name: pseudoLocalize( 'Language' ), exact: true } ); + await expect( language ).toHaveText( 'en-XA' ); + await expect( dialog.getByRole( 'button', { name: pseudoLocalize( 'Relaunch now' ), exact: true } ) ).toHaveCount( 0 ); + + // INVARIANT — a language chosen is kept at once, and the dialog says the + // window is not in it yet and offers the relaunch, which asks main. + await language.click(); + await page.getByRole( 'option', { name: 'English', exact: true } ).click(); + await expect( language ).toHaveText( 'English' ); + await expect.poll( () => session.readSettings().preferences?.locale ).toBe( 'en' ); + await expect( dialog.getByRole( 'status' ) ).toContainText( pseudoLocalize( 'The app shows the new language once it has relaunched. Running servers and builds stop, as they do when the app quits.' ) ); + await dialog.getByRole( 'button', { name: pseudoLocalize( 'Relaunch now' ), exact: true } ).click(); + await expect.poll( () => app.evaluate( () => global.__e2eRelaunches ) ).toBe( 1 ); + + // INVARIANT — the system's language is a choice like the others: kept as + // none, and still not what the window started in, so the offer stays. + // The language the window started in is still listed, and choosing it + // is refused where it was chosen, since the build has no catalog for + // it: what is kept stays as it was. + await language.click(); + await page.getByRole( 'option', { name: pseudoLocalize( 'Your system’s language' ), exact: true } ).click(); + await expect.poll( () => session.readSettings().preferences?.locale ).toBe( null ); + await expect( dialog.getByRole( 'button', { name: pseudoLocalize( 'Relaunch now' ), exact: true } ) ).toBeVisible(); + await language.click(); + await page.getByRole( 'option', { name: 'en-XA', exact: true } ).click(); + await expect( dialog.getByRole( 'alert' ) ).toHaveText( pseudoLocalize( 'The app has no translation for that language.' ) ); + expect( session.readSettings().preferences.locale ).toBe( null ); + + // INVARIANT — started again, as the relaunch would start it, the app is + // in the language kept, and the control says so with no offer. + await language.click(); + await page.getByRole( 'option', { name: 'English', exact: true } ).click(); + await expect.poll( () => session.readSettings().preferences?.locale ).toBe( 'en' ); + const relaunched = await session.restart(); + await expect( relaunched.page.locator( 'html' ) ).toHaveAttribute( 'lang', 'en', { timeout: 30_000 } ); + await ui.settingsButton( relaunched.page ).click(); + const after = ui.settingsDialog( relaunched.page ); + await expect( after.getByRole( 'combobox', { name: 'Language', exact: true } ) ).toHaveText( 'English' ); + await expect( after.getByRole( 'button', { name: 'Relaunch now', exact: true } ) ).toHaveCount( 0 ); +} ); diff --git a/tests/e2e/packaged/smoke.spec.js b/tests/e2e/packaged/smoke.spec.js index 6982aa9e..2265d2e4 100644 --- a/tests/e2e/packaged/smoke.spec.js +++ b/tests/e2e/packaged/smoke.spec.js @@ -65,6 +65,7 @@ const EXPECTED_API_KEYS = [ 'leavePullRequest', 'listBranches', 'listEditors', + 'listLanguages', 'listTicketPatches', 'listTracAttachments', 'markSiteInitialized', @@ -80,6 +81,7 @@ const EXPECTED_API_KEYS = [ 'previewPatch', 'previewPullRequest', 'rebaseBranch', + 'relaunch', 'revealWpDebug', 'runNpmInstall', 'runNpmScript', diff --git a/tests/unit/i18n.test.cjs b/tests/unit/i18n.test.cjs index 8a8061ec..088d3249 100644 --- a/tests/unit/i18n.test.cjs +++ b/tests/unit/i18n.test.cjs @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { resolveCatalog, catalogCandidates, catalogsFromNames, slugTag } = require('../../src/i18n.cjs'); +const { resolveCatalog, catalogCandidates, catalogsFromNames, slugTag, languageChoices } = require('../../src/i18n.cjs'); const { isPseudoLocale, pseudoLocalize } = require('../../src/renderer/pseudo-locale.cjs'); const { createI18n } = require('@wordpress/i18n'); @@ -188,3 +188,19 @@ test('pseudoLocalize leaves placeholders and markup readable by code', () => { test('pseudoLocalize passes an empty string through', () => { assert.equal(pseudoLocalize(''), ''); }); + +test('the languages offered are English and each catalog, named in itself and sorted by that name', () => { + const choices = languageChoices(['pt-br.json', 'de.json', 'README.md', 'zh-cn.json']); + // The names are ICU's, which can differ a character between Node + // versions; what is pinned is each language's tag, that it is named and + // not merely tagged, written as a list's entry is, and the order. + assert.deepEqual(choices.map((choice) => choice.tag), ['de', 'en', 'pt-br', 'zh-cn']); + assert.deepEqual(choices.slice(0, 2), [{ tag: 'de', label: 'Deutsch' }, { tag: 'en', label: 'English' }]); + assert.match(choices[2].label, /^Portugu/); + assert.match(choices[3].label, /^中文/); +}); + +test('the languages offered leave out a slug no OS reports, and English is offered once', () => { + assert.deepEqual(languageChoices(['pirate.json', 'art-xemoji.json', 'en.json']), [{ tag: 'en', label: 'English' }]); + assert.deepEqual(languageChoices([]), [{ tag: 'en', label: 'English' }]); +}); diff --git a/tests/unit/ipc-wiring.test.cjs b/tests/unit/ipc-wiring.test.cjs index ba381f2b..61bb31c8 100644 --- a/tests/unit/ipc-wiring.test.cjs +++ b/tests/unit/ipc-wiring.test.cjs @@ -97,7 +97,9 @@ function createElectronStub({ ready = false } = {}) { showItemInFolder: [], showSaveDialog: [], showOpenDialog: [], - applicationMenu: [] + applicationMenu: [], + quit: [], + relaunch: [] }; // What `dialog` returns is per-test: git:save-patch branches on `canceled`. @@ -144,7 +146,8 @@ function createElectronStub({ ready = false } = {}) { if (!appEvents.has(event)) appEvents.set(event, []); appEvents.get(event).push(listener); }, - quit() {}, + quit() { calls.quit.push(true); }, + relaunch(options) { calls.relaunch.push(options); }, exit() {}, getPath: () => os.tmpdir(), getAppPath: () => path.join(SRC_DIR, '..'), @@ -1869,7 +1872,7 @@ test('settings:set asks the disk whether the folder is there', async (t) => { const settings = fakeSettingsStore(); const main = loadMain({ stubs: { ...silentLogging(), ...settings.stubs } }); - assert.deepEqual(await main.invoke('settings:set', 'newSiteLocation', folder), { ok: true, settings: { newSiteLocation: folder } }); + assert.deepEqual(await main.invoke('settings:set', 'newSiteLocation', folder), { ok: true, settings: { locale: null, newSiteLocation: folder } }); const gone = await main.invoke('settings:set', 'newSiteLocation', path.join(folder, 'gone')); assert.equal(gone.ok, false); assert.equal(settings.values.preferences.newSiteLocation, folder); @@ -1905,7 +1908,7 @@ async function menuBuilt(main) { } test('the menu\'s Settings… opens the dialog in the main window, listed first or not (#559)', async () => { - const main = loadMain({ ready: true, stubs: { ...silentLogging(), './i18n.cjs': { resolveCatalog: async () => null } } }); + const main = loadMain({ ready: true, stubs: { ...silentLogging(), ...fakeSettingsStore().stubs, './i18n.cjs': { resolveCatalog: async () => null } } }); const settings = await menuBuilt(main); assert.equal(main.windows.length, 1, 'the ready path opened the main window'); const [mainWindow] = main.windows; @@ -1926,7 +1929,7 @@ test('the menu\'s Settings… opens the dialog in the main window, listed first }); test('the menu\'s Settings… with the main window closed opens one and sends nothing into it (#559)', async () => { - const main = loadMain({ ready: true, stubs: { ...silentLogging(), './i18n.cjs': { resolveCatalog: async () => null } } }); + const main = loadMain({ ready: true, stubs: { ...silentLogging(), ...fakeSettingsStore().stubs, './i18n.cjs': { resolveCatalog: async () => null } } }); const settings = await menuBuilt(main); const [closed] = main.windows; closed.isDestroyed = () => true; @@ -6313,7 +6316,8 @@ test('a second instance with an address delivers it, without one it only shows t test('i18n:locale asks i18n.cjs for the catalog of the OS languages, then of the one Electron reports', async () => { const resolveCatalog = spy(async () => ({ locale: 'es-MX', messages: { 'No sites yet.': ['Aún no hay sitios.'] } })); - const main = loadMain({ stubs: { ...silentLogging(), './i18n.cjs': { resolveCatalog } } }); + // With no language set in the settings (#559), the OS's are asked. + const main = loadMain({ stubs: { ...silentLogging(), ...fakeSettingsStore().stubs, './i18n.cjs': { resolveCatalog } } }); // Chromium folds Spanish (Mexico) into es-419; the OS list does not (#584). main.electron.app.getPreferredSystemLanguages = () => ['es-MX', 'en-US']; main.electron.app.getLocale = () => 'es-419'; @@ -6339,6 +6343,96 @@ test('i18n:locale takes --lang in place of the OS languages, unfolded', async () assert.deepEqual(resolveCatalog.calls[0][0], ['es-MX']); }); +test('i18n:locale puts the language set in the settings before the OS languages, and --lang in place of both (#559)', async () => { + const resolveCatalog = spy(async () => null); + const main = loadMain({ + stubs: { ...silentLogging(), ...fakeSettingsStore({ preferences: { locale: 'de' } }).stubs, './i18n.cjs': { resolveCatalog, languageChoices: () => [] } } + }); + main.electron.app.getPreferredSystemLanguages = () => ['fr-FR']; + + assert.deepEqual(await main.invoke('i18n:locale'), { locale: 'en', data: null }); + // Before and not instead: a chosen language whose catalog a release has + // dropped falls back to the OS's language, not to English. + assert.deepEqual(resolveCatalog.calls[0][0], ['de', 'fr-FR', 'en-GB']); + + const flagged = loadMain({ + stubs: { ...silentLogging(), ...fakeSettingsStore({ preferences: { locale: 'de' } }).stubs, './i18n.cjs': { resolveCatalog, languageChoices: () => [] } } + }); + flagged.electron.app.commandLine.getSwitchValue = (name) => (name === 'lang' ? 'es-MX' : ''); + await flagged.invoke('i18n:locale'); + assert.deepEqual(resolveCatalog.calls[1][0], ['es-MX']); +}); + +test('i18n:locale counts a store it cannot read as no language chosen, and the app still opens (#559)', async () => { + const resolveCatalog = spy(async () => null); + const logError = spy(); + const main = loadMain({ + stubs: { + ...silentLogging(), + './logging': { ...silentLogging()['./logging'], logError }, + './settings-store': { getStore: async () => { throw new Error('settings.json is not JSON'); } }, + './i18n.cjs': { resolveCatalog, languageChoices: () => [] } + } + }); + main.electron.app.getPreferredSystemLanguages = () => ['fr-FR']; + + assert.deepEqual(await main.invoke('i18n:locale'), { locale: 'en', data: null }); + assert.deepEqual(resolveCatalog.calls[0][0], ['fr-FR', 'en-GB']); + assert.equal(logError.calls.length, 1); + assert.match(logError.calls[0][1], /settings\.json is not JSON/); +}); + +test('i18n:locale takes the pseudo-locale from the settings as from --lang (#559)', async () => { + const main = loadMain({ stubs: { ...silentLogging(), ...fakeSettingsStore({ preferences: { locale: 'en-XA' } }).stubs } }); + assert.deepEqual(await main.invoke('i18n:locale'), { locale: 'en-XA', data: null }); +}); + +test('i18n:languages lists what the catalog directory holds through i18n.cjs, once (#559)', async () => { + const languageChoices = spy(() => [{ tag: 'en', label: 'English' }]); + const main = loadMain({ stubs: { ...silentLogging(), './i18n.cjs': { resolveCatalog: async () => null, languageChoices } } }); + + assert.deepEqual(await main.invoke('i18n:languages'), { ok: true, languages: [{ tag: 'en', label: 'English' }] }); + await main.invoke('i18n:languages'); + assert.equal(languageChoices.calls.length, 1); + // The names of the directory the app ships its catalogs in. + assert.ok(languageChoices.calls[0][0].includes('README.md')); +}); + +test('settings:set keeps a language the build has, and refuses one it has not (#559)', async () => { + const settings = fakeSettingsStore(); + const main = loadMain({ + stubs: { ...silentLogging(), ...settings.stubs, './i18n.cjs': { resolveCatalog: async () => null, languageChoices: () => [{ tag: 'de', label: 'Deutsch' }, { tag: 'en', label: 'English' }] } } + }); + + assert.deepEqual(await main.invoke('settings:set', 'locale', 'de'), { ok: true, settings: { locale: 'de', newSiteLocation: null } }); + assert.equal((await main.invoke('settings:set', 'locale', 'fr')).ok, false); + assert.equal(settings.values.preferences.locale, 'de'); + assert.deepEqual(await main.invoke('settings:set', 'locale', null), { ok: true, settings: { locale: null, newSiteLocation: null } }); +}); + +test('app:relaunch relaunches through a quit, so the child sweep runs, without the launch\'s link or --lang (#559)', async (t) => { + const main = loadMain({ stubs: silentLogging() }); + const argv = process.argv; + process.argv = ['/app/electron', '--no-sandbox', 'wpct://ticket/62281', '--lang=en-XA', '--inspect']; + t.after(() => { process.argv = argv; }); + + assert.deepEqual(await main.invoke('app:relaunch'), { ok: true }); + // A cold start's address is not a second request for its ticket, and the + // switch would outrank the language just chosen. + assert.deepEqual(main.calls.relaunch, [{ args: ['--no-sandbox', '--inspect'] }]); + assert.deepEqual(main.calls.quit, [true]); +}); + +test('app:relaunch starts the new instance from the AppImage on Linux, where the mounted one is gone once this quits (#559)', async (t) => { + const main = loadMain({ stubs: silentLogging() }); + const had = process.env.APPIMAGE; + process.env.APPIMAGE = '/home/jane/Downloads/WordPress-Contributor-Toolkit.AppImage'; + t.after(() => { if (had === undefined) delete process.env.APPIMAGE; else process.env.APPIMAGE = had; }); + + await main.invoke('app:relaunch'); + assert.equal(main.calls.relaunch[0].execPath, '/home/jane/Downloads/WordPress-Contributor-Toolkit.AppImage'); +}); + test('i18n:locale takes the pseudo-locale from --lang, which Chromium does not report', async () => { const resolveCatalog = spy(async () => null); const main = loadMain({ stubs: { ...silentLogging(), './i18n.cjs': { resolveCatalog } } }); @@ -6358,7 +6452,7 @@ test('main applies the locale reply the window gets, once resolved, before it bu }); const main = loadMain({ ready: true, - stubs: { ...silentLogging(), './i18n.cjs': { resolveCatalog }, './renderer/locale-setup.cjs': { applyLocale } } + stubs: { ...silentLogging(), ...fakeSettingsStore().stubs, './i18n.cjs': { resolveCatalog }, './renderer/locale-setup.cjs': { applyLocale } } }); for (let i = 0; i < 50 && main.windows.length === 0; i++) await new Promise(setImmediate); @@ -6422,6 +6516,7 @@ const WIRED = new Set([ 'provenance:set-event', 'settings:get', 'settings:set', + 'i18n:languages', 'github:account', 'github:sign-in', 'github:open-pr' @@ -6450,7 +6545,8 @@ const NO_DELEGATION = new Map([ ['wp-debug:start', 'tails a file'], ['wp-debug:stop', 'stops a tail'], ['github:sign-out', 'clears the in-memory token; asserted through github:account above'], - ['github:sign-in-cancel', 'sets a flag the in-flight poll reads'] + ['github:sign-in-cancel', 'sets a flag the in-flight poll reads'], + ['app:relaunch', 'relaunches the app; asserted directly above'] ]); // There used to be a third list here, UNWIRED_INVARIANTS: the Playground and diff --git a/tests/unit/settings-view.test.cjs b/tests/unit/settings-view.test.cjs index bd493b0f..0070dec0 100644 --- a/tests/unit/settings-view.test.cjs +++ b/tests/unit/settings-view.test.cjs @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { githubAccountLine, newSiteLocationNote } = require('../../src/renderer/settings-view.cjs'); +const { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, SYSTEM_LANGUAGE } = require('../../src/renderer/settings-view.cjs'); test('the GitHub line says the account is still being read, and offers no sign-out, until it is', () => { assert.deepEqual(githubAccountLine(null), { text: 'Reading…', canSignOut: false }); @@ -27,3 +27,30 @@ test('the folder note says the settings are still being read until they are, and assert.equal(newSiteLocationNote(undefined), 'Reading…'); assert.equal(newSiteLocationNote({ newSiteLocation: null }), 'Not set: the create-site dialog asks each time.'); }); + +test('the language entries are the system\'s first, then what the app has, and the one set if it is none of them', () => { + const languages = [{ tag: 'de', label: 'Deutsch' }, { tag: 'en', label: 'English' }]; + assert.deepEqual(languageItems(languages, null), [ + { value: SYSTEM_LANGUAGE, label: 'Your system’s language' }, + { value: 'de', label: 'Deutsch' }, + { value: 'en', label: 'English' } + ]); + assert.deepEqual(languageItems(languages, 'de').map((item) => item.value), [SYSTEM_LANGUAGE, 'de', 'en']); + assert.deepEqual(languageItems(languages, 'fr').at(-1), { value: 'fr', label: 'fr' }); + assert.deepEqual(languageItems(null, 'en-XA').map((item) => item.value), [SYSTEM_LANGUAGE, 'en-XA']); +}); + +test('the language has changed when what is set is no longer what the window started in', () => { + assert.equal(languageChanged(null, null), false); + assert.equal(languageChanged({ locale: 'de' }, null), false); + assert.equal(languageChanged({ locale: null }, { locale: null }), false); + assert.equal(languageChanged({ locale: 'de' }, { locale: 'de' }), false); + assert.equal(languageChanged({ locale: 'de' }, { locale: null }), true); + assert.equal(languageChanged({ locale: null }, { locale: 'de' }), true); +}); + +test('the system\'s entry is kept as no choice, and a language as itself', () => { + assert.equal(languageValue(SYSTEM_LANGUAGE), null); + assert.equal(languageValue('de'), 'de'); + assert.equal(languageValue('en'), 'en'); +}); diff --git a/tests/unit/settings.test.cjs b/tests/unit/settings.test.cjs index ba08be11..d3d0ccdb 100644 --- a/tests/unit/settings.test.cjs +++ b/tests/unit/settings.test.cjs @@ -8,18 +8,20 @@ const { SETTINGS, readSettings, acceptSetting } = require('../../src/settings.cj // What the disk says, as the test decides it. const disk = (folders) => ({ isAbsolute: path.posix.isAbsolute, isDirectory: (p) => folders.includes(p) }); +const languages = (tags) => ({ isLanguage: (tag) => tags.includes(tag) }); test('readSettings falls back for a store with nothing in it, and for values of the wrong kind', () => { - assert.deepEqual(readSettings(), { newSiteLocation: null }); - assert.deepEqual(readSettings(undefined), { newSiteLocation: null }); - assert.deepEqual(readSettings({}), { newSiteLocation: null }); - assert.deepEqual(readSettings({ newSiteLocation: 42 }), { newSiteLocation: null }); - assert.deepEqual(readSettings({ newSiteLocation: '' }), { newSiteLocation: null }); - assert.deepEqual(readSettings('not an object'), { newSiteLocation: null }); + const fallbacks = { locale: null, newSiteLocation: null }; + assert.deepEqual(readSettings(), fallbacks); + assert.deepEqual(readSettings(undefined), fallbacks); + assert.deepEqual(readSettings({}), fallbacks); + assert.deepEqual(readSettings({ newSiteLocation: 42, locale: ['de'] }), fallbacks); + assert.deepEqual(readSettings({ newSiteLocation: '', locale: '' }), fallbacks); + assert.deepEqual(readSettings('not an object'), fallbacks); }); test('readSettings gives back a stored folder without asking the disk about it', () => { - assert.deepEqual(readSettings({ newSiteLocation: '/Users/jane/sites' }), { newSiteLocation: '/Users/jane/sites' }); + assert.deepEqual(readSettings({ newSiteLocation: '/Users/jane/sites', locale: 'de' }), { locale: 'de', newSiteLocation: '/Users/jane/sites' }); }); test('readSettings answers for every setting there is', () => { @@ -52,3 +54,17 @@ test('a key that is not a setting is refused, and nothing is asked of the disk', assert.equal(result.ok, false); assert.equal(asked, 0); }); + +test('a language is kept when the app has it, English included, and nothing means the system\'s', () => { + assert.deepEqual(acceptSetting('locale', 'de', languages(['de', 'en'])), { ok: true, value: 'de' }); + assert.deepEqual(acceptSetting('locale', 'en', languages(['de', 'en'])), { ok: true, value: 'en' }); + for (const value of [null, undefined, '']) { + assert.deepEqual(acceptSetting('locale', value, languages([])), { ok: true, value: null }, String(value)); + } +}); + +test('a language the app has no catalog for is refused, and so is anything that is not a tag', () => { + assert.deepEqual(acceptSetting('locale', 'fr', languages(['de', 'en'])), { ok: false, error: 'The app has no translation for that language.' }); + assert.equal(acceptSetting('locale', ['de'], languages(['de'])).ok, false); + assert.equal(acceptSetting('locale', 42, languages(['de'])).ok, false); +});