"""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