Files
plezy/scripts/test_check_hardcoded_strings.py
edde746 369c6279d6 fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:

A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.

English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.

Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.

Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.

Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.

All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.

scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.

One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.

close #1856
2026-08-10 15:32:43 +02:00

196 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import check_hardcoded_strings
class HardcodedStringsCheckerTest(unittest.TestCase):
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)
self.root = Path(self.temporary_directory.name)
self.lib = self.root / "lib"
self.lib.mkdir()
self.allowlist_path = self.root / "allowlist.json"
self.allowlist_path.write_text("{}\n", encoding="utf-8")
self.enterContext(patch.object(check_hardcoded_strings, "ROOT", self.root))
self.enterContext(patch.object(check_hardcoded_strings, "LIB_DIR", self.lib))
self.enterContext(
patch.object(check_hardcoded_strings, "ALLOWLIST_PATH", self.allowlist_path)
)
def _write_source(self, source: str, relative: str = "widgets/example.dart") -> None:
path = self.lib / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source, encoding="utf-8")
def _findings(self) -> list[check_hardcoded_strings.Finding]:
findings, _ = check_hardcoded_strings.scan()
return findings
def test_text_literal_is_reported(self):
self._write_source("Widget build() => const Text('Skip Intro');\n")
findings = self._findings()
self.assertEqual([(finding.literal, finding.rule) for finding in findings], [
("Skip Intro", "Text first argument")
])
def test_phrase_bound_to_a_name_in_a_ui_file_is_reported(self):
# The actual shape of issue #1856: the literal never touches Text()
# directly, it is assigned to a local a few lines above the render.
self._write_source(
"Widget build() {\n"
" String label;\n"
" if (isCredits) {\n"
" label = 'Skip Credits';\n"
" } else {\n"
" label = t.videoControls.skipIntro;\n"
" }\n"
" return Text(label);\n"
"}\n"
)
findings = self._findings()
self.assertEqual([(finding.literal, finding.rule) for finding in findings], [
("Skip Credits", "display string bound to a name")
])
def test_identifier_bound_to_a_name_is_not_reported(self):
# Single-token identifiers are indistinguishable from copy by word
# count alone, so the rule requires a whitespace-separated phrase.
self._write_source(
"Widget build() {\n"
" final section = 'cast_row';\n"
" final mode = 'HDR_UNSUPPORTED';\n"
" final tab = 'liveTv';\n"
" return Text(t.common.close);\n"
"}\n"
)
self.assertEqual(self._findings(), [])
def test_phrase_bound_behind_a_log_argument_is_not_reported(self):
self._write_source(
"void run() => promptAndCreate(\n"
" createdLog: (playlist) => 'Successfully created playlist: ${playlist.title}',\n"
" title: Text(t.playlists.create),\n"
");\n"
)
self.assertEqual(self._findings(), [])
def test_phrase_bound_in_a_non_ui_file_is_not_reported(self):
self._write_source(
"String describe() => 'not name=value';\n",
relative="services/plain_service.dart",
)
self.assertEqual(self._findings(), [])
def test_translated_text_is_not_reported(self):
self._write_source("Widget build() => Text(t.videoControls.skipIntro);\n")
self.assertEqual(self._findings(), [])
def test_tooltip_literal_is_reported(self):
self._write_source("Widget build() => IconButton(tooltip: 'Close');\n")
findings = self._findings()
self.assertEqual([(finding.literal, finding.rule) for finding in findings], [
("Close", "UI named argument")
])
def test_mixed_translation_interpolation_is_reported(self):
self._write_source("final value = '${t.common.pause} auto-scroll';\n")
findings = self._findings()
self.assertEqual([(finding.literal, finding.rule) for finding in findings], [
("${t.common.pause} auto-scroll", "mixed translation interpolation")
])
def test_t_lambda_parameter_is_not_mistaken_for_translation_accessor(self):
self._write_source("final title = tracks.map((t) => 'Track ${t.id}');\n")
self.assertEqual(self._findings(), [])
def test_numeric_season_episode_pattern_is_not_reported(self):
self._write_source(
"Widget build() => Text("
"\"${hasIndex ? 'S${season} E${episode}' : ''}\""
");\n"
)
self.assertEqual(self._findings(), [])
def test_diagnostic_and_debug_label_literals_are_not_reported(self):
self._write_source(
"final widget = Thing(debugLabel: 'Developer controls');\n"
"appLogger.i(Text('Developer details'));\n"
)
self.assertEqual(self._findings(), [])
def test_allowlisted_literal_is_not_reported(self):
self._write_source("Widget build() => const Text('Permanent English');\n")
self.allowlist_path.write_text(
json.dumps(
{
"lib/widgets/example.dart": {
"Permanent English": "Deliberate product terminology"
}
}
),
encoding="utf-8",
)
self.assertEqual(self._findings(), [])
def test_stale_allowlist_report_finds_literal_that_matches_nothing(self):
self._write_source("Widget build() => Text(t.common.close);\n")
self.allowlist_path.write_text(
json.dumps(
{
"lib/widgets/example.dart": {
"Removed English": "Former deliberate terminology"
}
}
),
encoding="utf-8",
)
output = io.StringIO()
with contextlib.redirect_stdout(output):
result = check_hardcoded_strings.main(["--allowlist-missing"])
self.assertEqual(result, 1)
self.assertIn("lib/widgets/example.dart: 'Removed English'", output.getvalue())
def test_comments_directives_keys_units_and_generated_files_are_ignored(self):
self._write_source(
"// Text('Comment words')\n"
"import 'Text(\\'Imported words\\')';\n"
"final key = ValueKey('Stable widget identity');\n"
"final duration = Text('min');\n"
)
self._write_source("const Text('Generated English');\n", "model.g.dart")
self._write_source("const Text('Generated English');\n", "model.freezed.dart")
self._write_source("const Text('Generated English');\n", "i18n/generated.dart")
self.assertEqual(self._findings(), [])
if __name__ == "__main__":
unittest.main()