Spaces:
Paused
Paused
| """Tests for the StrictCsvParser.""" | |
| from __future__ import annotations | |
| import pytest | |
| from app.parsers import get_parser, pick_auto | |
| from app.parsers.base import ParserFile | |
| def _file(text: str, name: str = "answers.csv", encoding: str = "utf-8") -> ParserFile: | |
| return ParserFile(filename=name, content=text.encode(encoding)) | |
| def test_can_handle_csv_only_for_answer_zones(): | |
| p = get_parser("csv_strict") | |
| assert p.can_handle(b"", "x.csv", "student_answers") is True | |
| assert p.can_handle(b"", "x.csv", "teacher_answers") is True | |
| assert p.can_handle(b"", "x.csv", "questions") is False | |
| assert p.can_handle(b"", "x.pdf", "student_answers") is False | |
| async def test_student_answers_basic(): | |
| csv_text = ",Q1,Q2,Q3,Q4,Q5\n正確解答,B,A,A,C,D\n梁祐邦,A,=,C,C,D\n田瑜婕,=,A,C,B,D\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| students = result.data["students"] | |
| assert [s["name"] for s in students] == ["梁祐邦", "田瑜婕"] | |
| assert [a["answer"] for a in students[0]["answers"]] == ["A", "=", "C", "C", "D"] | |
| assert [a["answer"] for a in students[1]["answers"]] == ["=", "A", "C", "B", "D"] | |
| assert all(a["question_number"] == i + 1 for i, a in enumerate(students[0]["answers"])) | |
| async def test_student_answers_blank_cell_becomes_none(): | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,A,A\n梁祐邦,A,,C\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| answers = [a["answer"] for a in result.data["students"][0]["answers"]] | |
| assert answers == ["A", None, "C"] | |
| async def test_student_answers_legacy_dash_becomes_equals(): | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,A,A\n梁祐邦,-,A,A\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| assert result.data["students"][0]["answers"][0]["answer"] == "=" | |
| async def test_student_answers_skips_empty_name_rows(): | |
| csv_text = ",Q1,Q2\n正確解答,B,A\n梁祐邦,A,A\n,B,B\n田瑜婕,B,A\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| assert [s["name"] for s in result.data["students"]] == ["梁祐邦", "田瑜婕"] | |
| assert any("Skipped" in n for n in result.notes) | |
| async def test_student_answers_warns_when_key_row_isnt_marker(): | |
| csv_text = ",Q1,Q2\n梁祐邦,A,B\n田瑜婕,B,A\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| # Row 1 (after header) treated as the key even when name isn't a marker | |
| assert len(result.data["students"]) == 1 | |
| assert result.data["students"][0]["name"] == "田瑜婕" | |
| assert any("treating it as the key" in n for n in result.notes) | |
| async def test_student_answers_rejects_too_few_rows(): | |
| csv_text = ",Q1,Q2,Q3\n正確解答,A,B,C\n" | |
| p = get_parser("csv_strict") | |
| with pytest.raises(ValueError, match="at least one student"): | |
| await p.parse([_file(csv_text)], "student_answers") | |
| async def test_student_answers_rejects_single_column(): | |
| csv_text = "header\n正確解答\n梁祐邦\n" | |
| p = get_parser("csv_strict") | |
| with pytest.raises(ValueError, match="at least 2 columns"): | |
| await p.parse([_file(csv_text)], "student_answers") | |
| async def test_student_answers_pads_short_rows(): | |
| csv_text = ",Q1,Q2,Q3,Q4\n正確解答,B,A,A,C\n梁祐邦,A,=\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| answers = [a["answer"] for a in result.data["students"][0]["answers"]] | |
| assert answers == ["A", "=", None, None] | |
| async def test_student_answers_arbitrary_header_text_ignored(): | |
| """Row 0 header text is not validated — only column count matters.""" | |
| csv_text = "name,foo,bar,baz\n正確解答,B,A,A\n梁祐邦,A,=,C\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| assert len(result.data["students"]) == 1 | |
| async def test_teacher_answers_basic(): | |
| csv_text = ",Q1,Q2,Q3,Q4,Q5\n正確解答,B,A,A,C,D\n梁祐邦,A,=,C,C,D\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "teacher_answers") | |
| answers = result.data["answers"] | |
| assert [a["correct_answer"] for a in answers] == ["B", "A", "A", "C", "D"] | |
| assert [a["question_number"] for a in answers] == [1, 2, 3, 4, 5] | |
| async def test_teacher_answers_rejects_equals_in_key(): | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,=,A\n梁祐邦,A,A,A\n" | |
| p = get_parser("csv_strict") | |
| with pytest.raises(ValueError, match="must be a concrete letter"): | |
| await p.parse([_file(csv_text)], "teacher_answers") | |
| async def test_teacher_answers_rejects_blank_cell_in_key(): | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,,A\n梁祐邦,A,A,A\n" | |
| p = get_parser("csv_strict") | |
| with pytest.raises(ValueError, match="empty or not a letter"): | |
| await p.parse([_file(csv_text)], "teacher_answers") | |
| async def test_utf8_bom_accepted(): | |
| csv_text = ",Q1,Q2\n正確解答,B,A\n梁祐邦,A,A\n" | |
| file = ParserFile(filename="answers.csv", content=("" + csv_text).encode("utf-8")) | |
| p = get_parser("csv_strict") | |
| result = await p.parse([file], "student_answers") | |
| assert result.data["students"][0]["name"] == "梁祐邦" | |
| async def test_non_utf8_raises_clear_error(): | |
| csv_text = ",Q1,Q2\n正確解答,B,A\n梁祐邦,A,A\n" | |
| file = ParserFile(filename="answers.csv", content=csv_text.encode("big5")) | |
| p = get_parser("csv_strict") | |
| with pytest.raises(ValueError, match="UTF-8"): | |
| await p.parse([file], "student_answers") | |
| def test_pick_auto_routes_csv_to_strict(): | |
| chosen = pick_auto(b"", "answers.csv", "student_answers") | |
| assert chosen.name == "csv_strict" | |
| chosen = pick_auto(b"", "answers.csv", "teacher_answers") | |
| assert chosen.name == "csv_strict" | |
| def test_pick_auto_questions_never_uses_csv(): | |
| chosen = pick_auto(b"", "answers.csv", "questions") | |
| assert chosen.name == "llm_vision" | |
| async def test_lowercase_marker_accepted(): | |
| csv_text = ",Q1,Q2\nkey,B,A\n梁祐邦,A,A\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| # Should NOT warn — "key" is in the marker set | |
| assert not any("treating it as the key" in n for n in result.notes) | |
| assert len(result.data["students"]) == 1 | |
| async def test_excel_trailing_empty_columns_ignored(): | |
| csv_text = ",Q1,Q2,Q3,,\n正確解答,B,A,A,,\n梁祐邦,A,=,C,,\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| answers = [a["answer"] for a in result.data["students"][0]["answers"]] | |
| assert answers == ["A", "=", "C"] | |
| # --------------------------------------------------------------------------- | |
| # Auto-fill correct marker when CSV has no "=" anywhere | |
| # --------------------------------------------------------------------------- | |
| async def test_auto_marks_correct_when_no_equals_found(): | |
| """If no '=' exists, cells matching the key get replaced with '='.""" | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,A,C\n梁祐邦,B,X,C\n田瑜婕,A,A,Y\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| students = result.data["students"] | |
| # 梁祐邦: Q1 matches key B → "=", Q2 wrong, Q3 matches C → "=" | |
| assert [a["answer"] for a in students[0]["answers"]] == ["=", "X", "="] | |
| # 田瑜婕: Q1 wrong, Q2 matches A → "=", Q3 wrong | |
| assert [a["answer"] for a in students[1]["answers"]] == ["A", "=", "Y"] | |
| async def test_does_not_auto_mark_when_equals_already_present(): | |
| """If even one '=' exists, leave everything else alone.""" | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,A,C\n梁祐邦,B,X,=\n田瑜婕,A,A,Y\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| students = result.data["students"] | |
| # 梁祐邦's "B" stays "B" — no auto-conversion since "=" was found in his row | |
| assert [a["answer"] for a in students[0]["answers"]] == ["B", "X", "="] | |
| # 田瑜婕's "A" stays "A" — same file, same skip | |
| assert [a["answer"] for a in students[1]["answers"]] == ["A", "A", "Y"] | |
| async def test_auto_mark_handles_missing_key_letter(): | |
| """Cells where the key is None stay as the student typed.""" | |
| # Key Q2 will be blank → triggers teacher-side validation if used as | |
| # teacher_answers, so we test student path with a key letter present | |
| # alongside one None. Here we craft via a key cell with letter+None mix. | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,A,C\n梁祐邦,B,A,X\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| students = result.data["students"] | |
| # All match except Q3 → first two become "=", Q3 stays "X" | |
| assert [a["answer"] for a in students[0]["answers"]] == ["=", "=", "X"] | |
| async def test_auto_mark_handles_blank_student_cell(): | |
| """Blank student cells stay None — never auto-converted to '='.""" | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,A,C\n梁祐邦,B,,C\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| answers = [a["answer"] for a in result.data["students"][0]["answers"]] | |
| assert answers == ["=", None, "="] | |
| async def test_auto_mark_note_emitted_with_count(): | |
| csv_text = ",Q1,Q2,Q3\n正確解答,B,A,C\n梁祐邦,B,X,C\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse([_file(csv_text)], "student_answers") | |
| matched = [n for n in result.notes if "auto-converted" in n] | |
| assert len(matched) == 1 | |
| assert "2 correct" in matched[0] | |
| async def test_auto_mark_is_per_file_not_global(): | |
| """File A has '=' → no preprocess; File B has no '=' → preprocessed.""" | |
| csv_a = ",Q1,Q2\n正確解答,B,A\n甲生,=,X\n" | |
| csv_b = ",Q1,Q2\n正確解答,B,A\n乙生,B,A\n" | |
| p = get_parser("csv_strict") | |
| result = await p.parse( | |
| [_file(csv_a, name="a.csv"), _file(csv_b, name="b.csv")], | |
| "student_answers", | |
| ) | |
| students = result.data["students"] | |
| by_name = {s["name"]: s for s in students} | |
| # 甲生 from file A — no preprocessing, "=" preserved, "X" untouched | |
| assert [a["answer"] for a in by_name["甲生"]["answers"]] == ["=", "X"] | |
| # 乙生 from file B — preprocessing replaces both with "=" | |
| assert [a["answer"] for a in by_name["乙生"]["answers"]] == ["=", "="] | |
| # Note mentions only file B | |
| matched = [n for n in result.notes if "auto-converted" in n] | |
| assert len(matched) == 1 | |
| assert "b.csv" in matched[0] | |
| # --------------------------------------------------------------------------- | |
| # Combined "answers" data_type via process_uploaded_files | |
| # --------------------------------------------------------------------------- | |
| class _FakeUpload: | |
| """Minimal stand-in for FastAPI's UploadFile.""" | |
| def __init__(self, filename: str, content: bytes) -> None: | |
| self.filename = filename | |
| self._content = content | |
| async def read(self) -> bytes: | |
| return self._content | |
| async def test_combined_answers_persists_both_subtypes(tmp_path, monkeypatch): | |
| """`data_type='answers'` runs the parser twice and stores both rows.""" | |
| from app import database | |
| from app.file_processor import process_uploaded_files | |
| # Point the DB at a fresh file | |
| db_file = tmp_path / "test.db" | |
| monkeypatch.setattr(database, "DATABASE_PATH", db_file) | |
| monkeypatch.setattr( | |
| database.get_settings.__wrapped__, "__defaults__", None, raising=False | |
| ) | |
| # Ensure config returns no override | |
| from app import config as config_module | |
| cached = config_module.get_settings() | |
| monkeypatch.setattr(cached, "database_path", "", raising=False) | |
| await database.init_database() | |
| user_id = await database.create_user("t@example.com", "h", "T") | |
| session_id = await database.create_session(user_id, "S") | |
| csv_text = ",Q1,Q2,Q3,Q4,Q5\n正確解答,B,A,A,C,D\n梁祐邦,A,=,C,C,D\n田瑜婕,=,A,C,B,D\n" | |
| upload = _FakeUpload("answers.csv", csv_text.encode("utf-8")) | |
| response = await process_uploaded_files( | |
| [upload], | |
| "answers", | |
| session_id, | |
| parser="csv_strict", | |
| ) | |
| assert "student_answers" in response | |
| assert "teacher_answers" in response | |
| assert len(response["student_answers"]["students"]) == 2 | |
| assert len(response["teacher_answers"]["answers"]) == 5 | |
| rows = await database.get_parsed_data(session_id) | |
| by_type = {r["data_type"]: r for r in rows} | |
| assert "student_answers" in by_type | |
| assert "teacher_answers" in by_type | |
| assert len(by_type["student_answers"]["structured_data"]["students"]) == 2 | |
| assert len(by_type["teacher_answers"]["structured_data"]["answers"]) == 5 | |