"""安全每日一题 按员工语言出题 —— the resolution rules and the invariant. The bug being pinned here is not "the wrong language rendered"; it is the one that would be *worse* than the Chinese-only bank it replaces: a localized option list whose keys drifted from the baseline, so a reader picks option ``C`` out of a list we rendered and gets graded against a ``correct_key`` that describes a different list. Every test below either pins a language decision or pins that invariant. Pure functions — no database. The service and router only thread a ``lang`` through to :mod:`localization`, and the grader is untouched by design (it compares option keys, which every language shares). """ from __future__ import annotations import pytest from app.domains.security_quiz import localization class _Q: """The three columns ``localization`` reads, without SQLAlchemy.""" def __init__(self, prompt, options, explanation=None, localized=None): self.prompt = prompt self.options = options self.explanation = explanation self.localized = localized ABCD = [ {"key": "A", "text": "甲"}, {"key": "B", "text": "乙"}, ] EN_BLOCK = { "prompt": "English prompt?", "options": [{"key": "A", "text": "Alpha"}, {"key": "B", "text": "Bravo"}], "explanation": "Because.", } # ── Reader language resolution ────────────────────────────────────── class _Person: def __init__(self, preferred=None, country=None, china=False): self.preferred_language = preferred self.country = country self.is_china_employee = china def test_explicit_lang_beats_header_and_stored_preference(): """The switcher the reader is looking at wins. That is the whole point of accepting ``?lang=`` at all.""" person = _Person(preferred="zh-CN") assert localization.reader_lang(person, explicit="ja", header="en") == "ja" def test_header_used_when_no_explicit_lang(): person = _Person(preferred="zh-CN") assert localization.reader_lang(person, header="en-GB") == "en" def test_falls_back_to_the_person_not_to_chinese(): """The MCP surface and any future notification card land here. Falling back to the Chinese baseline instead of the person's own ``preferred_language`` would have re-created the original bug on every non-browser surface — which is exactly how the daily quiz ended up Chinese-only in the first place. """ assert localization.reader_lang(_Person(preferred="english")) == "en" assert localization.reader_lang(_Person(preferred=None, country="JP")) == "ja" def test_unusable_lang_string_degrades_to_baseline_not_none(): """A read path has to render *something*.""" assert localization.normalize_quiz_lang("klingon") == "zh" assert localization.normalize_quiz_lang(None) == "zh" # ── Rendering ─────────────────────────────────────────────────────── def test_zh_renders_the_flat_columns(): q = _Q("题干?", ABCD, "解析", {"en": EN_BLOCK}) view = localization.render(q, "zh") assert view["prompt"] == "题干?" assert view["options"] == ABCD assert view["explanation"] == "解析" assert view["lang"] == "zh" assert view["is_fallback_lang"] is False def test_en_renders_its_block_and_is_not_a_fallback(): q = _Q("题干?", ABCD, "解析", {"en": EN_BLOCK}) view = localization.render(q, "en") assert view["prompt"] == "English prompt?" assert [o["text"] for o in view["options"]] == ["Alpha", "Bravo"] assert view["explanation"] == "Because." assert view["lang"] == "en" assert view["is_fallback_lang"] is False def test_missing_language_falls_back_and_says_so(): """The honesty field. A Chinese question on an English screen with no explanation is indistinguishable from a broken page.""" q = _Q("题干?", ABCD, "解析", None) view = localization.render(q, "en") assert view["prompt"] == "题干?" assert view["lang"] == "zh" assert view["is_fallback_lang"] is True def test_japanese_degrades_to_english_before_chinese(): """Mirrors ``core.locale.FALLBACK_CHAIN``: English is a closer register for a JP-based reader than Chinese.""" q = _Q("题干?", ABCD, "解析", {"en": EN_BLOCK}) view = localization.render(q, "ja") assert view["lang"] == "en" assert view["prompt"] == "English prompt?" assert view["is_fallback_lang"] is True def test_block_without_a_prompt_does_not_count_as_present(): """A block carrying only an explanation would render a Chinese question with an English explanation — worse than either language alone.""" q = _Q("题干?", ABCD, "解析", {"en": {"explanation": "orphan"}}) view = localization.render(q, "en") assert view["lang"] == "zh" assert view["explanation"] == "解析" assert localization.available_langs(q) == ["zh"] def test_block_may_omit_options_and_inherits_the_baseline(): """Boolean (对/错) questions store language-neutral True/False and the label comes from the frontend, so there is nothing to translate.""" tf = [{"key": "T", "text": "True"}, {"key": "F", "text": "False"}] q = _Q("这句话对吗?", tf, "解析", {"en": {"prompt": "Is this true?"}}) view = localization.render(q, "en") assert view["prompt"] == "Is this true?" assert view["options"] == tf assert view["is_fallback_lang"] is False def test_options_never_degrade_to_an_empty_list(): """A question with no options is unanswerable, so a block whose options are unusable inherits the baseline rather than rendering nothing.""" q = _Q("题干?", ABCD, None, {"en": {"prompt": "P", "options": []}}) assert localization.render(q, "en")["options"] == ABCD # ── The invariant: option keys are the contract ────────────────────── def test_read_path_rejects_a_block_whose_option_keys_drifted(): """THE test. ``correct_key`` records option keys and the reader picks from whichever list we rendered, so a block with different keys is not a translation of this question — it is a different question. Rendering it would grade somebody against choices they never saw, so the read path falls back to the baseline instead. """ q = _Q("题干?", ABCD, "解析", { "en": { "prompt": "English prompt?", "options": [{"key": "A", "text": "Alpha"}, {"key": "C", "text": "Charlie"}], }, }) view = localization.render(q, "en") # The English PROMPT still renders — the reader can read the question — # but the options are the baseline's, whose keys the answer key # describes. assert view["prompt"] == "English prompt?" assert view["options"] == ABCD def test_read_path_rejects_a_partial_option_list(): q = _Q("题干?", ABCD, None, { "en": {"prompt": "P", "options": [{"key": "A", "text": "Alpha"}]}, }) assert localization.render(q, "en")["options"] == ABCD def test_write_path_raises_on_mismatched_keys_rather_than_falling_back(): """Same rule, moved forward in time: from the editor this is a 422 the author can read, not a silent runtime fallback nobody notices.""" with pytest.raises(localization.LocalizationError) as exc: localization.validate_block( {"prompt": "P", "options": [{"key": "A", "text": "a"}, {"key": "C", "text": "c"}]}, lang="en", baseline_options=ABCD, ) assert "option keys" in str(exc.value) def test_write_path_refuses_chinese_as_a_localized_block(): """Chinese is the baseline and lives in the flat columns; accepting it here would create a second, silently-preferred copy of the question.""" with pytest.raises(localization.LocalizationError): localization.validate_block( {"prompt": "中文"}, lang="zh", baseline_options=ABCD, ) def test_write_path_requires_a_prompt(): with pytest.raises(localization.LocalizationError): localization.validate_block( {"explanation": "only"}, lang="en", baseline_options=ABCD, ) def test_write_path_keeps_only_the_three_translatable_keys(): """Anything else an admin (or a future model) puts in the JSON is dropped here rather than stored and then ignored forever.""" out = localization.validate_block( {"prompt": "P", "explanation": "E", "correct_key": "B", "mt": True}, lang="en", baseline_options=ABCD, ) assert out == {"prompt": "P", "explanation": "E"} def test_validate_localized_accepts_empty_as_delete(): assert localization.validate_localized({}, baseline_options=ABCD) == {} assert localization.validate_localized(None, baseline_options=ABCD) == {} def test_available_langs_reports_the_baseline_plus_real_blocks(): q = _Q("题干?", ABCD, None, {"en": EN_BLOCK}) assert localization.available_langs(q) == ["zh", "en"]