🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-10-02 06:10:55 +02:00
commit 75cc7cde25
20 changed files with 3180 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
"""Tests for the Excel writer and reader (including round-trip)."""
from __future__ import annotations
from pathlib import Path
from openpyxl import load_workbook
from mitsubishi_label_converter.excel import (
HEADER_ROW,
META_SHEET_NAME,
_unique_sheet_names,
merge_sections,
write_workbook,
)
from mitsubishi_label_converter.excel_reader import read_workbook
from mitsubishi_label_converter.models import (
GlobalVarSection,
ProjectMetadata,
StructMember,
Variable,
)
def make_sections() -> list[GlobalVarSection]:
plain = GlobalVarSection(
name="Global",
retain=False,
variables=[
Variable("version", "D450", "STRING[12]", ""),
Variable(
"PLC_FEL_STA",
"",
"ST_Din_STA",
"",
line_number=39,
members=(
StructMember("Value", "D0.0"),
StructMember("Status", "D1"),
),
),
Variable("PLC_FEL", "", "FB_Din", "PLC error active", line_number=36),
],
)
retained = GlobalVarSection(
name="Global",
retain=True,
variables=[
Variable("PulseTot", "D600", "WORD", "", line_number=31, retain=True),
],
)
return [plain, retained]
def make_metadata() -> ProjectMetadata:
return ProjectMetadata(
file_header={
"companyName": "MITSUBISHI ELECTRIC CORPORATION",
"companyURL": "http://www.mitsubishielectric.com",
"productName": "GX Works3",
"productVersion": "1.126G",
"creationDateTime": "2026-10-01T20:59:19+02:00",
},
content_header={
"name": "21014JON-B14_20260930",
"modificationDateTime": "2026-10-01T20:57:58+02:00",
},
project_add_data={"projectPlcType": "FX5U"},
)
def test_merge_sections() -> None:
"""Two Global sections (plain + retain) merge into one section."""
merged = merge_sections(make_sections())
assert len(merged) == 1
assert merged[0].name == "Global"
assert [v.name for v in merged[0].variables] == [
"version",
"PLC_FEL_STA",
"PLC_FEL",
"PulseTot",
]
# per-variable retain flags survive the merge
assert [v.retain for v in merged[0].variables] == [False, False, False, True]
def test_unique_sheet_names_deduplicates() -> None:
sections = [
GlobalVarSection("Same", False, []),
GlobalVarSection("Same", True, []),
GlobalVarSection("Same", False, []),
]
merged = merge_sections(sections)
titles = _unique_sheet_names(merged)
assert len(titles) == len(set(titles))
assert len(titles) == 1
def test_unique_sheet_names_after_merge() -> None:
"""Different names never collide after merging."""
sections = [
GlobalVarSection("Alpha", False, []),
GlobalVarSection("Beta", True, []),
]
titles = _unique_sheet_names(merge_sections(sections))
assert titles == ["Alpha", "Beta"]
def test_write_workbook_creates_expected_sheets(tmp_path: Path) -> None:
"""Plain + retain sections merge into ONE worksheet plus Meta."""
out = tmp_path / "out.xlsx"
write_workbook(make_sections(), out, metadata=make_metadata())
wb = load_workbook(out)
assert wb.sheetnames == ["Global", META_SHEET_NAME]
def test_sheet_content_with_member_rows(tmp_path: Path) -> None:
out = tmp_path / "out.xlsx"
write_workbook(make_sections(), out, metadata=make_metadata())
wb = load_workbook(out)
ws = wb["Global"]
rows = list(ws.iter_rows(values_only=True))
assert rows[0] == tuple(HEADER_ROW)
# openpyxl reads cells that were written as "" back as None
assert rows[1] == ("version", "D450", "STRING[12]", None, None, "FALSE")
# structured variable row followed by its member rows
assert rows[2][:3] == ("PLC_FEL_STA", None, "ST_Din_STA")
assert rows[2][5] == "FALSE"
# members inherit the parent's retain flag (for filtering)
assert rows[3] == (
"PLC_FEL_STA.Value", "D0.0", None, None, None, "FALSE"
)
assert rows[4] == (
"PLC_FEL_STA.Status", "D1", None, None, None, "FALSE"
)
# next variable continues after the members
assert rows[5] == ("PLC_FEL", None, "FB_Din", "PLC error active", 36, "FALSE")
# the retained variable from the second section follows, flagged TRUE
assert rows[6] == ("PulseTot", "D600", "WORD", None, 31, "TRUE")
def test_meta_sheet_content(tmp_path: Path) -> None:
out = tmp_path / "out.xlsx"
write_workbook(make_sections(), out, metadata=make_metadata())
wb = load_workbook(out)
ws = wb[META_SHEET_NAME]
rows = list(ws.iter_rows(values_only=True))
meta = {k: v for k, v in rows[1:]}
assert meta["fileHeader.productName"] == "GX Works3"
assert meta["contentHeader.name"] == "21014JON-B14_20260930"
assert meta["project.projectPlcType"] == "FX5U"
# retain flags live in the Retain column now, not in Meta
assert meta["sheet.Global.name"] == "Global"
assert "sheet.Global.retain" not in meta
def test_round_trip_excel_reader(tmp_path: Path) -> None:
"""Write then read: sections and metadata must survive intact.
The reader must split the merged worksheet back into the original two
sections (plain first, retain second), matching the input. Member rows
carrying the inherited retain flag must still regroup correctly.
"""
out = tmp_path / "out.xlsx"
sections = make_sections()
metadata = make_metadata()
write_workbook(sections, out, metadata=metadata)
read_sections, read_meta = read_workbook(out)
assert read_sections == sections
assert read_meta == metadata
def test_member_rows_carry_retain_for_filtering(tmp_path: Path) -> None:
"""Every row — variable or member — must have a non-empty Retain cell
so the Retain column can be used for Excel filtering."""
out = tmp_path / "out.xlsx"
write_workbook(make_sections(), out, metadata=make_metadata())
wb = load_workbook(out)
ws = wb["Global"]
for row in ws.iter_rows(min_row=2, values_only=True):
assert row[5] in ("TRUE", "FALSE"), (
f"row {row[0]!r} has no retain flag"
)
def test_retain_members_of_retained_variable(tmp_path: Path) -> None:
"""A structured variable with retain=True flags all its members TRUE."""
out = tmp_path / "out.xlsx"
retained_struct = GlobalVarSection(
name="Global",
retain=True,
variables=[
Variable(
"SUML_DO",
"",
"ST_Dout_STA",
"",
line_number=5,
retain=True,
members=(
StructMember("Value", "D100.0"),
StructMember("Status", "D101"),
),
),
],
)
write_workbook([retained_struct], out)
wb = load_workbook(out)
rows = list(wb["Global"].iter_rows(values_only=True))
assert rows[1] == ("SUML_DO", None, "ST_Dout_STA", None, 5, "TRUE")
assert rows[2] == ("SUML_DO.Value", "D100.0", None, None, None, "TRUE")
assert rows[3] == ("SUML_DO.Status", "D101", None, None, None, "TRUE")
def test_round_trip_regroups_interleaved_retain_rows(tmp_path: Path) -> None:
"""Manually edited sheets: retain rows moved to the top must still
regroup into the correct sections."""
out = tmp_path / "out.xlsx"
write_workbook(make_sections(), out, metadata=make_metadata())
# Simulate an edit: move the retained variable to the top of the sheet.
from openpyxl import load_workbook as lwb
wb = lwb(out)
ws = wb["Global"]
header = [c.value for c in ws[1]]
rows = [r for r in ws.iter_rows(min_row=2, values_only=True)]
pulse_row = next(r for r in rows if r[0] == "PulseTot")
others = [r for r in rows if r[0] != "PulseTot"]
# delete all and rewrite with PulseTot first
ws.delete_rows(1, ws.max_row)
for row in [header] + [pulse_row] + others:
ws.append(list(row))
wb.save(out)
read_sections, _ = read_workbook(out)
# First section in the sheet is now the retain one (first-appearance order)
assert len(read_sections) == 2
assert [v.name for v in read_sections[0].variables] == ["PulseTot"]
assert read_sections[0].retain is True
assert [v.name for v in read_sections[1].variables] == [
"version",
"PLC_FEL_STA",
"PLC_FEL",
]
assert read_sections[1].retain is False
def test_sheet_name_max_31_chars() -> None:
long_name = "A" * 40
sections = [GlobalVarSection(long_name, False, [])]
titles = _unique_sheet_names(sections)
assert len(titles[0]) <= 31
assert titles[0].startswith("A")
def test_forbidden_characters_are_replaced() -> None:
sections = [GlobalVarSection("Global/Vars[1]:x", False, [])]
titles = _unique_sheet_names(sections)
for forbidden in "[]:*?/\\":
assert forbidden not in titles[0]
+175
View File
@@ -0,0 +1,175 @@
"""Tests for the PLCopen XML parser."""
from __future__ import annotations
from pathlib import Path
import pytest
from mitsubishi_label_converter.parser import (
parse_global_vars,
parse_project_metadata,
)
NS = "http://www.plcopen.org/xml/tc6_0201"
SAMPLE_XML = f"""<?xml version="1.0" encoding="utf-16"?>
<project xmlns="{NS}">
<instances>
<configurations>
<configuration name="">
<globalVars name="Global">
<variable name="Always_ON" address="SM400">
<type><BOOL/></type>
<addData>
<data name="http://www.mitsubishielectric.com/xml/variableLineNumber"
handleUnknown="implementation">
<variableLineNumber number="3"/>
</data>
</addData>
</variable>
<variable name="version" address="D450">
<type><string length="12"/></type>
</variable>
<variable name="PLC_FEL">
<type><derived name="FB_Din"/></type>
<addData>
<data name="http://www.mitsubishielectric.com/xml/variableComments"
handleUnknown="implementation">
<variableComments>
<comment number="1">
<html xml:space="preserve"
xmlns="http://www.w3.org/1999/xhtml">PLC error active</html>
</comment>
</variableComments>
</data>
</addData>
</variable>
<variable name="PLC_FEL_STA">
<type><derived name="ST_Din_STA"/></type>
<addData>
<data name="http://www.mitsubishielectric.com/xml/variableStructDeviceAssignment"
handleUnknown="implementation">
<variableStructDeviceAssignment>
<member name="Value" address="D0.0"/>
<member name="Status" address="D1"/>
</variableStructDeviceAssignment>
</data>
</addData>
</variable>
</globalVars>
<globalVars name="Global" retain="true">
<variable name="PulseTot" address="D600">
<type><WORD/></type>
</variable>
</globalVars>
</configuration>
</configurations>
</instances>
</project>
"""
@pytest.fixture()
def sample_file(tmp_path: Path) -> Path:
"""Write the sample XML as UTF-16 LE, like GX Works3 does."""
xml_path = tmp_path / "Global.xml"
xml_path.write_bytes(SAMPLE_XML.encode("utf-16"))
return xml_path
def test_parses_two_sections(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
assert len(sections) == 2
assert all(section.name == "Global" for section in sections)
def test_retain_flag_is_parsed(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
assert sections[0].retain is False
assert sections[1].retain is True
def test_primitive_type(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
first = sections[0].variables[0]
assert first.name == "Always_ON"
assert first.address == "SM400"
assert first.type_name == "BOOL"
assert first.comment == ""
def test_string_type_includes_length(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
version = sections[0].variables[1]
assert version.name == "version"
assert version.type_name == "STRING[12]"
def test_derived_type_uses_name(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
plc_fel = sections[0].variables[2]
assert plc_fel.address == "" # derived FB variable without device address
assert plc_fel.type_name == "FB_Din"
sta = sections[0].variables[3]
assert sta.type_name == "ST_Din_STA"
assert [(m.name, m.address) for m in sta.members] == [
("Value", "D0.0"),
("Status", "D1"),
]
def test_comment_extraction(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
plc_fel = sections[0].variables[2]
assert plc_fel.comment == "PLC error active"
def test_struct_members_are_parsed(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
# PLC_FEL (plain derived FB) has no member assignments
assert sections[0].variables[2].members == ()
def test_struct_members_from_real_file() -> None:
asset = Path(__file__).parents[1] / "assets" / "Global.xml"
if not asset.is_file():
pytest.skip("assets/Global.xml not present")
sections = parse_global_vars(asset)
structured = [v for s in sections for v in s.variables if v.members]
assert len(structured) == 189
sta = next(v for v in structured if v.name == "PLC_FEL_STA")
assert sta.type_name == "ST_Din_STA"
assert sta.line_number == 39
assert (sta.members[0].name, sta.members[0].address) == ("Value", "D0.0")
def test_project_metadata_from_real_file() -> None:
asset = Path(__file__).parents[1] / "assets" / "Global.xml"
if not asset.is_file():
pytest.skip("assets/Global.xml not present")
meta = parse_project_metadata(asset)
assert meta.file_header["productName"] == "GX Works3"
assert meta.content_header["name"] == "21014JON-B14_20260930"
assert meta.project_add_data["projectPlcType"] == "FX5U"
def test_line_number_is_parsed(sample_file: Path) -> None:
sections = parse_global_vars(sample_file)
always_on = sections[0].variables[0]
assert always_on.line_number == 3
def test_real_world_global_xml() -> None:
"""End-to-end check against the real asset in the repository."""
asset = Path(__file__).parents[1] / "assets" / "Global.xml"
if not asset.is_file():
pytest.skip("assets/Global.xml not present")
sections = parse_global_vars(asset)
assert len(sections) == 2
# 174/118 raw child elements include one <addData> (varListProperties) each
assert [len(s.variables) for s in sections] == [173, 117]
version_row = sections[0].variables[0]
assert version_row.name == "version"
assert version_row.address == "D450"
assert version_row.type_name == "STRING[12]"
+197
View File
@@ -0,0 +1,197 @@
"""Tests for the SDT (structured data type) parser and type enrichment."""
from __future__ import annotations
from pathlib import Path
import pytest
from mitsubishi_label_converter.excel_reader import read_workbook
from mitsubishi_label_converter.excel import write_workbook
from mitsubishi_label_converter.models import (
GlobalVarSection,
ProjectMetadata,
StructMember,
Variable,
)
from mitsubishi_label_converter.parser import parse_global_vars
from mitsubishi_label_converter.sdt_parser import (
apply_struct_types,
parse_struct_definitions,
)
NS = "http://www.plcopen.org/xml/tc6_0201"
SDT_XML = f"""<?xml version="1.0" encoding="utf-16"?>
<project xmlns="{NS}">
<types>
<dataTypes>
<dataType name="ST_Din_STA">
<baseType>
<struct>
<variable name="Value"><type><BOOL/></type></variable>
<variable name="Status"><type><WORD/></type></variable>
<variable name="Name">
<type><string length="16"/></type>
</variable>
<variable name="Levels">
<type><array><dimension lower="1" upper="4"/>
<baseType><INT/></baseType></array></type>
</variable>
<variable name="Sub"><type><derived name="ST_Sub"/></type></variable>
</struct>
</baseType>
</dataType>
<dataType name="ST_Sub">
<baseType>
<struct>
<variable name="Flag"><type><BOOL/></type></variable>
</struct>
</baseType>
</dataType>
</dataTypes>
</types>
</project>
"""
GLOBAL_XML = f"""<?xml version="1.0" encoding="utf-16"?>
<project xmlns="{NS}">
<instances>
<configurations>
<configuration name="">
<globalVars name="Global">
<variable name="DI4">
<type><derived name="ST_Din_STA"/></type>
<addData>
<data name="http://www.mitsubishielectric.com/xml/variableStructDeviceAssignment"
handleUnknown="implementation">
<variableStructDeviceAssignment>
<member name="Value" address="D0.0"/>
<member name="Status" address="D1"/>
<member name="Name" address="D10"/>
<member name="Levels" address="D20"/>
<member name="Sub" address="D30"/>
<member name="Unknown" address="D40"/>
</variableStructDeviceAssignment>
</data>
</addData>
</variable>
<variable name="FB_Instance">
<type><derived name="FB_Din"/></type>
<addData>
<data name="http://www.mitsubishielectric.com/xml/variableStructDeviceAssignment"
handleUnknown="implementation">
<variableStructDeviceAssignment>
<member name="Value" address="R0.0"/>
</variableStructDeviceAssignment>
</data>
</addData>
</variable>
</globalVars>
</configuration>
</configurations>
</instances>
</project>
"""
@pytest.fixture()
def sdt_file(tmp_path: Path) -> Path:
path = tmp_path / "SDT.xml"
path.write_bytes(SDT_XML.encode("utf-16"))
return path
@pytest.fixture()
def global_file(tmp_path: Path) -> Path:
path = tmp_path / "Global.xml"
path.write_bytes(GLOBAL_XML.encode("utf-16"))
return path
def test_parse_struct_definitions(sdt_file: Path) -> None:
definitions = parse_struct_definitions(sdt_file)
assert definitions["ST_Din_STA"] == {
"Value": "BOOL",
"Status": "WORD",
"Name": "STRING[16]",
"Levels": "ARRAY[1..4] OF INT",
"Sub": "ST_Sub",
}
assert definitions["ST_Sub"] == {"Flag": "BOOL"}
def test_apply_struct_types(global_file: Path, sdt_file: Path) -> None:
sections = parse_global_vars(global_file)
definitions = parse_struct_definitions(sdt_file)
apply_struct_types(sections, definitions)
di4 = sections[0].variables[0]
assert [(m.name, m.type_name) for m in di4.members] == [
("Value", "BOOL"),
("Status", "WORD"),
("Name", "STRING[16]"),
("Levels", "ARRAY[1..4] OF INT"),
("Sub", "ST_Sub"),
("Unknown", ""), # not part of the struct definition
]
# FB_Din is not defined in the SDT file: members stay untyped
fb = sections[0].variables[1]
assert [(m.name, m.type_name) for m in fb.members] == [("Value", "")]
def test_excel_round_trip_with_member_types(
global_file: Path, sdt_file: Path, tmp_path: Path
) -> None:
"""Members with types survive the Excel write/read round-trip."""
sections = parse_global_vars(global_file)
definitions = parse_struct_definitions(sdt_file)
apply_struct_types(sections, definitions)
out = tmp_path / "out.xlsx"
write_workbook(sections, out, metadata=ProjectMetadata())
read_sections, _ = read_workbook(out)
assert read_sections == sections
def test_real_files_sdt_enrichment() -> None:
"""Enrich the real Global.xml with the real SDT.xml and verify."""
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():
pytest.skip("assets not present")
sections = parse_global_vars(global_xml)
definitions = parse_struct_definitions(sdt_xml)
# All 31 struct types carrying device assignments are ST_* and defined
# in SDT.xml (FB_* instances have no device-assigned members).
used = {v.type_name for s in sections for v in s.variables if v.members}
assert len(used) == 31
assert all(name.startswith("ST_") for name in used)
assert used <= set(definitions)
apply_struct_types(sections, definitions)
typed = sum(
1
for s in sections
for v in s.variables
for m in v.members
if m.type_name
)
total = sum(len(v.members) for s in sections for v in s.variables)
# Every device-assigned member belongs to an ST_* type defined in the
# SDT file, so every member gets a type.
assert typed == total
def test_real_sdt_definition_sample() -> None:
assets = Path(__file__).parents[1] / "assets"
sdt_xml = assets / "SDT.xml"
if not sdt_xml.is_file():
pytest.skip("assets/SDT.xml not present")
definitions = parse_struct_definitions(sdt_xml)
assert len(definitions) == 164
assert definitions["ST_Din_STA"]["Value"] == "BOOL"
assert definitions["ST_Din_STA"]["Status"] == "WORD"
+347
View File
@@ -0,0 +1,347 @@
"""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
+154
View File
@@ -0,0 +1,154 @@
"""Tests for the XML writer (Excel data -> GX Works3 XML)."""
from __future__ import annotations
from pathlib import Path
from mitsubishi_label_converter.models import (
GlobalVarSection,
ProjectMetadata,
StructMember,
Variable,
)
from mitsubishi_label_converter.parser import (
parse_global_vars,
parse_project_metadata,
)
from mitsubishi_label_converter.xml_writer import write_xml
def make_sections() -> list[GlobalVarSection]:
return [
GlobalVarSection(
name="Global",
retain=False,
variables=[
Variable("Always_ON", "SM400", "BOOL", "", line_number=3),
Variable("version", "D450", "STRING[12]", ""),
Variable(
"PLC_FEL_STA",
"",
"ST_Din_STA",
"",
line_number=39,
members=(
StructMember("Value", "D0.0"),
StructMember("Status", "D1"),
),
),
Variable(
"Switch_status",
"SD200",
"INT",
"CPU switch status (0: RUN, 1: STOP)",
line_number=14,
),
],
),
GlobalVarSection(
name="Global",
retain=True,
variables=[
Variable(
"PulseTot", "D600", "WORD", "", line_number=31, retain=True
),
],
),
]
def make_metadata() -> ProjectMetadata:
return ProjectMetadata(
file_header={
"companyName": "MITSUBISHI ELECTRIC CORPORATION",
"companyURL": "http://www.mitsubishielectric.com",
"productName": "GX Works3",
"productVersion": "1.126G",
"creationDateTime": "2026-10-01T20:59:19+02:00",
},
content_header={
"name": "21014JON-B14_20260930",
"modificationDateTime": "2026-10-01T20:57:58+02:00",
},
project_add_data={"projectPlcType": "FX5U"},
)
def test_full_round_trip(tmp_path: Path) -> None:
"""XML -> sections -> XML: the parsed data must be identical."""
out = tmp_path / "regenerated.xml"
sections = make_sections()
write_xml(sections, make_metadata(), out)
# The regenerated file must parse back to the same sections.
reparsed = parse_global_vars(out)
assert reparsed == sections
meta = parse_project_metadata(out)
assert meta.file_header == make_metadata().file_header
assert meta.content_header == make_metadata().content_header
assert meta.project_add_data == make_metadata().project_add_data
def test_output_is_utf16_le(tmp_path: Path) -> None:
out = tmp_path / "regenerated.xml"
write_xml(make_sections(), make_metadata(), out)
raw = out.read_bytes()
# UTF-16 LE BOM
assert raw[:2] == b"\xff\xfe"
# XML declaration must declare utf-16
assert raw.decode("utf-16").startswith('<?xml version="1.0" encoding="utf-16"?>')
def test_retain_flag_written(tmp_path: Path) -> None:
out = tmp_path / "regenerated.xml"
write_xml(make_sections(), make_metadata(), out)
text = out.read_text(encoding="utf-16")
assert '<globalVars name="Global" retain="true">' in text
# the plain section: retain attribute must NOT be present
assert '<globalVars name="Global">' in text
def test_member_addresses_written(tmp_path: Path) -> None:
out = tmp_path / "regenerated.xml"
write_xml(make_sections(), make_metadata(), out)
text = out.read_text(encoding="utf-16")
assert 'member name="Value" address="D0.0"' in text
assert 'member name="Status" address="D1"' in text
def test_comment_written(tmp_path: Path) -> None:
out = tmp_path / "regenerated.xml"
write_xml(make_sections(), make_metadata(), out)
reparsed = parse_global_vars(out)
switch_status = reparsed[0].variables[3]
assert switch_status.comment == "CPU switch status (0: RUN, 1: STOP)"
def test_line_numbers_written(tmp_path: Path) -> None:
out = tmp_path / "regenerated.xml"
write_xml(make_sections(), make_metadata(), out)
reparsed = parse_global_vars(out)
assert [v.line_number for v in reparsed[0].variables] == [3, None, 39, 14]
def test_real_file_full_round_trip(tmp_path: Path) -> None:
"""The real asset: XML -> sections -> XML -> sections must be stable."""
asset = Path(__file__).parents[1] / "assets" / "Global.xml"
if not asset.is_file():
import pytest
pytest.skip("assets/Global.xml not present")
original_sections = parse_global_vars(asset)
original_meta = parse_project_metadata(asset)
out = tmp_path / "regenerated.xml"
write_xml(original_sections, original_meta, out)
reparsed_sections = parse_global_vars(out)
reparsed_meta = parse_project_metadata(out)
assert reparsed_sections == original_sections
assert reparsed_meta.file_header == original_meta.file_header
assert reparsed_meta.content_header == original_meta.content_header
assert reparsed_meta.project_add_data == original_meta.project_add_data