Files
mitsubishi-label-converter-py/tests/test_validator.py
T
2026-10-02 06:10:55 +02:00

347 lines
9.7 KiB
Python

"""Tests for the validation report."""
from __future__ import annotations
from mitsubishi_label_converter.models import (
GlobalVarSection,
StructMember,
Variable,
)
from mitsubishi_label_converter.validator import (
SEVERITY_ERROR,
SEVERITY_INFO,
SEVERITY_WARNING,
parse_address,
validate,
word_size,
)
def var(
name: str,
address: str = "",
type_name: str = "BOOL",
members: tuple[StructMember, ...] = (),
**kwargs,
) -> Variable:
return Variable(
name=name,
address=address,
type_name=type_name,
comment="",
members=members,
**kwargs,
)
def section(variables: list[Variable], name: str = "Global") -> GlobalVarSection:
return GlobalVarSection(name=name, retain=False, variables=variables)
def codes(report, severity: str) -> set[str]:
return {f.code for f in report.findings if f.severity == severity}
# ---------------------------------------------------------------- parse_address
def test_parse_address_word_level() -> None:
addr = parse_address("D450")
assert addr is not None
assert (addr.device, addr.register, addr.bit) == ("D", 450, None)
def test_parse_address_bit_decimal() -> None:
addr = parse_address("D6005.1")
assert addr is not None
assert (addr.device, addr.register, addr.bit) == ("D", 6005, 1)
def test_parse_address_bit_hex() -> None:
addr = parse_address("D201.A")
assert addr is not None
assert (addr.device, addr.register, addr.bit) == ("D", 201, 10)
def test_parse_address_special_devices() -> None:
assert parse_address("SM400") is not None
assert parse_address("SD8005") is not None
assert parse_address("R5699.0") is not None
def test_parse_address_invalid() -> None:
assert parse_address("") is None
assert parse_address("D") is None
assert parse_address("D45.0.1") is None
assert parse_address("not an address") is None
# ---------------------------------------------------------------- word_size
def test_word_size_primitives() -> None:
assert word_size("BOOL") == 1
assert word_size("INT") == 1
assert word_size("WORD") == 1
assert word_size("DINT") == 2
assert word_size("REAL") == 2
assert word_size("LREAL") == 4
def test_word_size_strings() -> None:
assert word_size("STRING[12]") == 7 # (12 + 1 + 1) // 2
assert word_size("STRING[11]") == 6
assert word_size("STRING[0]") == 1
assert word_size("WSTRING[10]") == 11
def test_word_size_unknown() -> None:
assert word_size("ST_Din_STA") is None # derived: no assumption
assert word_size("") is None
# ---------------------------------------------------------------- name checks
def test_duplicate_names_reported_as_error() -> None:
report = validate(
[
section(
[
var("Always_ON", "SM400"),
var("Always_ON", "SM401"),
]
)
]
)
assert "NAME_DUPLICATE" in codes(report, SEVERITY_ERROR)
assert report.has_errors
def test_unique_names_clean() -> None:
report = validate([section([var("A", "D1"), var("B", "D2")])])
assert "NAME_DUPLICATE" not in codes(report, SEVERITY_ERROR)
assert not report.has_errors
# ---------------------------------------------------------------- address checks
def test_duplicate_addresses_reported_as_error() -> None:
report = validate(
[section([var("A", "D100"), var("B", "D100")])]
)
assert "ADDR_DUPLICATE" in codes(report, SEVERITY_ERROR)
def test_same_variable_member_bits_not_flagged() -> None:
"""Bits of the same variable sharing a word are normal, not findings."""
report = validate(
[
section(
[
var(
"STA",
type_name="ST_Din_STA",
members=(
StructMember("Value", "D0.0"),
StructMember("AutoMan", "D0.1"),
StructMember("Status", "D1"),
),
)
]
)
]
)
assert "ADDR_DUPLICATE" not in codes(report, SEVERITY_ERROR)
assert "ADDR_BIT_IN_WORD" not in codes(report, SEVERITY_WARNING)
assert "ADDR_OVERLAP" not in codes(report, SEVERITY_WARNING)
def test_bit_inside_other_variables_word_reported() -> None:
report = validate(
[
section(
[
var("Word", "D100", "WORD"),
var(
"Sta",
type_name="ST_X",
members=(StructMember("Flag", "D100.3"),),
),
]
)
]
)
assert "ADDR_BIT_IN_WORD" in codes(report, SEVERITY_WARNING)
def test_overlapping_word_ranges_reported() -> None:
report = validate(
[
section(
[
var("Str", "D100", "STRING[12]"), # occupies D100..D106
var("Other", "D104", "WORD"),
]
)
]
)
assert "ADDR_OVERLAP" in codes(report, SEVERITY_WARNING)
def test_different_devices_do_not_overlap() -> None:
report = validate(
[
section(
[
var("A", "D100"),
var("B", "R100"),
var("C", "SD200"),
]
)
]
)
assert "ADDR_OVERLAP" not in codes(report, SEVERITY_WARNING)
assert "ADDR_DUPLICATE" not in codes(report, SEVERITY_ERROR)
def test_unparseable_address_reported() -> None:
report = validate([section([var("Weird", "???")])])
assert "ADDR_FORMAT" in codes(report, SEVERITY_WARNING)
# ---------------------------------------------------------------- struct checks
def test_unknown_member_reported() -> None:
definitions = {"ST_X": {"Flag": "BOOL"}}
report = validate(
[
section(
[
var(
"Sta",
type_name="ST_X",
members=(StructMember("Typo", "D0.0"),),
)
]
)
],
definitions,
)
assert "MEMBER_UNKNOWN" in codes(report, SEVERITY_WARNING)
def test_unassigned_struct_member_reported_as_info() -> None:
definitions = {"ST_X": {"Flag": "BOOL", "Missing": "WORD"}}
report = validate(
[
section(
[
var(
"Sta",
type_name="ST_X",
members=(StructMember("Flag", "D0.0"),),
)
]
)
],
definitions,
)
assert "MEMBER_UNASSIGNED" in codes(report, SEVERITY_INFO)
def test_struct_without_device_assignment_reported() -> None:
definitions = {"ST_X": {"Flag": "BOOL"}}
report = validate(
[section([var("Sta", type_name="ST_X")])],
definitions,
)
assert "STRUCT_UNASSIGNED" in codes(report, SEVERITY_INFO)
def test_unknown_derived_type_aggregated_once() -> None:
"""One finding per unknown type, with the variable count — not per var."""
# a non-empty SDT (one known type) enables the struct checks
definitions = {"ST_Known": {"Flag": "BOOL"}}
report = validate(
[
section(
[
var("A", type_name="FB_Din"),
var("B", type_name="FB_Din"),
var("C", type_name="FB_Din"),
]
)
],
definitions,
)
type_findings = [
f for f in report.findings if f.code == "TYPE_UNKNOWN"
]
assert len(type_findings) == 1
assert "used by 3 variable(s)" in type_findings[0].message
def test_without_sdt_no_struct_findings() -> None:
"""Without SDT definitions, struct checks are skipped entirely."""
report = validate(
[
section(
[
var(
"Sta",
type_name="ST_X",
members=(StructMember("Flag", "D0.0"),),
)
]
)
]
)
assert not report.findings
# ---------------------------------------------------------------- report text
def test_format_text_renders_groups() -> None:
report = validate(
[
section(
[
var("A", "D1"),
var("A", "D2"), # duplicate name (different addresses)
var("B", "D1"), # duplicate address of var A
]
)
]
)
text = report.format_text()
assert "2 error(s)" in text
assert "ERRORS (2)" in text
assert "[NAME_DUPLICATE]" in text
assert "[ADDR_DUPLICATE]" in text
def test_clean_report_text() -> None:
report = validate([section([var("A", "D1")])])
assert "No findings." in report.format_text()
def test_real_file_is_clean() -> None:
"""The shipped Global.xml + SDT.xml must produce no errors/warnings."""
from pathlib import Path
from mitsubishi_label_converter.parser import parse_global_vars
from mitsubishi_label_converter.sdt_parser import parse_struct_definitions
assets = Path(__file__).parents[1] / "assets"
global_xml = assets / "Global.xml"
sdt_xml = assets / "SDT.xml"
if not global_xml.is_file() or not sdt_xml.is_file():
import pytest
pytest.skip("assets not present")
sections = parse_global_vars(global_xml)
definitions = parse_struct_definitions(sdt_xml)
report = validate(sections, definitions)
assert report.error_count == 0
assert report.warning_count == 0
# only the known informational findings remain
assert report.info_count == 13