Add RollCalc JSON importer
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
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
|
||||
Reference in New Issue
Block a user