feat(i18n): translate labels, and keep brand names out of it

name and title were translated at render time but never machine-filled,
on the grounds that no backend tells the menu label "Pictures" from the
brand "Mastodon". That left the visible half of a card in English. They
are filled now, and the two key sets collapse into one.

The brands need somewhere to be named instead. app/i18n/keep.txt lists
them, one per line, and every entry is stored as itself in every target
language: no request, and never over an entry written by hand. --keep adds
one-off strings, --keep-file points elsewhere.

The shipped list holds the 43 product names that appear as name: or title:
in config.sample.yaml. Generic labels — Pictures, Imprint, Settings,
Certificates — are deliberately absent, and so are Cybermaster, Polymath
and Yachtmaster, which read as brand or as job title depending on who is
asking.

Two of these behaviours first shipped unguarded. A test that protected a
string and asserted the hand-written value survived passed either way,
because a run where nothing is missing reports "complete" and never
writes; and nothing exercised main(), so the keep file could stop being
read without a failure. Both are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:18:33 +02:00
parent 747ce379cc
commit e96e8684e6
5 changed files with 223 additions and 16 deletions

View File

@@ -30,19 +30,26 @@ class TestCollectSources(unittest.TestCase):
self.assertEqual(
i18n_sync.collect_sources(config),
{
"Agile Coach",
"I lead transformations.",
"Another card.",
"Apps",
"Application menu",
"A tagline",
},
)
def test_label_and_structural_keys_are_left_alone(self):
def test_labels_are_collected_too(self):
config = {"name": "Pictures", "title": "Agile Coach"}
self.assertEqual(i18n_sync.collect_sources(config), {"Pictures", "Agile Coach"})
def test_structural_keys_are_left_alone(self):
config = {
"name": "Mastodon",
"title": "Cybermaster",
"url": "https://example.test",
"link_text": "www.example.test",
"identifier": "@someone@example.test",
"icon": {"class": "fa-solid fa-users"},
}
self.assertEqual(i18n_sync.collect_sources(config), set())
@@ -178,6 +185,80 @@ class TestSupportedLanguages(unittest.TestCase):
i18n_sync.supported_languages("http://lt", self._session(payload))
class TestReadKeep(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.directory, True)
self.path = self.directory / "keep.txt"
def test_a_missing_file_keeps_nothing(self):
self.assertEqual(i18n_sync.read_keep(self.path), [])
def test_comments_and_blank_lines_are_ignored(self):
self.path.write_text(
"# brands\n\nMastodon\n Bluesky \n\n# more\nNextcloud\n",
encoding="utf-8",
)
self.assertEqual(
i18n_sync.read_keep(self.path), ["Mastodon", "Bluesky", "Nextcloud"]
)
def test_the_shipped_list_names_the_brands_of_the_sample_configuration(self):
shipped = i18n_sync.read_keep(i18n_sync.DEFAULT_KEEP_PATH)
self.assertGreaterEqual(set(shipped), {"Mastodon", "Nextcloud", "Matrix"})
self.assertNotIn("Pictures", shipped)
self.assertNotIn("Imprint", shipped)
class TestCommandLine(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.directory, True)
self.keep_file = self.directory / "keep.txt"
self.config = self.directory / "config.yaml"
self.config.write_text("cards: []\n", encoding="utf-8")
def _main(self, *extra):
with patch.object(i18n_sync, "sync") as sync:
i18n_sync.main(
[
"--url",
"http://lt/",
"--config",
str(self.config),
"--keep-file",
str(self.keep_file),
*extra,
]
)
return sync.call_args
def test_the_keep_file_reaches_the_sync(self):
self.keep_file.write_text("# brands\nMastodon\nNextcloud\n", encoding="utf-8")
keep = self._main().args[5]
self.assertEqual(list(keep), ["Mastodon", "Nextcloud"])
def test_the_command_line_adds_to_the_keep_file(self):
self.keep_file.write_text("Mastodon\n", encoding="utf-8")
keep = self._main("--keep", "Taiga").args[5]
self.assertEqual(list(keep), ["Mastodon", "Taiga"])
def test_a_trailing_slash_is_stripped_from_the_url(self):
self.assertEqual(self._main().args[0], "http://lt")
def test_a_backend_failure_is_reported_as_an_exit_code(self):
with patch.object(i18n_sync, "sync", side_effect=i18n_sync.BackendError("no")):
self.assertEqual(
i18n_sync.main(["--url", "http://lt", "--config", str(self.config)]), 1
)
class TestLoadExisting(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
@@ -256,7 +337,7 @@ class TestSync(unittest.TestCase):
i18n.CONTENT_DIR = self.directory
self.path = self.directory / "de.yaml"
def _run(self, sources, offered=("de",), translated="UEBERSETZT"):
def _run(self, sources, offered=("de",), translated="UEBERSETZT", keep=()):
listing = Mock(raise_for_status=Mock())
listing.json.return_value = [{"code": code} for code in offered]
answer = Mock(ok=True)
@@ -264,7 +345,7 @@ class TestSync(unittest.TestCase):
session = Mock(get=Mock(return_value=listing), post=Mock(return_value=answer))
with patch.object(i18n_sync.requests, "Session", return_value=session):
i18n_sync.sync("http://lt", "", set(sources), ["de"], self.directory)
i18n_sync.sync("http://lt", "", set(sources), ["de"], self.directory, keep)
return session
def test_missing_entries_are_written(self):
@@ -299,6 +380,22 @@ class TestSync(unittest.TestCase):
self.assertFalse(self.path.exists())
session.post.assert_not_called()
def test_a_protected_string_is_stored_as_itself(self):
session = self._run(["Mastodon", "Hello"], keep=["Mastodon"])
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
self.assertEqual(catalog["Mastodon"], "Mastodon")
self.assertEqual(session.post.call_count, 1)
def test_a_protected_string_never_overwrites_an_existing_entry(self):
self.path.write_text("Mastodon: HAND-EDITED\n", encoding="utf-8")
self._run(["Mastodon", "Hello"], keep=["Mastodon"])
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
self.assertEqual(catalog["Mastodon"], "HAND-EDITED")
self.assertEqual(catalog["Hello"], "UEBERSETZT")
def test_an_empty_translation_is_not_stored(self):
self._run(["Hello"], translated="")