11"""Tests for the static i18n extraction audit (code_puppy.i18n.audit)."""
22
3+ import ast
4+
35from code_puppy .i18n import audit
46
57
@@ -16,6 +18,47 @@ def test_fstring_is_raw():
1618 assert _kinds ('emit_warning(f"hi {name}")' ) == ["raw" ]
1719
1820
21+ def test_fstring_pure_variable_is_dynamic ():
22+ """f"{var}" has no literal content — must not be reported as raw."""
23+ assert _kinds ('emit_info(f"{result}")' ) == ["dynamic" ]
24+
25+
26+ def test_fstring_whitespace_only_literal_is_dynamic ():
27+ """f" {var}" has only whitespace in the constant part — not translatable."""
28+ assert _kinds ('emit_info(f" {msg}")' ) == ["dynamic" ]
29+
30+
31+ def test_fstring_with_content_and_variable_is_raw ():
32+ """f"Error: {e}" has a meaningful literal prefix — must stay raw."""
33+ assert _kinds ('emit_error(f"Error: {e}")' ) == ["raw" ]
34+
35+
36+ def test_fstring_wrapped_literal_is_raw ():
37+ """f"{'literal'}" wraps a Constant inside a FormattedValue.
38+
39+ The classifier used to only inspect direct Constant children of
40+ JoinedStr, so this parsed as ``JoinedStr([FormattedValue(Constant)])``
41+ and was silently classified as dynamic — a false negative. Recurse
42+ into FormattedValue.value so it now shows up as raw.
43+ """
44+ src = "emit_info(f\" {'Error: connection refused'}\" )"
45+ # Direct AST check so we're not relying on the higher-level pipeline.
46+ tree = ast .parse (src , mode = "eval" )
47+ call = tree .body
48+ assert isinstance (call , ast .Call )
49+ joined = call .args [0 ]
50+ assert isinstance (joined , ast .JoinedStr )
51+ assert isinstance (joined .values [0 ], ast .FormattedValue )
52+ # And end-to-end through the classifier.
53+ assert audit ._classify (joined ) == "raw"
54+ assert _kinds (src ) == ["raw" ]
55+
56+
57+ def test_fstring_with_only_arrow_is_raw ():
58+ """Non-whitespace punctuation like an arrow counts as a literal."""
59+ assert _kinds ('emit_info(f"-> {item}")' ) == ["raw" ]
60+
61+
1962def test_string_concat_is_raw ():
2063 assert _kinds ('emit_error("bad: " + detail)' ) == ["raw" ]
2164
@@ -108,6 +151,57 @@ def test_json_output_is_valid(tmp_path, capsys):
108151 assert payload ["coverage" ] == 50.0
109152
110153
154+ def test_single_file_path_is_accepted (tmp_path , capsys ):
155+ """Passing a .py file directly must produce a non-empty report.
156+
157+ Previously ``_iter_py_files`` called ``os.walk(file)`` which yields
158+ nothing, so every per-file audit silently returned 0 sites.
159+ """
160+ import json
161+
162+ mod = tmp_path / "solo.py"
163+ mod .write_text ('emit_info("raw string")\n ' , encoding = "utf-8" )
164+ assert audit .main ([str (mod ), "--json" ]) == 0
165+ payload = json .loads (capsys .readouterr ().out )
166+ assert payload ["raw" ] == 1 , "single-file audit must find the raw site"
167+
168+
169+ def test_single_file_pure_variable_fstring_not_raw (tmp_path , capsys ):
170+ """Single-file audit: f"{var}" must NOT be counted as raw."""
171+ import json
172+
173+ mod = tmp_path / "mod.py"
174+ mod .write_text ('emit_info(f"{result}")\n ' , encoding = "utf-8" )
175+ assert audit .main ([str (mod ), "--json" ]) == 0
176+ payload = json .loads (capsys .readouterr ().out )
177+ assert payload ["raw" ] == 0
178+ assert payload ["dynamic" ] == 1
179+
180+
181+ def test_nonexistent_path_raises (tmp_path , capsys ):
182+ """A typo'd path must not silently report 100% coverage.
183+
184+ Previously ``audit_tree('/does/not/exist')`` returned an empty
185+ ``Report`` whose ``coverage`` property is ``100.0``, so
186+ ``--fail-under`` would happily pass on a typo'd path and tell CI
187+ everything was fine. Fail loud instead: this is a
188+ programming/config error, so ``FileNotFoundError`` propagates all
189+ the way out of ``main()`` and produces a nonzero exit.
190+ """
191+ import pytest
192+
193+ missing = tmp_path / "totally-not-real"
194+ assert not missing .exists ()
195+
196+ # audit_tree raises directly.
197+ with pytest .raises (FileNotFoundError ):
198+ audit .audit_tree (str (missing ))
199+
200+ # And main() lets it propagate — no accidental swallow, no exit 0.
201+ with pytest .raises (FileNotFoundError ):
202+ audit .main ([str (missing )])
203+
204+
111205# --- integration smoke ----------------------------------------------------
112206def test_audits_the_real_package_without_error ():
113207 """The tool must stay runnable against the live tree as it evolves.
0 commit comments