🎉 Initial commit
This commit is contained in:
@@ -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]
|
||||
@@ -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]"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user