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
This commit is contained in:
Executable
+408
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reject common user-facing English string literals that bypass Slang.
|
||||
|
||||
This is deliberately a structural check rather than a Dart/dataflow analyzer. It
|
||||
cannot see English inside a ``throw`` that a screen later renders, nor a literal
|
||||
assigned to a provider field that a widget later renders, because neither is
|
||||
distinguishable from a log message without dataflow analysis.
|
||||
|
||||
Bare ``label:`` and ``actionLabel:`` are deliberately NOT scanned. In this
|
||||
codebase they overwhelmingly name a diagnostic operation rather than UI text
|
||||
(``_broadcastToDvrs(actionLabel: 'Reload guide', successMessage: t....)``,
|
||||
``raceEndpointCandidates(label: ...)``, ``systemShelf(label: 'Failed to ...')``),
|
||||
so scanning them yields only false positives, and a check that is chronically
|
||||
red is a check that gets switched off. Widget ``label:`` text is still covered
|
||||
when it reaches a ``Text`` (rule 1) or mixes with ``t.`` (rule 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LIB_DIR = ROOT / "lib"
|
||||
ALLOWLIST_PATH = Path(__file__).with_name("hardcoded_strings_allowlist.json")
|
||||
|
||||
_UI_ARGUMENTS = (
|
||||
"tooltip",
|
||||
"semanticLabel",
|
||||
"labelText",
|
||||
"hintText",
|
||||
"helperText",
|
||||
"errorText",
|
||||
"dialogTitle",
|
||||
)
|
||||
_UI_ARGUMENT_RE = re.compile(r"(?<![A-Za-z0-9_])(?:" + "|".join(_UI_ARGUMENTS) + r")\s*:\s*$")
|
||||
_TEXT_RE = re.compile(r"(?:\bText|\bSelectableText)\s*\(\s*$")
|
||||
_EXCLUDED_ARGUMENT_RE = re.compile(r"(?:debugLabel|fontFamily)\s*:\s*$")
|
||||
_KEY_RE = re.compile(r"(?:\bKey|\bValueKey)\s*\(\s*$")
|
||||
# `createdLog:`/`log:` name a diagnostic sink, never rendered copy. Matched
|
||||
# unanchored because the literal is usually behind a lambda
|
||||
# (`createdLog: (playlist) => 'Created ...'`), so the argument name is not
|
||||
# the text immediately preceding the literal. The `[a-z]Log` arm keeps
|
||||
# innocent names such as `catalog:` out of the exclusion.
|
||||
_DIAGNOSTIC_RE = re.compile(
|
||||
r"(?:\bappLogger\.|\bSentry\.|\bassert\s*\(|\bthrow\b|(?:\blog|[a-z]Log)\s*:)"
|
||||
)
|
||||
_TRANSLATION_INTERPOLATION_RE = re.compile(
|
||||
r"\$\{\s*(?:t\.|context\.t\b|Translations\.of\s*\()"
|
||||
)
|
||||
_T_PARAMETER_RE = re.compile(r"(?:\(\s*t\s*\)|\bt)\s*=>[^;]*$")
|
||||
# Rule 4 support: a literal bound to a name or returned, rather than handed
|
||||
# straight to a widget. This is the shape issue #1856 actually had --
|
||||
# `baseButtonText = 'Skip Intro';` a few lines above the `Text(buttonText)`
|
||||
# that renders it -- which rules 1-3 structurally cannot see.
|
||||
#
|
||||
# Restricted to phrase-shaped literals: at least two letter-words separated by
|
||||
# whitespace. Without that restriction the rule cannot tell display copy from
|
||||
# the identifier strings this codebase binds constantly ('cast_row', 'auto',
|
||||
# 'liveTv', 'HDR_UNSUPPORTED'), and it drowns in false positives. The cost is
|
||||
# that a bound single-word label ('Software', 'Stereo') slips through; those
|
||||
# are indistinguishable from an identifier without dataflow analysis.
|
||||
_PHRASE_RE = re.compile(r"[A-Za-z]\s+[A-Za-z]")
|
||||
_BOUND_LITERAL_RE = re.compile(r"(?:\breturn|=>|(?<![=!<>+\-*/%&|^~])=)\s*$")
|
||||
_RENDERS_UI_RE = re.compile(
|
||||
r"(?:\bText|\bSelectableText)\s*\(|(?<![A-Za-z0-9_])(?:"
|
||||
+ "|".join(_UI_ARGUMENTS)
|
||||
+ r")\s*:"
|
||||
)
|
||||
_WORD_RE = re.compile(r"[A-Za-z]{3,}")
|
||||
_UNITS = {
|
||||
"bit",
|
||||
"bits",
|
||||
"bps",
|
||||
"dp",
|
||||
"fps",
|
||||
"gb",
|
||||
"gbps",
|
||||
"hz",
|
||||
"kb",
|
||||
"kbps",
|
||||
"khz",
|
||||
"mb",
|
||||
"mbps",
|
||||
"mhz",
|
||||
"min",
|
||||
"mins",
|
||||
"ms",
|
||||
"px",
|
||||
"sec",
|
||||
"secs",
|
||||
"sp",
|
||||
"tb",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Literal:
|
||||
line: int
|
||||
value: str
|
||||
start: int
|
||||
end: int
|
||||
raw: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
path: str
|
||||
line: int
|
||||
literal: str
|
||||
rule: str
|
||||
|
||||
|
||||
def _interpolation_end(source: str, start: int) -> int:
|
||||
"""Return the first offset after a balanced ``${...}`` expression."""
|
||||
cursor = start + 2
|
||||
depth = 1
|
||||
while cursor < len(source) and depth:
|
||||
if source.startswith("//", cursor):
|
||||
newline = source.find("\n", cursor + 2)
|
||||
cursor = len(source) if newline < 0 else newline + 1
|
||||
continue
|
||||
if source.startswith("/*", cursor):
|
||||
end = source.find("*/", cursor + 2)
|
||||
cursor = len(source) if end < 0 else end + 2
|
||||
continue
|
||||
raw = (
|
||||
source[cursor] in "rR"
|
||||
and cursor + 1 < len(source)
|
||||
and source[cursor + 1] in "'\""
|
||||
)
|
||||
quote_start = cursor + 1 if raw else cursor
|
||||
if source[quote_start] in "'\"":
|
||||
quote_char = source[quote_start]
|
||||
quote = (
|
||||
quote_char * 3
|
||||
if source.startswith(quote_char * 3, quote_start)
|
||||
else quote_char
|
||||
)
|
||||
cursor = quote_start + len(quote)
|
||||
while cursor < len(source):
|
||||
if not raw and source[cursor] == "\\":
|
||||
cursor += 2
|
||||
elif source.startswith(quote, cursor):
|
||||
cursor += len(quote)
|
||||
break
|
||||
else:
|
||||
cursor += 1
|
||||
continue
|
||||
if source[cursor] == "{":
|
||||
depth += 1
|
||||
elif source[cursor] == "}":
|
||||
depth -= 1
|
||||
cursor += 1
|
||||
return cursor
|
||||
|
||||
|
||||
def _string_literals(source: str) -> list[Literal]:
|
||||
"""Return Dart string literals while ignoring line and block comments."""
|
||||
literals: list[Literal] = []
|
||||
index = 0
|
||||
length = len(source)
|
||||
while index < length:
|
||||
if source.startswith("//", index):
|
||||
newline = source.find("\n", index + 2)
|
||||
index = length if newline < 0 else newline + 1
|
||||
continue
|
||||
if source.startswith("/*", index):
|
||||
depth = 1
|
||||
index += 2
|
||||
while index < length and depth:
|
||||
if source.startswith("/*", index):
|
||||
depth += 1
|
||||
index += 2
|
||||
elif source.startswith("*/", index):
|
||||
depth -= 1
|
||||
index += 2
|
||||
else:
|
||||
index += 1
|
||||
continue
|
||||
|
||||
raw = False
|
||||
token_start = index
|
||||
if (
|
||||
source[index] in "rR"
|
||||
and index + 1 < length
|
||||
and source[index + 1] in "'\""
|
||||
and (index == 0 or not (source[index - 1].isalnum() or source[index - 1] == "_"))
|
||||
):
|
||||
raw = True
|
||||
index += 1
|
||||
if source[index] not in "'\"":
|
||||
index += 1
|
||||
continue
|
||||
|
||||
quote_char = source[index]
|
||||
quote = quote_char * 3 if source.startswith(quote_char * 3, index) else quote_char
|
||||
body_start = index + len(quote)
|
||||
cursor = body_start
|
||||
while cursor < length:
|
||||
if not raw and source[cursor] == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if not raw and source.startswith("${", cursor):
|
||||
cursor = _interpolation_end(source, cursor)
|
||||
continue
|
||||
if source.startswith(quote, cursor):
|
||||
end = cursor + len(quote)
|
||||
literals.append(
|
||||
Literal(
|
||||
line=source.count("\n", 0, token_start) + 1,
|
||||
value=source[body_start:cursor],
|
||||
start=token_start,
|
||||
end=end,
|
||||
raw=raw,
|
||||
)
|
||||
)
|
||||
index = end
|
||||
break
|
||||
if len(quote) == 1 and source[cursor] == "\n":
|
||||
index = cursor + 1
|
||||
break
|
||||
cursor += 1
|
||||
else:
|
||||
index = length
|
||||
return literals
|
||||
|
||||
|
||||
def _literal_text(value: str) -> str:
|
||||
"""Return the literal with every interpolation replaced by whitespace."""
|
||||
literal_parts: list[str] = []
|
||||
cursor = 0
|
||||
while cursor < len(value):
|
||||
if value.startswith("${", cursor):
|
||||
literal_parts.append(" ")
|
||||
cursor = _interpolation_end(value, cursor)
|
||||
elif value[cursor] == "$" and cursor + 1 < len(value) and (
|
||||
value[cursor + 1].isalpha() or value[cursor + 1] == "_"
|
||||
):
|
||||
cursor += 2
|
||||
while cursor < len(value) and (value[cursor].isalnum() or value[cursor] == "_"):
|
||||
cursor += 1
|
||||
else:
|
||||
literal_parts.append(value[cursor])
|
||||
cursor += 1
|
||||
return "".join(literal_parts)
|
||||
|
||||
|
||||
def _literal_words(value: str) -> list[str]:
|
||||
return [word.lower() for word in _WORD_RE.findall(_literal_text(value))]
|
||||
|
||||
|
||||
def _contains_english(value: str) -> bool:
|
||||
words = _literal_words(value)
|
||||
return bool(words) and not all(word in _UNITS for word in words)
|
||||
|
||||
|
||||
def _statement_prefix(source: str, start: int) -> str:
|
||||
boundary = source.rfind(";", 0, start)
|
||||
return source[boundary + 1 : start]
|
||||
|
||||
|
||||
def _is_excluded_context(source: str, literal: Literal) -> bool:
|
||||
statement_prefix = _statement_prefix(source, literal.start)
|
||||
if re.match(r"\s*(?:import|part)\b", statement_prefix):
|
||||
return True
|
||||
|
||||
prefix = source[max(0, literal.start - 300) : literal.start]
|
||||
if _EXCLUDED_ARGUMENT_RE.search(prefix) or _KEY_RE.search(prefix):
|
||||
return True
|
||||
return bool(_DIAGNOSTIC_RE.search(statement_prefix))
|
||||
|
||||
|
||||
def _rule_for_literal(source: str, literal: Literal, renders_ui: bool = False) -> str | None:
|
||||
if not _contains_english(literal.value) or _is_excluded_context(source, literal):
|
||||
return None
|
||||
|
||||
prefix = source[max(0, literal.start - 300) : literal.start]
|
||||
if not literal.raw and _TRANSLATION_INTERPOLATION_RE.search(literal.value):
|
||||
if not (re.search(r"\$\{\s*t\.", literal.value) and _T_PARAMETER_RE.search(prefix)):
|
||||
return "mixed translation interpolation"
|
||||
if _TEXT_RE.search(prefix):
|
||||
return "Text first argument"
|
||||
if _UI_ARGUMENT_RE.search(prefix):
|
||||
return "UI named argument"
|
||||
if (
|
||||
renders_ui
|
||||
and _PHRASE_RE.search(_literal_text(literal.value))
|
||||
and _BOUND_LITERAL_RE.search(_statement_prefix(source, literal.start))
|
||||
):
|
||||
return "display string bound to a name"
|
||||
return None
|
||||
|
||||
|
||||
def load_allowlist(path: Path | None = None) -> dict[str, dict[str, str]]:
|
||||
path = ALLOWLIST_PATH if path is None else path
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("allowlist root must be an object")
|
||||
for relative_path, entries in data.items():
|
||||
if not isinstance(relative_path, str) or not isinstance(entries, dict):
|
||||
raise ValueError("allowlist entries must map paths to objects")
|
||||
for literal, reason in entries.items():
|
||||
if not isinstance(literal, str) or not isinstance(reason, str) or not reason.strip():
|
||||
raise ValueError(f"allowlist entry {relative_path!r} / {literal!r} needs a reason")
|
||||
return data
|
||||
|
||||
|
||||
def _is_allowlisted(path: str, literal: str, allowlist: dict[str, dict[str, str]]) -> bool:
|
||||
return literal in allowlist.get(path, {}) or literal in allowlist.get("*", {})
|
||||
|
||||
|
||||
def scan(
|
||||
lib_dir: Path | None = None,
|
||||
allowlist: dict[str, dict[str, str]] | None = None,
|
||||
root: Path | None = None,
|
||||
) -> tuple[list[Finding], dict[str, set[str]]]:
|
||||
lib_dir = LIB_DIR if lib_dir is None else lib_dir
|
||||
root = ROOT if root is None else root
|
||||
if allowlist is None:
|
||||
allowlist = load_allowlist()
|
||||
findings: list[Finding] = []
|
||||
seen_literals: dict[str, set[str]] = {}
|
||||
for path in sorted(lib_dir.rglob("*.dart")):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
# lib/dev is a separate measurement entrypoint, explicitly "NOT part of the app".
|
||||
if (
|
||||
relative.startswith(("lib/i18n/", "lib/dev/"))
|
||||
or path.name.endswith((".g.dart", ".freezed.dart"))
|
||||
):
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8")
|
||||
literals = _string_literals(source)
|
||||
renders_ui = bool(_RENDERS_UI_RE.search(source))
|
||||
seen_literals[relative] = {literal.value for literal in literals}
|
||||
for literal in literals:
|
||||
rule = _rule_for_literal(source, literal, renders_ui)
|
||||
if rule is None or _is_allowlisted(relative, literal.value, allowlist):
|
||||
continue
|
||||
findings.append(Finding(relative, literal.line, literal.value, rule))
|
||||
return findings, seen_literals
|
||||
|
||||
|
||||
def stale_allowlist_entries(
|
||||
allowlist: dict[str, dict[str, str]], seen_literals: dict[str, set[str]]
|
||||
) -> list[tuple[str, str]]:
|
||||
all_literals = set().union(*seen_literals.values()) if seen_literals else set()
|
||||
stale: list[tuple[str, str]] = []
|
||||
for path, entries in allowlist.items():
|
||||
present = all_literals if path == "*" else seen_literals.get(path, set())
|
||||
stale.extend((path, literal) for literal in entries if literal not in present)
|
||||
return sorted(stale)
|
||||
|
||||
|
||||
def _print_findings(findings: list[Finding]) -> None:
|
||||
current_path = None
|
||||
for finding in findings:
|
||||
if finding.path != current_path:
|
||||
if current_path is not None:
|
||||
print()
|
||||
current_path = finding.path
|
||||
print(f"{finding.path}:")
|
||||
print(f" {finding.line}: [{finding.rule}] {finding.literal!r}")
|
||||
print(f"\nFound {len(findings)} hardcoded user-facing string(s).")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--root", type=Path, default=ROOT)
|
||||
parser.add_argument(
|
||||
"--allowlist", type=Path, default=ALLOWLIST_PATH, help="path to the JSON allowlist"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowlist-missing",
|
||||
action="store_true",
|
||||
help="report allowlist entries whose literal no longer exists",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
allowlist = load_allowlist(args.allowlist)
|
||||
findings, seen_literals = scan(args.root / "lib", allowlist, args.root)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"error: {error}")
|
||||
return 1
|
||||
|
||||
if args.allowlist_missing:
|
||||
stale = stale_allowlist_entries(allowlist, seen_literals)
|
||||
if stale:
|
||||
print("Allowlist entries that no longer match a source literal:")
|
||||
for path, literal in stale:
|
||||
print(f" {path}: {literal!r}")
|
||||
return 1
|
||||
print("All hardcoded-string allowlist entries still match source literals.")
|
||||
return 0
|
||||
|
||||
if findings:
|
||||
_print_findings(findings)
|
||||
return 1
|
||||
print("No hardcoded user-facing English strings found.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+15
-6
@@ -80,7 +80,16 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 4. Workflow and script regression guards
|
||||
# 4. Hardcoded user-facing strings
|
||||
section "hardcoded UI strings"
|
||||
if python3 scripts/check_hardcoded_strings.py; then
|
||||
ok "user-facing strings use the translation layer"
|
||||
else
|
||||
fail "hardcoded user-facing English strings found"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 5. Workflow and script regression guards
|
||||
section "workflow and script guards"
|
||||
if bash scripts/ci_guard_checks.sh; then
|
||||
ok "workflow and script guards passed"
|
||||
@@ -89,7 +98,7 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 5. Icon consistency
|
||||
# 6. Icon consistency
|
||||
section "icon consistency"
|
||||
if dart run scripts/check_icon_consistency.dart; then
|
||||
ok "production icons use AppIcon and rounded Symbols"
|
||||
@@ -98,7 +107,7 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 3. Native formatting
|
||||
# 7. Native formatting
|
||||
section "native format"
|
||||
out="$(mktemp)"
|
||||
if scripts/format_native.sh --check >"$out" 2>&1; then
|
||||
@@ -110,7 +119,7 @@ else
|
||||
fi
|
||||
rm -f "$out"
|
||||
|
||||
# 3. Dart analyzer (mirrors ci.yml "Analyze code")
|
||||
# 8. Dart analyzer (mirrors ci.yml "Analyze code")
|
||||
section "Dart analyzer"
|
||||
if dart run scripts/check_analyzer.dart; then
|
||||
ok "no unapproved diagnostics"
|
||||
@@ -119,7 +128,7 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 4. Unused code (mirrors ci.yml "Check for unused code")
|
||||
# 9. Unused code (mirrors ci.yml "Check for unused code")
|
||||
section "dart_code_linter: unused code"
|
||||
if ! have_dart_code_linter; then
|
||||
skip "dart_code_linter unresolved — run 'flutter pub get'"
|
||||
@@ -136,7 +145,7 @@ else
|
||||
rm -f "$out"
|
||||
fi
|
||||
|
||||
# 5. Unused files (mirrors ci.yml "Check for unused files")
|
||||
# 10. Unused files (mirrors ci.yml "Check for unused files")
|
||||
section "dart_code_linter: unused files"
|
||||
if ! have_dart_code_linter; then
|
||||
skip "dart_code_linter unresolved — run 'flutter pub get'"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"*": {
|
||||
"Plex": "Brand name",
|
||||
"Jellyfin": "Brand name",
|
||||
"Emby": "Brand name",
|
||||
"Plezy": "Brand name",
|
||||
"Simkl": "Brand name",
|
||||
"BL": "Technical token",
|
||||
"EL": "Technical token",
|
||||
"RPU": "Technical token",
|
||||
"dontkillmyapp.com": "External technical resource name"
|
||||
},
|
||||
"lib/screens/auth_screen.dart": {
|
||||
"Debug: Enter Plex Token": "kDebugMode developer authentication affordance",
|
||||
"Plex Auth Token": "kDebugMode developer authentication affordance",
|
||||
"Enter your Plex.tv token": "kDebugMode developer authentication affordance",
|
||||
"Auth service not ready": "kDebugMode developer authentication diagnostic"
|
||||
},
|
||||
"lib/screens/settings/settings_screen.dart": {
|
||||
"Test Sentry": "kDebugMode developer diagnostic affordance",
|
||||
"Send a test error": "kDebugMode developer diagnostic affordance",
|
||||
"Test ANR": "kDebugMode developer diagnostic affordance",
|
||||
"Block the main thread for 10 seconds": "kDebugMode developer diagnostic affordance",
|
||||
"Blocking main thread...": "kDebugMode developer diagnostic affordance"
|
||||
},
|
||||
"lib/widgets/video_controls/sheets/video_settings_sheet.dart": {
|
||||
"Trigger MPV Fallback": "kDebugMode developer playback affordance",
|
||||
"Simulate HTTP $status from server": "kDebugMode developer playback affordance"
|
||||
},
|
||||
"lib/screens/settings/seerr_connect_screen.dart": {
|
||||
"https://seerr.example.com": "Intentional server URL example"
|
||||
},
|
||||
"lib/screens/settings/external_player_screen.dart": {
|
||||
"myplayer://play?url=": "Intentional URL scheme example",
|
||||
"com.example.player": "Intentional Android package example",
|
||||
"mpv": "Intentional executable example",
|
||||
"/usr/bin/player": "Intentional executable path example"
|
||||
},
|
||||
"lib/screens/settings/appearance_settings_screen.dart": {
|
||||
"English": "Language endonym",
|
||||
"Svenska": "Language endonym",
|
||||
"Français": "Language endonym",
|
||||
"Italiano": "Language endonym",
|
||||
"Nederlands": "Language endonym",
|
||||
"Deutsch": "Language endonym",
|
||||
"Magyar": "Language endonym",
|
||||
"简体中文": "Language endonym",
|
||||
"繁體中文": "Language endonym",
|
||||
"한국어": "Language endonym",
|
||||
"Español": "Language endonym",
|
||||
"Português": "Language endonym",
|
||||
"日本語": "Language endonym",
|
||||
"Русский": "Language endonym",
|
||||
"Polski": "Language endonym",
|
||||
"Dansk": "Language endonym",
|
||||
"Norsk bokmål": "Language endonym",
|
||||
"Български": "Language endonym",
|
||||
"Türkçe": "Language endonym",
|
||||
"Azərbaycanca": "Language endonym",
|
||||
"Қазақша": "Language endonym",
|
||||
"Oʻzbekcha": "Language endonym"
|
||||
},
|
||||
"lib/screens/settings/logs_screen.dart": {
|
||||
"Android ${info.version.release} (API ${info.version.sdkInt})": "English bug-report diagnostic",
|
||||
"TV mode: yes$suffix": "English bug-report diagnostic",
|
||||
"Renderer: $renderer": "English bug-report diagnostic",
|
||||
"Background: ${backgroundWork.describeSync()}": "English bug-report diagnostic",
|
||||
"iOS ${info.systemVersion}": "English bug-report diagnostic",
|
||||
"macOS ${info.osRelease}": "English bug-report diagnostic",
|
||||
"Linux ${info.versionId ?? info.id}": "English bug-report diagnostic",
|
||||
"Effects: ${DevicePerformance.describeSync()}": "English bug-report diagnostic",
|
||||
"Display: ${DevicePerformance.describeDisplay()}": "English bug-report diagnostic"
|
||||
},
|
||||
"lib/services/startup_diagnostics.dart": {
|
||||
"Plezy startup failure": "English bug-report diagnostic",
|
||||
"Version: $appVersion": "English bug-report diagnostic",
|
||||
"Platform: $platform": "English bug-report diagnostic",
|
||||
"When: ${timestamp.toUtc().toIso8601String()}": "English bug-report diagnostic",
|
||||
"Phase: $phaseId": "English bug-report diagnostic",
|
||||
"Error: $errorType": "English bug-report diagnostic",
|
||||
"Repair offered: ${repairable ? 'yes' : 'no'}": "English bug-report diagnostic",
|
||||
"Message: $message": "English bug-report diagnostic",
|
||||
"Stack trace:": "English bug-report diagnostic"
|
||||
}
|
||||
}
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user