MIL-002 NATO converter: modelling, converter code, tests and Doxygen comments

User story, use case and SSD for "spell a word"; spell_word with
try/except KeyError, generate_phonetic with the retry loop, console and CSV
adapters, 49 pytest tests; MIL-002 and the modelling documents accepted with
review records.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
This commit is contained in:
2026-10-14 19:35:08 +08:00
parent 12a5cb5562
commit 80ec1bc364
27 changed files with 1071 additions and 2 deletions
+71
View File
@@ -0,0 +1,71 @@
import pytest
from nato_phonetic_alphabet import constants
from nato_phonetic_alphabet.adapters.console import TerminalConsole
from nato_phonetic_alphabet.application.ports import InputClosedError
from nato_phonetic_alphabet.main import main
def _feed(monkeypatch: pytest.MonkeyPatch, answers: list[str]) -> list[str]:
prompts: list[str] = []
def fake_input(prompt: str = "") -> str:
prompts.append(prompt)
if not answers:
raise EOFError
return answers.pop(0)
monkeypatch.setattr("builtins.input", fake_input)
return prompts
def test_main_prints_code_words_for_valid_word(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_feed(monkeypatch, ["Hello"])
status = main()
lines = capsys.readouterr().out.splitlines()
assert status == constants.EXIT_OK
assert lines == [constants.RESULT_TEXT, "Hotel", "Echo", "Lima", "Lima", "Oscar"]
def test_main_asks_again_after_invalid_word(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
prompts = _feed(monkeypatch, ["h3llo", "Hi"])
main()
lines = capsys.readouterr().out.splitlines()
assert prompts == [constants.PROMPT_TEXT, constants.PROMPT_TEXT]
assert lines == [
constants.INVALID_INPUT_TEXT,
constants.RESULT_TEXT,
"Hotel",
"India",
]
def test_main_ends_cleanly_on_end_of_input(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_feed(monkeypatch, [])
status = main()
assert status == constants.EXIT_OK
assert capsys.readouterr().out == ""
def test_terminal_console_raises_input_closed_on_interrupt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def interrupted(prompt: str = "") -> str:
raise KeyboardInterrupt
monkeypatch.setattr("builtins.input", interrupted)
with pytest.raises(InputClosedError):
TerminalConsole().ask_word()
+19
View File
@@ -0,0 +1,19 @@
from pathlib import Path
from nato_phonetic_alphabet.adapters.csv_alphabet import load_nato_dict
EXPECTED_ENTRIES = 26
def test_load_nato_dict_reads_temporary_csv(tmp_path: Path) -> None:
data_file = tmp_path / "alphabet.csv"
data_file.write_text("letter,code\na,Alfa\nB,Bravo\n", encoding="utf-8")
assert load_nato_dict(data_file) == {"A": "Alfa", "B": "Bravo"}
def test_load_nato_dict_builds_all_entries_from_packaged_file() -> None:
alphabet = load_nato_dict()
assert len(alphabet) == EXPECTED_ENTRIES
assert alphabet["Z"] == "Zulu"
+49
View File
@@ -0,0 +1,49 @@
import pytest
from nato_phonetic_alphabet.application.phonetic import generate_phonetic
from nato_phonetic_alphabet.application.ports import InputClosedError
ALPHABET = {"A": "Alfa", "B": "Bravo"}
class FakeConsole:
def __init__(self, words: list[str]) -> None:
self._words = words
self.asked = 0
self.invalid = 0
def ask_word(self) -> str:
self.asked += 1
if not self._words:
raise InputClosedError
return self._words.pop(0)
def show_invalid(self) -> None:
self.invalid += 1
def show_code_words(self, code_words: list[str]) -> None:
raise AssertionError("generate_phonetic must not print the result")
def test_generate_phonetic_returns_code_words_for_valid_word() -> None:
console = FakeConsole(["ab"])
assert generate_phonetic(ALPHABET, console) == ["Alfa", "Bravo"]
assert console.asked == 1
assert console.invalid == 0
def test_generate_phonetic_asks_again_after_invalid_words() -> None:
console = FakeConsole(["a1", "", "b"])
assert generate_phonetic(ALPHABET, console) == ["Bravo"]
assert console.invalid == 2
assert console.asked == 3
def test_generate_phonetic_lets_closed_input_escape() -> None:
console = FakeConsole(["x"])
with pytest.raises(InputClosedError):
generate_phonetic(ALPHABET, console)
assert console.invalid == 1
+43
View File
@@ -0,0 +1,43 @@
import pytest
from nato_phonetic_alphabet.adapters.csv_alphabet import load_nato_dict
from nato_phonetic_alphabet.domain.spelling import InvalidWordError, spell_word
ALPHABET = load_nato_dict()
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@pytest.mark.parametrize("letter", list(LETTERS))
def test_spell_word_maps_letter_to_its_code_word(letter: str) -> None:
assert spell_word(letter, ALPHABET) == [ALPHABET[letter]]
def test_spell_word_uses_known_code_words() -> None:
assert spell_word("ADZ", ALPHABET) == ["Alfa", "Delta", "Zulu"]
def test_spell_word_covers_all_26_letters() -> None:
assert len(spell_word(LETTERS, ALPHABET)) == len(LETTERS)
def test_spell_word_is_case_insensitive_for_mixed_case() -> None:
assert spell_word("Hello", ALPHABET) == [
"Hotel",
"Echo",
"Lima",
"Lima",
"Oscar",
]
@pytest.mark.parametrize("word", ["a1", "two words", "hi!", "", "ß", "æ"])
def test_spell_word_raises_for_non_letter_or_empty(word: str) -> None:
with pytest.raises(InvalidWordError):
spell_word(word, ALPHABET)
def test_spell_word_keeps_cause_of_invalid_character() -> None:
with pytest.raises(InvalidWordError) as info:
spell_word("a1", ALPHABET)
assert isinstance(info.value.__cause__, KeyError)
+30
View File
@@ -0,0 +1,30 @@
import subprocess
import sys
from nato_phonetic_alphabet import constants
def _run_module(stdin_text: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-m", "nato_phonetic_alphabet"],
input=stdin_text,
capture_output=True,
text=True,
check=False,
timeout=30,
)
def test_python_dash_m_spells_word_from_piped_input() -> None:
result = _run_module("Hi\n")
assert result.returncode == 0
assert constants.RESULT_TEXT in result.stdout
assert result.stdout.splitlines()[-2:] == ["Hotel", "India"]
def test_python_dash_m_exits_cleanly_on_end_of_input() -> None:
result = _run_module("")
assert result.returncode == 0
assert result.stderr == ""