diff --git a/docs/adr/adr-0001-csv-module-over-pandas.md b/docs/adr/adr-0001-csv-module-over-pandas.md new file mode 100644 index 0000000..9417b58 --- /dev/null +++ b/docs/adr/adr-0001-csv-module-over-pandas.md @@ -0,0 +1,52 @@ +# Read the states with the csv module + +## Metadata +| Key | Value | +| --- | --- | +| ID | ADR-0001 | +| CrossReference | [BC-001], [SA-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-13 | Accepted | Claude (AI) for S01 | AI as stand-in for S01 | Initial version; review RC-008: Go | [bb8730c] | + +Allowed Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by ADR-NNNN`. + +--- + +## Context + +The lectures read `50_states.csv` with pandas (`pandas.read_csv`, a DataFrame, then `DataFrame.to_csv` for `states_to_learn.csv`). The file has 50 rows and three columns (`state`, `x`, `y`). [BC-001] sets the constraint "avoid adding runtime dependencies unless they are needed", and [SA-001] records that S03 wants no runtime dependencies while S02 wants the assignment's function and variable names. + +Options evaluated: + +1. **pandas**: matches the lectures literally; adds a large runtime dependency (with NumPy) for 50 rows and makes installation and CI slower. +2. **Standard library `csv` module**: no dependency; a few more lines to read and write; the assignment's names (`data`, `states`, `guessed_states`, `missing_states`) can be kept. + +## Decision + +Read and write the CSV files with the standard library `csv` module, and keep the assignment's names for variables and functions. pandas is not a runtime dependency. + +## Consequences + +**Positive:** + +- The game has no runtime dependencies, so S03 installs nothing and CI is fast. +- The game logic is plain functions over `str` and `int`, easy to test without a display. + +**Negative:** + +- The code differs from the lecture's pandas calls, so S02 compares by names and behaviour, not by line. +- Reading and writing the CSV takes a few more lines than `read_csv` and `to_csv`. + +## Affected Artifacts + +- [BC-001] - Success criterion 6 (no runtime dependency beyond the standard library) is met without exception. +- [SA-001] - Resolves the S02 and S03 conflict in favour of no runtime dependency. + +--- + +[BC-001]: ../business-case.md +[SA-001]: ../stakeholder-analysis.md +[bb8730c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/bb8730c30761f885491abeb764690e0b53d2459b diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 829dc6b..bf577c5 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,8 +15,8 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 005 | -| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0001 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 | +| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 | ## Languages diff --git a/docs/sqa/reviews/rc-008-adr-0001.md b/docs/sqa/reviews/rc-008-adr-0001.md new file mode 100644 index 0000000..b8b2bde --- /dev/null +++ b/docs/sqa/reviews/rc-008-adr-0001.md @@ -0,0 +1,58 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-008 | +| CrossReference | [QC-ADR-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-13 | Proposed | Claude (AI) for S01 | AI as stand-in for S01 | Initial review record: Go | [bb8730c] | + +--- + +## Artifact Under Review + +- Instance reviewed: [ADR-0001] +- Checklist used: [QC-ADR-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the author is the same AI) + +## Checklist Results + +### QC-ADR-001 + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | The ID (`ADR-NNNN`, 4-digit) matches the filename number and is unique and sequential (Mandatory) | Pass | ID ADR-0001 matches file adr-0001-...; first ADR. | +| 2 | Context states the problem, the forces (cost, risk, constraints) and the options evaluated (Mandatory) | Pass | Context states problem, constraints from BC-001 and SA-001, and two options. | +| 3 | At least two options were considered, or the absence of alternatives is justified (Mandatory) | Pass | Two options: pandas and the csv module. | +| 4 | Decision is stated in one or two unhedged sentences and matches one of the evaluated options (Mandatory) | Pass | Decision is two unhedged sentences and equals option 2. | +| 5 | Consequences list both Positive and Negative outcomes (Mandatory) | Pass | Positive and Negative lists present. | +| 6 | Quality-attribute impacts (performance, security, portability, etc.) are named where the decision affects them (Optional) | Pass | Portability and installation effect named (no runtime dependency). | +| 7 | Affected Artifacts are listed as links, and each is also in CrossReference (Mandatory) | Pass | BC-001 and SA-001 are linked and in CrossReference. | +| 8 | Status changes are recorded as new Version History rows with a Change summary and Commit link; retained rows are never rewritten (Mandatory) | Pass | One Version History row; to be set Accepted on this Go. | +| 9 | A superseded or deprecated ADR names its successor, and the successor links back (Optional) | N-A | Not superseded or deprecated. | +| 10 | Written in business or technical language of the domain, with no unexplained abbreviations (Optional) | Pass | Abbreviations spelled out or defined (CSV file name, CI named as continuous integration in BC-001). | +| 11 | Status is one of `Proposed`, `Accepted`, `Rejected`, `Deprecated` or `Superseded by ADR-NNNN`, and every Version History row uses only these (Mandatory) | Pass | Status values are from the allowed list. | + +## Overall Verdict + +Go - ADR-0001 meets every Mandatory criterion. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | | + +--- + +[QC-ADR-001]: ../../../framework/qc/artifacts/qc-adr.md + +[ADR-0001]: ../../adr/adr-0001-csv-module-over-pandas.md +[bb8730c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/bb8730c30761f885491abeb764690e0b53d2459b diff --git a/docs/sqa/reviews/rc-009-mil-002-code.md b/docs/sqa/reviews/rc-009-mil-002-code.md new file mode 100644 index 0000000..42be7ce --- /dev/null +++ b/docs/sqa/reviews/rc-009-mil-002-code.md @@ -0,0 +1,61 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-009 | +| CrossReference | [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-13 | Proposed | Claude (AI) for S01 | AI as stand-in for S01 | Initial review record: Go | [bb8730c] | + +--- + +## Artifact Under Review + +- Instance reviewed: source and tests delivered by MIL-002 (branch mil-002-game-core) +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the author is the same AI) + +## Checklist Results + +### QC-PY-001 + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) (Mandatory) | Pass | snake_case functions, PascalCase State; ruff N rules pass. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes (Mandatory) | Pass | find_state, get_missing_states, save_states_to_learn state purpose; assignment names kept. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions (Mandatory) | Pass | ruff format and check clean, no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` (Mandatory) | Pass | All signatures annotated; mypy --strict passes. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) (Mandatory) | N-A | No exception handling; a missing file raises FileNotFoundError to the caller. | +| 6 | No mutable default arguments and no shadowed builtins (Mandatory) | Pass | Default arguments are immutable (Path values); no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers (Mandatory) | Pass | storage.py opens files with `with`. | +| 8 | Public modules, classes and functions have docstrings that say what, not how (Optional) | Pass | Docstrings with Doxygen @param/@return on all public items. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output (Mandatory) | Pass | No print and no logging in the logic. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded (Mandatory) | N-A | No Design Class Diagram exists; State is a plain value object. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network (Mandatory) | Pass | 15 tests named for behaviour, tmp_path for files, no network or order dependence. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment (Optional) | Pass | mypy strict reports no issues. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused (Optional) | Pass | No runtime dependencies (ADR-0001). | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike (Mandatory) | Pass | Column names and paths defined once in constants.py; matching rule in find_state only. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles (Mandatory) | Pass | states.py imports no IO or UI; storage.py imports inward (states, constants); no cycles. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations (Mandatory) | Pass | states.py has one reason to change (rules), storage.py another (file format); no inheritance. | + +## Overall Verdict + +Go - Mandatory criteria pass for states.py, storage.py and their tests (ruff, mypy --strict, 17 pytest tests, doxygen without warnings). + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | | + +--- + +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[bb8730c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/bb8730c30761f885491abeb764690e0b53d2459b diff --git a/src/us_states_game/states.py b/src/us_states_game/states.py new file mode 100644 index 0000000..3f6e12b --- /dev/null +++ b/src/us_states_game/states.py @@ -0,0 +1,54 @@ +"""Pure domain logic of the U.S. States Game: states, answers and misses. + +@file states.py +@brief State model, answer matching and the list of missing states. + +Nothing here reads files or touches the screen, so the rules run under test +with no display and no file system. +""" + +from collections.abc import Iterable +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class State: + """A U.S. state and the position of its name on the map. + + @param name Name of the state, for example "Ohio". + @param x Horizontal map coordinate of the state. + @param y Vertical map coordinate of the state. + """ + + name: str + x: int + y: int + + +def find_state(answer: str, states: Iterable[State]) -> State | None: + """Find the state the player named, ignoring case and surrounding spaces. + + @param answer Text the player typed. + @param states The states to search. + @return The matching state, or None if the answer matches no state. + """ + wanted = answer.strip().casefold() + if not wanted: + return None + for state in states: + if state.name.casefold() == wanted: + return state + return None + + +def get_missing_states( + states: Iterable[State], guessed_states: Iterable[str] +) -> list[str]: + """List the names of the states the player did not guess. + + @param states All states of the game, in the order to report them. + @param guessed_states Names of the states the player guessed. + @return Names of the states not guessed, in the order of `states`. + """ + guessed = set(guessed_states) + return [state.name for state in states if state.name not in guessed] diff --git a/src/us_states_game/storage.py b/src/us_states_game/storage.py new file mode 100644 index 0000000..03a3695 --- /dev/null +++ b/src/us_states_game/storage.py @@ -0,0 +1,48 @@ +"""CSV input and output of the U.S. States Game, using the csv module. + +@file storage.py +@brief Loads the states from CSV and saves the states to learn. + +Uses the standard library `csv` module, not pandas (see ADR-0001). +""" + +import csv +from collections.abc import Iterable +from pathlib import Path + +from us_states_game.constants import ( + STATE_COLUMN, + STATES_CSV_PATH, + STATES_TO_LEARN_FILENAME, + X_COLUMN, + Y_COLUMN, +) +from us_states_game.states import State + + +def load_states(path: Path = STATES_CSV_PATH) -> list[State]: + """Read the states and their map coordinates from a CSV file. + + @param path CSV file with the state, x and y columns. + @return The states, in file order. + """ + with path.open(newline="", encoding="utf-8") as file: + return [ + State(row[STATE_COLUMN], int(row[X_COLUMN]), int(row[Y_COLUMN])) + for row in csv.DictReader(file) + ] + + +def save_states_to_learn( + missing_states: Iterable[str], + path: Path = Path(STATES_TO_LEARN_FILENAME), +) -> None: + """Write the missing states to a one-column CSV file. + + @param missing_states Names of the states the player missed. + @param path File to write; an existing file is replaced. + """ + with path.open("w", newline="", encoding="utf-8") as file: + writer = csv.writer(file) + writer.writerow([STATE_COLUMN]) + writer.writerows([name] for name in missing_states) diff --git a/tests/test_states.py b/tests/test_states.py new file mode 100644 index 0000000..6685943 --- /dev/null +++ b/tests/test_states.py @@ -0,0 +1,41 @@ +"""Tests of the pure game logic in states.py.""" + +from us_states_game.states import State, find_state, get_missing_states + +STATES = [State("Alabama", 139, -77), State("Ohio", 100, 20), State("Texas", -50, -120)] + + +def test_find_state_matches_exact_name() -> None: + assert find_state("Ohio", STATES) == State("Ohio", 100, 20) + + +def test_find_state_ignores_case() -> None: + assert find_state("oHiO", STATES) == State("Ohio", 100, 20) + + +def test_find_state_ignores_surrounding_whitespace() -> None: + assert find_state(" ohio \n", STATES) == State("Ohio", 100, 20) + + +def test_find_state_returns_none_for_unknown_answer() -> None: + assert find_state("Atlantis", STATES) is None + + +def test_find_state_returns_none_for_empty_answer() -> None: + assert find_state("", STATES) is None + + +def test_find_state_returns_none_for_blank_answer() -> None: + assert find_state(" ", STATES) is None + + +def test_get_missing_states_keeps_order_of_states() -> None: + assert get_missing_states(STATES, ["Ohio"]) == ["Alabama", "Texas"] + + +def test_get_missing_states_is_empty_when_all_guessed() -> None: + assert get_missing_states(STATES, ["Texas", "Ohio", "Alabama"]) == [] + + +def test_get_missing_states_returns_all_when_none_guessed() -> None: + assert get_missing_states(STATES, []) == ["Alabama", "Ohio", "Texas"] diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..cd42749 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,50 @@ +"""Tests of the CSV input and output in storage.py.""" + +import csv +from pathlib import Path + +from us_states_game.constants import NUMBER_OF_STATES, STATE_COLUMN +from us_states_game.states import State, get_missing_states +from us_states_game.storage import load_states, save_states_to_learn + + +def _read_rows(path: Path) -> list[list[str]]: + with path.open(newline="", encoding="utf-8") as file: + return list(csv.reader(file)) + + +def test_load_states_returns_all_states() -> None: + assert len(load_states()) == NUMBER_OF_STATES + + +def test_load_states_reads_alabama_coordinates() -> None: + assert load_states()[0] == State("Alabama", 139, -77) + + +def test_load_states_reads_custom_path(tmp_path: Path) -> None: + path = tmp_path / "states.csv" + path.write_text("state,x,y\nOhio,10,-20\n", encoding="utf-8") + assert load_states(path) == [State("Ohio", 10, -20)] + + +def test_save_states_to_learn_writes_header_and_states(tmp_path: Path) -> None: + path = tmp_path / "learn.csv" + save_states_to_learn(["Ohio", "Texas"], path) + assert _read_rows(path) == [[STATE_COLUMN], ["Ohio"], ["Texas"]] + + +def test_save_states_to_learn_writes_only_header_when_nothing_missing( + tmp_path: Path, +) -> None: + path = tmp_path / "learn.csv" + save_states_to_learn([], path) + assert _read_rows(path) == [[STATE_COLUMN]] + + +def test_save_states_to_learn_lists_47_rows_after_three_guesses( + tmp_path: Path, +) -> None: + path = tmp_path / "learn.csv" + missing = get_missing_states(load_states(), ["Alabama", "Ohio", "Texas"]) + save_states_to_learn(missing, path) + assert len(_read_rows(path)) - 1 == NUMBER_OF_STATES - 3