import json from pathlib import Path import pytest from article_data_manager.importers.rollcalc_json import ( RollCalcFileError, RollCalcJsonSyntaxError, RollCalcValidationError, load_rollcalc_articles, ) def write_json(path: Path, payload: object) -> Path: path.write_text(json.dumps(payload), encoding="utf-8") return path def valid_article(**overrides: object) -> dict[str, object]: article: dict[str, object] = { "nr": "214700", "name": "Example Article", "thickness": 6.722, "area_weight": 0.0, "core_type": 0.0, } article.update(overrides) return article def test_loads_valid_file_with_one_article(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", [valid_article()]) articles = load_rollcalc_articles(path) assert len(articles) == 1 assert articles[0].nr == "214700" assert articles[0].name == "Example Article" assert articles[0].thickness == 6.722 assert articles[0].area_weight == 0.0 assert articles[0].core_type == 0.0 def test_loads_valid_file_with_multiple_articles(tmp_path: Path) -> None: path = write_json( tmp_path / "article-data.json", [ valid_article(nr="100001"), valid_article(nr="100002"), ], ) articles = load_rollcalc_articles(path) assert [article.nr for article in articles] == ["100001", "100002"] def test_preserves_source_order(tmp_path: Path) -> None: path = write_json( tmp_path / "article-data.json", [ valid_article(nr="300003"), valid_article(nr="100001"), valid_article(nr="200002"), ], ) articles = load_rollcalc_articles(path) assert [article.nr for article in articles] == ["300003", "100001", "200002"] def test_preserves_leading_zero_in_article_number(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(nr="000123")]) articles = load_rollcalc_articles(path) assert articles[0].nr == "000123" def test_numeric_integer_is_stored_as_float(tmp_path: Path) -> None: path = write_json( tmp_path / "article-data.json", [valid_article(thickness=5, area_weight=350, core_type=1)], ) article = load_rollcalc_articles(path)[0] assert article.thickness == 5.0 assert article.area_weight == 350.0 assert article.core_type == 1.0 assert isinstance(article.thickness, float) assert isinstance(article.area_weight, float) assert isinstance(article.core_type, float) def test_accepts_and_ignores_unknown_fields(tmp_path: Path) -> None: path = write_json( tmp_path / "article-data.json", [valid_article(width=6.0, unknown_nested={"value": "ignored"})], ) article = load_rollcalc_articles(path)[0] assert not hasattr(article, "width") assert not hasattr(article, "unknown_nested") assert article.to_dict() == { "nr": "214700", "name": "Example Article", "thickness": 6.722, "area_weight": 0.0, "core_type": 0.0, } def test_empty_json_array_returns_empty_list(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", []) assert load_rollcalc_articles(path) == [] def test_missing_file_raises_file_error(tmp_path: Path) -> None: path = tmp_path / "missing.json" with pytest.raises(RollCalcFileError, match="does not exist"): load_rollcalc_articles(path) def test_directory_path_raises_file_error(tmp_path: Path) -> None: with pytest.raises(RollCalcFileError, match="regular file"): load_rollcalc_articles(tmp_path) def test_invalid_utf8_raises_file_error(tmp_path: Path) -> None: path = tmp_path / "article-data.json" path.write_bytes(b"\xff\xfe\xfa") with pytest.raises(RollCalcFileError, match="UTF-8"): load_rollcalc_articles(path) def test_empty_file_raises_json_syntax_error(tmp_path: Path) -> None: path = tmp_path / "article-data.json" path.write_text("", encoding="utf-8") with pytest.raises(RollCalcJsonSyntaxError, match="invalid JSON"): load_rollcalc_articles(path) def test_syntactically_invalid_json_raises_json_syntax_error(tmp_path: Path) -> None: path = tmp_path / "article-data.json" path.write_text("[}", encoding="utf-8") with pytest.raises(RollCalcJsonSyntaxError, match="invalid JSON"): load_rollcalc_articles(path) def test_json_root_must_be_array(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", {"nr": "214700"}) with pytest.raises(RollCalcValidationError, match="root must be an array"): load_rollcalc_articles(path) def test_array_entry_must_be_object(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(), "invalid"]) with pytest.raises(RollCalcValidationError, match="index 1: entry must be an object"): load_rollcalc_articles(path) def test_missing_required_field(tmp_path: Path) -> None: article = valid_article() del article["name"] path = write_json(tmp_path / "article-data.json", [article]) with pytest.raises(RollCalcValidationError, match="missing required field 'name'"): load_rollcalc_articles(path) def test_nr_must_be_string(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(nr=214700)]) with pytest.raises(RollCalcValidationError, match="field 'nr' must be a string, got int"): load_rollcalc_articles(path) def test_nr_must_not_be_empty(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(nr="")]) with pytest.raises(RollCalcValidationError, match="field 'nr' must not be empty"): load_rollcalc_articles(path) def test_nr_must_not_contain_surrounding_whitespace(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(nr=" 000123")]) with pytest.raises(RollCalcValidationError, match="leading or trailing whitespace"): load_rollcalc_articles(path) def test_name_must_be_string(tmp_path: Path) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(name=123)]) with pytest.raises(RollCalcValidationError, match="field 'name' must be a string"): load_rollcalc_articles(path) @pytest.mark.parametrize("field_name", ["thickness", "area_weight", "core_type"]) def test_numeric_field_must_not_be_string(tmp_path: Path, field_name: str) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(**{field_name: "6.722"})]) with pytest.raises( RollCalcValidationError, match=f"field '{field_name}' must be a JSON number, got str", ): load_rollcalc_articles(path) @pytest.mark.parametrize("field_name", ["thickness", "area_weight", "core_type"]) def test_numeric_field_must_not_be_null(tmp_path: Path, field_name: str) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(**{field_name: None})]) with pytest.raises( RollCalcValidationError, match=f"field '{field_name}' must be a JSON number, got null", ): load_rollcalc_articles(path) @pytest.mark.parametrize("field_name", ["thickness", "area_weight", "core_type"]) def test_numeric_field_must_not_be_boolean(tmp_path: Path, field_name: str) -> None: path = write_json(tmp_path / "article-data.json", [valid_article(**{field_name: True})]) with pytest.raises( RollCalcValidationError, match=f"field '{field_name}' must be a JSON number, got bool", ): load_rollcalc_articles(path) def test_duplicate_article_number_raises_validation_error(tmp_path: Path) -> None: path = write_json( tmp_path / "article-data.json", [valid_article(nr="00001"), valid_article(nr="1"), valid_article(nr="00001")], ) with pytest.raises( RollCalcValidationError, match="Duplicate article number '00001' at index 2; first occurrence at index 0", ): load_rollcalc_articles(path) def test_multiple_errors_report_first_deterministically(tmp_path: Path) -> None: path = write_json( tmp_path / "article-data.json", [ valid_article(name=123, thickness="6.722"), valid_article(nr="214700"), ], ) with pytest.raises(RollCalcValidationError) as exc_info: load_rollcalc_articles(path) message = str(exc_info.value) assert "index 0" in message assert "field 'name'" in message assert "thickness" not in message