347 lines
9.7 KiB
Python
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 |