@tsplc/build 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1696 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1683 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +18 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/edge-gateway/Dockerfile +17 -0
  17. package/docker/entrypoint.sh +10 -0
  18. package/docker/install/NOTICE.md +21 -0
  19. package/docker/install/core.sh +36 -0
  20. package/docker/install/decode.c +12 -0
  21. package/docker/install/device/Runtime.ico +0 -0
  22. package/docker/install/device/Runtime.png +0 -0
  23. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  24. package/docker/install/extract.py +43 -0
  25. package/docker/install/helper-launcher.c +46 -0
  26. package/docker/install/install.sh +94 -0
  27. package/docker/install/prerequisites.sh +15 -0
  28. package/docker/install/verify.py +133 -0
  29. package/docker/install/verify.sh +34 -0
  30. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  31. package/docker/install/wine-ncrypt/README.md +28 -0
  32. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  33. package/docker/install/wine-ncrypt/main.c +619 -0
  34. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  35. package/docker/install/wine-signal/README.md +22 -0
  36. package/docker/install/wine-signal/build.sh +10 -0
  37. package/docker/install/wine-signal/check.sh +34 -0
  38. package/docker/install/wine-signal/noop-i386.c +2 -0
  39. package/docker/install/wine-signal/signal-state.c +105 -0
  40. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  41. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  42. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  43. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  44. package/docker/install/wine-signal/wine +4 -0
  45. package/docker/runner.ts +156 -0
  46. package/package.json +53 -4
  47. package/python/build.py +660 -0
  48. package/python/codesys_login.py +100 -0
  49. package/python/codesys_online.py +327 -0
  50. package/python/test_build.py +444 -0
  51. package/python/test_codesys_login.py +120 -0
  52. package/python/test_codesys_online.py +184 -0
  53. package/python/test_installer_packages.py +216 -0
  54. package/python/test_installer_verification.py +307 -0
  55. package/python/test_worker.py +142 -0
  56. package/python/worker.py +102 -0
  57. package/runtime/install.sh +74 -0
  58. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  59. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  60. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  61. package/src/artifacts.ts +77 -0
  62. package/src/build.ts +87 -0
  63. package/src/cli.ts +116 -0
  64. package/src/config.ts +12 -0
  65. package/src/controller-online.ts +297 -0
  66. package/src/controller-session.ts +298 -0
  67. package/src/diagnostics.ts +68 -0
  68. package/src/docker-build.ts +163 -0
  69. package/src/docker-context.ts +35 -0
  70. package/src/docker-run.ts +103 -0
  71. package/src/host/codesys-command.ts +45 -0
  72. package/src/host/codesys-host.ts +145 -0
  73. package/src/host/codesys-worker.ts +348 -0
  74. package/src/host/windows-path.ts +73 -0
  75. package/src/index.ts +43 -0
  76. package/src/io.ts +127 -0
  77. package/src/libraries.ts +166 -0
  78. package/src/package-path.ts +5 -0
  79. package/src/run.ts +133 -0
  80. package/src/runtime-status.ts +21 -0
  81. package/src/sources.ts +248 -0
  82. package/src/st/declarations.ts +229 -0
  83. package/src/st/lexer.ts +125 -0
  84. package/src/st/parse.ts +202 -0
  85. package/src/st/reader.ts +50 -0
@@ -0,0 +1,444 @@
1
+ import hashlib
2
+ import json
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ from unittest.mock import Mock
8
+ from decimal import Decimal
9
+ from build import CompilerError, build, diagnostic
10
+
11
+
12
+ class Calls(list):
13
+ def __init__(self, task):
14
+ self.task = task
15
+ super().__init__(child.name for child in task.children)
16
+ def add(self, name):
17
+ self.task.create(name, "task_call")
18
+
19
+
20
+ class Node:
21
+ def __init__(self, name, kind, parent=None):
22
+ self.name, self.kind, self.parent = name, kind, parent
23
+ self.children = []
24
+ self.declaration, self.implementation = "", ""
25
+ self.textual_declaration = SimpleNamespace(replace=lambda text: setattr(self, "declaration", text))
26
+ self.textual_implementation = SimpleNamespace(replace=lambda text: setattr(self, "implementation", text))
27
+ self.is_folder = kind == "folder"
28
+ self.is_task = kind == "task"
29
+ self.is_task_configuration = kind == "task_configuration"
30
+ self.is_symbol_config = kind == "symbols"
31
+ if self.is_task:
32
+ self.kind_of_task, self.priority, self.interval, self.interval_unit = 1, "1", "20", "ms"
33
+ self.create("PLC_PRG", "task_call")
34
+ @property
35
+ def pous(self):
36
+ # Match the engine: list.remove changes the snapshot, not the task tree.
37
+ return Calls(self)
38
+ @property
39
+ def calls(self):
40
+ return [child.name for child in self.children]
41
+
42
+ def get_name(self):
43
+ return self.name
44
+ def get_children(self):
45
+ return self.children
46
+ def remove(self):
47
+ self.parent.children.remove(self)
48
+ def find_ignore_transient_objects(self, name, recursive):
49
+ matches = [child for child in self.children if child.kind != "task_call" and child.name.lower() == name.lower()]
50
+ if recursive:
51
+ for child in self.children:
52
+ matches.extend(child.find_ignore_transient_objects(name, True))
53
+ return matches
54
+ def create(self, name, kind):
55
+ child = Node(name, kind, self)
56
+ self.children.append(child)
57
+ return child
58
+ def create_task_configuration(self): return self.create("Task Configuration", "task_configuration")
59
+ def create_task(self, name): return self.create(name, "task")
60
+ def create_folder(self, name):
61
+ self.create(name, "folder")
62
+ def create_program(self, name): return self.create(name, "program")
63
+ def create_function_block(self, name): return self.create(name, "function_block")
64
+ def create_function(self, name, return_type): return self.create(name, "function")
65
+ def create_gvl(self, name): return self.create(name, "gvl")
66
+ def create_dut(self, name, kind, base): return self.create(name, kind)
67
+ def create_interface(self, name): return self.create(name, "interface")
68
+ def create_method(self, name, return_type): return self.create(name, "method")
69
+ def create_action(self, name): return self.create(name, "action")
70
+ def create_property(self, name, return_type):
71
+ prop = self.create(name, "property")
72
+ prop.create("Get", "get")
73
+ prop.create("Set", "set")
74
+ return prop
75
+
76
+
77
+ class Controller:
78
+ """The template's controller; Update Device succeeds only for installed descriptions."""
79
+ is_device = True
80
+ def __init__(self, installed):
81
+ self.installed = installed
82
+ self.identity = SimpleNamespace(type=4096, id="0000 1008", version="4.22.0.0")
83
+ def get_device_identification(self):
84
+ return self.identity
85
+ def update(self, type, id, version):
86
+ if (type, id, version) in self.installed:
87
+ self.identity = SimpleNamespace(type=type, id=id, version=version)
88
+
89
+
90
+ LINUX_SL = dict(type=4102, id="0000 0005", version="4.22.0.0")
91
+
92
+
93
+ class CompilerFixture:
94
+ def __init__(self):
95
+ self.opened = []
96
+ self.installed = {(4102, "0000 0005", "4.22.0.0")}
97
+ self.fail_compile = False
98
+ self.empty_output = False
99
+ self.engine = SimpleNamespace(
100
+ projects=SimpleNamespace(open=self.open),
101
+ DutType=SimpleNamespace(Structure="structure", Enumeration="enumeration", Union="union", Alias="alias"),
102
+ Severity=SimpleNamespace(Error=1, FatalError=2),
103
+ KindOfTask=SimpleNamespace(Cyclic=1),
104
+ system=SimpleNamespace(get_message_categories=lambda: ["compiler"], get_message_objects=lambda category, severity: [], clear_messages=Mock()),
105
+ )
106
+ def open(self, path, primary):
107
+ app = Node("Application", "application")
108
+ configuration = app.create_task_configuration()
109
+ configuration.create_task("MainTask")
110
+ background = configuration.create_task("BackgroundTask")
111
+ background.children.clear()
112
+ background.pous.add("Monitor")
113
+ app.create("Monitor", "program")
114
+ app.create("Symbols", "symbols")
115
+ app.create("PLC_PRG", "program")
116
+ controller = Controller(self.installed)
117
+ project = SimpleNamespace(path=path, close=Mock(), active_application=app, controller=controller,
118
+ get_children=lambda: [controller])
119
+ def save(): Path(project.path).write_text("compiled project")
120
+ def save_as(destination):
121
+ project.path = destination
122
+ save()
123
+ def rebuild():
124
+ if self.fail_compile: raise RuntimeError("Fixture compiler error")
125
+ def export(destination, update, compact):
126
+ Path(destination).write_text("" if self.empty_output else "boot application")
127
+ project.save, project.save_as = save, save_as
128
+ app.clean, app.rebuild, app.generate_code = Mock(), rebuild, Mock()
129
+ app.create_boot_application = export
130
+ self.opened.append(project)
131
+ return project
132
+
133
+
134
+ def spec(kind, name, children=None, folder=None, **kwargs):
135
+ return dict(kind=kind, name=name, declaration="source " + name, children=children or [], folder=folder or [], **kwargs)
136
+
137
+
138
+ class BuildTests(unittest.TestCase):
139
+ def setUp(self):
140
+ self.temp = tempfile.TemporaryDirectory()
141
+ self.addCleanup(self.temp.cleanup)
142
+ self.root = Path(self.temp.name)
143
+ self.template = self.root / "Template.project"
144
+ self.template.write_text("original template")
145
+ self.compiler = CompilerFixture()
146
+ self.loaded = {}
147
+ self.counter = 0
148
+ self.objects = [spec("program", "Main", implementation="RETURN;")]
149
+ def request(self, **overrides):
150
+ self.counter += 1
151
+ run = self.root / str(self.counter)
152
+ run.mkdir()
153
+ request = dict(template=str(self.template), templateHash=hashlib.sha256(self.template.read_bytes()).hexdigest(),
154
+ project=str(run / "Application.project"), output=str(run / "Application.app"), receipt=str(run / "compiled.json"),
155
+ objects=self.objects, entry="Main", task="MainTask", symbols=False, removeObjects=["PLC_PRG"], mode="build")
156
+ request.update(overrides)
157
+ return request
158
+ def run_build(self, **overrides):
159
+ request = self.request(**overrides)
160
+ build(self.compiler.engine, request, self.loaded, str(self.root))
161
+ return request
162
+ def find(self, name):
163
+ return self.loaded["project"].active_application.find_ignore_transient_objects(name, True)[0]
164
+ def test_imports_program_and_rewires_only_the_configured_task(self):
165
+ request = self.run_build()
166
+ self.assertEqual(self.find("Main").implementation, "RETURN;")
167
+ self.assertEqual(self.find("MainTask").calls, ["Main"])
168
+ self.assertEqual(self.find("BackgroundTask").calls, ["Monitor"])
169
+ self.assertTrue(Path(request["receipt"]).exists())
170
+ self.assertEqual(self.template.read_text(), "original template")
171
+ def test_explicit_tasks_replace_the_complete_schedule_and_preserve_program_order(self):
172
+ self.objects.append(spec("program", "Second", implementation="RETURN;"))
173
+ tasks = [dict(name="MainTask", intervalNanoseconds="10000000", priority=0, programs=["Second", "Main"])]
174
+ request = self.run_build(tasks=tasks)
175
+ task = self.find("MainTask")
176
+ self.assertEqual((task.kind_of_task, task.interval, task.interval_unit, task.priority), (1, "10", "ms", "0"))
177
+ self.assertEqual(task.calls, ["Second", "Main"])
178
+ self.assertEqual(self.loaded["project"].active_application.find_ignore_transient_objects("BackgroundTask", True), [])
179
+ self.assertEqual(json.loads(Path(request["receipt"]).read_text())["tasks"], tasks)
180
+ def test_creates_missing_tasks_and_reloads_for_schedule_changes(self):
181
+ self.objects.append(spec("program", "Second", implementation="RETURN;"))
182
+ tasks = [dict(name="FastTask", intervalNanoseconds="10000000", priority=1, programs=["Main"]),
183
+ dict(name="SlowTask", intervalNanoseconds="40000000", priority=31, programs=["Second"])]
184
+ self.run_build(tasks=tasks)
185
+ self.assertEqual(self.find("FastTask").calls, ["Main"])
186
+ self.assertEqual(self.find("SlowTask").interval, "40")
187
+ self.objects[0]["implementation"] = "Changed();"
188
+ self.run_build(tasks=tasks)
189
+ self.assertEqual(len(self.compiler.opened), 1)
190
+ tasks[0]["intervalNanoseconds"] = "20000000"
191
+ self.run_build(tasks=tasks)
192
+ self.assertEqual(len(self.compiler.opened), 2)
193
+ tasks[0]["priority"] = 2
194
+ self.run_build(tasks=tasks)
195
+ self.assertEqual(len(self.compiler.opened), 3)
196
+ tasks[0]["programs"], tasks[1]["programs"] = ["Second"], ["Main"]
197
+ self.run_build(tasks=tasks)
198
+ self.assertEqual(len(self.compiler.opened), 4)
199
+ def test_explicit_tasks_reject_invalid_or_missing_programs(self):
200
+ for changes in [dict(intervalNanoseconds="1"), dict(intervalNanoseconds="4294967296000000"), dict(priority=32), dict(programs=[]), dict(programs=["Monitor"]), dict(programs=["Main", "Main"])]:
201
+ task = dict(name="MainTask", intervalNanoseconds="20000000", priority=1, programs=["Main"])
202
+ task.update(changes)
203
+ with self.subTest(changes=changes), self.assertRaises(RuntimeError):
204
+ self.run_build(tasks=[task])
205
+ def test_failed_task_call_readback_discards_the_build(self):
206
+ open_project = self.compiler.open
207
+ def broken_open(path, primary):
208
+ project = open_project(path, primary)
209
+ task = project.active_application.find_ignore_transient_objects("MainTask", True)[0]
210
+ task.create = Mock()
211
+ return project
212
+ self.compiler.engine.projects.open = broken_open
213
+ with self.assertRaisesRegex(RuntimeError, "readback differs"):
214
+ self.run_build(tasks=[dict(name="MainTask", intervalNanoseconds="20000000", priority=1, programs=["Main"])])
215
+ self.assertEqual(self.loaded, {})
216
+ def test_unspecified_template_tasks_cannot_survive_failed_removal(self):
217
+ open_project = self.compiler.open
218
+ def broken_open(path, primary):
219
+ project = open_project(path, primary)
220
+ project.active_application.find_ignore_transient_objects("BackgroundTask", True)[0].remove = Mock()
221
+ return project
222
+ self.compiler.engine.projects.open = broken_open
223
+ with self.assertRaisesRegex(RuntimeError, "Unplanned or duplicate tasks"):
224
+ self.run_build(tasks=[dict(name="MainTask", intervalNanoseconds="20000000", priority=1, programs=["Main"])])
225
+ def test_reuses_the_loaded_project_for_text_changes(self):
226
+ self.run_build()
227
+ self.objects[0]["implementation"] = "NewBody();"
228
+ self.run_build()
229
+ self.assertEqual(len(self.compiler.opened), 1)
230
+ self.assertEqual(self.find("Main").implementation, "NewBody();")
231
+ def test_reloads_for_template_changes(self):
232
+ self.run_build()
233
+ self.template.write_text("changed template")
234
+ self.run_build()
235
+ self.assertEqual(len(self.compiler.opened), 2)
236
+ self.assertEqual(self.compiler.opened[0].path, self.compiler.opened[1].path)
237
+ self.compiler.opened[0].close.assert_called_once()
238
+ self.assertEqual([p.name for p in self.root.glob("project*")], ["project"])
239
+ def test_updates_the_template_controller_and_reloads_when_it_changes(self):
240
+ self.run_build(controller=LINUX_SL)
241
+ self.assertEqual(vars(self.loaded["project"].controller.identity), LINUX_SL)
242
+ self.run_build(controller=LINUX_SL)
243
+ self.assertEqual(len(self.compiler.opened), 1)
244
+ self.run_build()
245
+ self.assertEqual(len(self.compiler.opened), 2)
246
+ self.assertEqual(self.loaded["project"].controller.identity.id, "0000 1008")
247
+ def test_rejects_a_controller_without_an_installed_description(self):
248
+ self.compiler.installed.clear()
249
+ with self.assertRaisesRegex(RuntimeError, "install the device description for 4102 0000 0005 4.22.0.0"):
250
+ self.run_build(controller=LINUX_SL)
251
+ def test_reloads_for_removed_children_and_moved_domains(self):
252
+ block = spec("function_block", "FB_Test", [spec("method", "Run", implementation="RETURN;")], ["old"], implementation="")
253
+ self.objects.append(block)
254
+ self.run_build()
255
+ block["children"] = []
256
+ block["folder"] = ["new", "nested"]
257
+ self.run_build()
258
+ self.assertEqual(len(self.compiler.opened), 2)
259
+ self.assertEqual(self.find("FB_Test").children, [])
260
+ self.assertEqual(self.find("FB_Test").parent.name, "nested")
261
+ def test_imports_every_supported_object_kind(self):
262
+ self.objects.extend([
263
+ spec("dut", "ST_Data", dutType="structure"), spec("dut", "E_State", dutType="enumeration"),
264
+ spec("dut", "U_Data", dutType="union"), spec("dut", "T_Count", dutType="alias"),
265
+ spec("gvl", "Globals"), spec("function", "F_Add", returnType="INT", implementation="F_Add := 1;"),
266
+ spec("function", "F_NoReturn", implementation="RETURN;"),
267
+ spec("interface", "I_Test", [spec("method", "Run")]),
268
+ spec("function_block", "FB_Test", [spec("action", "Reset", implementation="X := 0;"),
269
+ spec("property", "Ready", [spec("get", "Get", implementation="Ready := TRUE;")], returnType="BOOL")], implementation=""),
270
+ ])
271
+ self.run_build()
272
+ self.assertEqual(self.find("Ready").children[0].implementation, "Ready := TRUE;")
273
+ self.assertEqual([child.name for child in self.find("Ready").children], ["Get"])
274
+ self.assertEqual(self.find("Reset").implementation, "X := 0;")
275
+ self.assertEqual(self.find("T_Count").kind, "alias")
276
+ self.assertEqual(self.find("F_NoReturn").implementation, "RETURN;")
277
+ def test_reports_missing_task_instead_of_leaving_entry_unscheduled(self):
278
+ with self.assertRaisesRegex(RuntimeError, "one task named Missing"):
279
+ self.run_build(task="Missing")
280
+ def test_discards_a_failed_project_and_recovers_on_the_next_build(self):
281
+ self.compiler.fail_compile = True
282
+ request = self.request()
283
+ with self.assertRaisesRegex(RuntimeError, "Fixture compiler error"):
284
+ build(self.compiler.engine, request, self.loaded, str(self.root))
285
+ self.assertEqual(self.loaded, {})
286
+ self.assertFalse(Path(request["receipt"]).exists())
287
+ self.compiler.opened[0].close.assert_called_once()
288
+ self.compiler.fail_compile = False
289
+ self.run_build()
290
+ self.assertEqual(len(self.compiler.opened), 2)
291
+ def test_rejects_empty_boot_application(self):
292
+ self.compiler.empty_output = True
293
+ with self.assertRaisesRegex(RuntimeError, "non-empty boot application"):
294
+ self.run_build()
295
+ def test_clears_old_diagnostics_before_compilation(self):
296
+ self.run_build()
297
+ self.compiler.engine.system.clear_messages.assert_called_once_with("compiler")
298
+ def test_fails_on_compiler_error_diagnostics(self):
299
+ self.compiler.engine.system.get_message_objects = lambda category, severity: [SimpleNamespace(text="Unresolved identifier")]
300
+ with self.assertRaisesRegex(RuntimeError, "Unresolved identifier"):
301
+ self.run_build()
302
+
303
+ def test_preserves_compiler_position_metadata_without_decoding(self):
304
+ project = SimpleNamespace(path="C:\\scratch\\Application.project", is_root=True, active_application=None)
305
+ folder = SimpleNamespace(get_name=lambda: "Functions", is_root=False, parent=project)
306
+ obj = SimpleNamespace(get_name=lambda: "Main", guid="object-guid", is_root=False, parent=folder)
307
+ message = SimpleNamespace(text="Unresolved identifier", severity="Error", project=project, object=obj,
308
+ position=18446744073709551615, position_text="(Impl) Line 2", position_offset=Decimal(7),
309
+ length=Decimal(4), prefix="C", number=Decimal(46))
310
+ self.compiler.engine.system.get_message_objects = lambda category, severity: [message, SimpleNamespace(text="No position")]
311
+ with self.assertRaises(CompilerError) as failure:
312
+ self.run_build()
313
+ diagnostics = failure.exception.diagnostics
314
+ self.assertEqual(diagnostics[0], dict(category="compiler", text="Unresolved identifier", severity="Error",
315
+ project=project.path, object=dict(name="Main", guid="object-guid", path=["Functions", "Main"], isApplicationChild=None),
316
+ position="18446744073709551615", positionText="(Impl) Line 2", positionOffset=7, length=4, prefix="C", number=46))
317
+ self.assertIsNone(diagnostics[1]["object"])
318
+ self.assertIsNone(diagnostics[1]["positionText"])
319
+
320
+ def test_inaccessible_position_context_cannot_mask_compiler_error(self):
321
+ class RemovedObject(object):
322
+ @property
323
+ def is_root(self): raise RuntimeError("Object removed")
324
+ class ClosedProject(object):
325
+ @property
326
+ def path(self): raise RuntimeError("Project closed")
327
+ message = SimpleNamespace(text="Original compiler error", object=RemovedObject(), project=ClosedProject(),
328
+ position_text="Unrecognized vendor position", position=12)
329
+ result = diagnostic(message, "compiler")
330
+ self.assertEqual(result["text"], "Original compiler error")
331
+ self.assertEqual(result["positionText"], "Unrecognized vendor position")
332
+ self.assertIsNone(result["object"])
333
+ self.assertIsNone(result["project"])
334
+ self.assertEqual(result["metadataErrors"], {"object": "Object removed", "project": "Project closed"})
335
+
336
+ def test_verifies_application_parent_by_guid_instead_of_name(self):
337
+ application = SimpleNamespace(guid="application-guid", get_name=lambda: "CustomApplication", is_root=False, parent=SimpleNamespace(is_root=True))
338
+ project = SimpleNamespace(path="private.project", active_application=application)
339
+ obj = SimpleNamespace(get_name=lambda: "Main", guid="program-guid", is_root=False, parent=application)
340
+ message = SimpleNamespace(text="Compiler error", object=obj, project=project)
341
+ identity = diagnostic(message, "compiler")["object"]
342
+ self.assertTrue(identity["isApplicationChild"])
343
+ self.assertEqual(identity["applicationPath"], ["Main"])
344
+ obj.parent = SimpleNamespace(guid="template-folder-guid", get_name=lambda: "CustomApplication", is_root=False, parent=application)
345
+ identity = diagnostic(message, "compiler")["object"]
346
+ self.assertFalse(identity["isApplicationChild"])
347
+ self.assertEqual(identity["applicationPath"], ["CustomApplication", "Main"])
348
+
349
+ def test_preserves_verified_application_relative_paths_for_nested_pous_and_duts(self):
350
+ project = SimpleNamespace(path="private.project", is_root=True)
351
+ device = SimpleNamespace(guid="device-guid", get_name=lambda: "Device", is_root=False, parent=project)
352
+ application = SimpleNamespace(guid="application-guid", get_name=lambda: "CustomApplication", is_root=False, parent=device)
353
+ project.active_application = application
354
+ controls = SimpleNamespace(guid="controls-guid", get_name=lambda: "controls", is_root=False, parent=application)
355
+ folder = SimpleNamespace(guid="folder-guid", get_name=lambda: "pumps", is_root=False, parent=controls)
356
+ for name in ("F_Pump", "T_Pump"):
357
+ obj = SimpleNamespace(guid=name + "-guid", get_name=lambda: name, is_root=False, parent=folder)
358
+ message = SimpleNamespace(text="Original compiler error", object=obj, project=project)
359
+ identity = diagnostic(message, "compiler")["object"]
360
+ self.assertEqual(identity["path"], ["Device", "CustomApplication", "controls", "pumps", name])
361
+ self.assertEqual(identity["applicationPath"], ["controls", "pumps", name])
362
+ self.assertFalse(identity["isApplicationChild"])
363
+
364
+ def test_same_named_other_application_has_no_verified_relative_identity(self):
365
+ project = SimpleNamespace(path="private.project", is_root=True)
366
+ application = SimpleNamespace(guid="active-guid", get_name=lambda: "Application", is_root=False, parent=project)
367
+ other = SimpleNamespace(guid="other-guid", get_name=lambda: "Application", is_root=False, parent=project)
368
+ project.active_application = application
369
+ folder = SimpleNamespace(guid="folder-guid", get_name=lambda: "controls", is_root=False, parent=other)
370
+ obj = SimpleNamespace(guid="program-guid", get_name=lambda: "Main", is_root=False, parent=folder)
371
+ identity = diagnostic(SimpleNamespace(text="Compiler error", object=obj, project=project), "compiler")["object"]
372
+ self.assertEqual(identity["path"], ["Application", "controls", "Main"])
373
+ self.assertFalse(identity["isApplicationChild"])
374
+ self.assertNotIn("applicationPath", identity)
375
+
376
+
377
+ class Channel(list):
378
+ """A raw byte channel with eight bit elements, like CODESYS's EDS-imported payload."""
379
+ def __init__(self, identifier, kind, bits=8):
380
+ super().__init__(SimpleNamespace(user_comment="", io_mapping=SimpleNamespace(variable=None)) for _ in range(8))
381
+ self.id, self.channel_type, self.bit_size, self.user_comment = identifier, kind, bits, ""
382
+ self.io_mapping = Mapping()
383
+
384
+
385
+ class Mapping:
386
+ def __init__(self, address="%IB99"):
387
+ self.automatic_iec_address, self.address, self.variable = True, address, None
388
+ @property
389
+ def manual_iec_address(self):
390
+ return self.address
391
+ @manual_iec_address.setter
392
+ def manual_iec_address(self, value):
393
+ # The engine rejects a manual address once automatic addressing is disabled.
394
+ if not self.automatic_iec_address:
395
+ raise ValueError("Invalid address: The existing address is not valid.")
396
+ self.address = value
397
+
398
+
399
+ class DeviceTreeTests(unittest.TestCase):
400
+ engine = SimpleNamespace(ChannelType=SimpleNamespace(Input="input", Output="output"))
401
+
402
+ def parameters(self, inputs, outputs, bits=8):
403
+ channels = [Channel(100 + index, "input", bits) for index in range(inputs)]
404
+ channels += [Channel(200 + index, "output") for index in range(outputs)]
405
+ return dict((channel.id, channel) for channel in reversed(channels))
406
+
407
+ def test_pins_payload_bytes_at_their_offsets_and_labels_bound_io(self):
408
+ from build import map_channels, pin_channels, verify_pinned
409
+ parameters = self.parameters(3, 2)
410
+ # Automatic addresses that already follow the first channel stay automatic.
411
+ parameters[101].io_mapping.address = "%IB17"
412
+ device = {
413
+ "name": "remoteIo",
414
+ "inputs": {"address": "%IB16", "size": 3},
415
+ "outputs": {"address": "%QB28", "size": 2},
416
+ "comments": [{"address": "%IX17.4", "text": "Sensor"}, {"address": "%IB16", "text": "Count (2 bytes)"}],
417
+ }
418
+ pinned = pin_channels(self.engine, None, parameters, device)
419
+ self.assertEqual(sorted(pinned), ["%IB16", "%IB17", "%IB18", "%QB28", "%QB29"])
420
+ self.assertEqual([parameters[identifier].io_mapping.manual_iec_address for identifier in (100, 101, 102)], ["%IB16", "%IB17", "%IB18"])
421
+ self.assertEqual([parameters[identifier].io_mapping.automatic_iec_address for identifier in (100, 101, 102)], [False, True, False])
422
+ map_channels(pinned, {"devices": [device], "mappings": [
423
+ {"address": "%IX17.4", "variable": "Line.Sensor"}, {"address": "%QB29", "variable": "Line.Motor"},
424
+ ]}, "Application")
425
+ self.assertEqual(parameters[101][4].io_mapping.variable, "Application.Line.Sensor")
426
+ self.assertEqual(parameters[201].io_mapping.variable, "Application.Line.Motor")
427
+ # Mapped channels need no comment; IO that keeps AT is named in its comment.
428
+ self.assertEqual(parameters[101][4].user_comment, "")
429
+ self.assertEqual(parameters[100].user_comment, "Count (2 bytes)")
430
+ verify_pinned(pinned)
431
+ parameters[101].io_mapping.address = "%IB0"
432
+ with self.assertRaisesRegex(RuntimeError, "Channel %IB17 moved to %IB0"):
433
+ verify_pinned(pinned)
434
+
435
+ def test_rejects_typed_channels_and_unplaced_directions(self):
436
+ from build import pin_channels
437
+ with self.assertRaisesRegex(RuntimeError, "reader inputs are not 2 raw byte channels"):
438
+ pin_channels(self.engine, None, self.parameters(2, 0, 16), {"name": "reader", "inputs": {"address": "%IB0", "size": 2}})
439
+ with self.assertRaisesRegex(RuntimeError, "reader has outputs without a configured address"):
440
+ pin_channels(self.engine, None, self.parameters(0, 1), {"name": "reader"})
441
+
442
+
443
+ if __name__ == "__main__":
444
+ unittest.main()
@@ -0,0 +1,120 @@
1
+ import sys
2
+ import unittest
3
+ from types import SimpleNamespace
4
+ from unittest.mock import Mock, patch
5
+
6
+ # Only .NET imports are unavailable in CPython.
7
+ with patch.dict(sys.modules, {
8
+ 'System': SimpleNamespace(Activator=None, AppDomain=None, Array=None, Object=None, Enum=None,
9
+ Environment=None),
10
+ 'System.Net': SimpleNamespace(Dns=None, IPAddress=None),
11
+ 'System.Reflection': SimpleNamespace(BindingFlags=None, TargetInvocationException=Exception),
12
+ }):
13
+ import codesys_login
14
+
15
+
16
+ class ValuesFailed(Exception):
17
+ def __init__(self, failed_expressions):
18
+ super().__init__('values failed')
19
+ self.failed_expressions = failed_expressions
20
+
21
+
22
+ class LoginTests(unittest.TestCase):
23
+ def setUp(self):
24
+ self.ids = {'codeId': 'code', 'dataId': 'data'}
25
+ self.application = SimpleNamespace(guid='application-guid')
26
+ self.project = SimpleNamespace(active_application=self.application, close=Mock())
27
+ self.core = SimpleNamespace(Login=Mock(return_value=True), Logout=Mock())
28
+ self.prepared = {}
29
+ self.online = SimpleNamespace(
30
+ Dispose=Mock(), logout=Mock(), start=Mock(), stop=Mock(), reset=Mock(),
31
+ read_value=Mock(side_effect=lambda expression: 'TRUE'),
32
+ set_prepared_value=Mock(side_effect=self.prepare),
33
+ set_unforce_value=Mock(side_effect=lambda expression, restore: self.prepare(expression, '<unforce>')),
34
+ write_prepared_values=Mock(), force_prepared_values=Mock(), unforce_all_values=Mock(),
35
+ get_forced_expressions=Mock(return_value=['Line.enabled']),
36
+ )
37
+ self.engine = SimpleNamespace(
38
+ projects=SimpleNamespace(open=Mock(return_value=self.project)),
39
+ online=SimpleNamespace(create_online_application=Mock(return_value=self.online)),
40
+ ResetOption=SimpleNamespace(Warm='warm', Cold='cold'),
41
+ )
42
+ patcher = patch.multiple(
43
+ codesys_login,
44
+ connect_controller=Mock(),
45
+ core_application=Mock(return_value=self.core),
46
+ session_login=Mock(),
47
+ controller_ids=Mock(return_value=self.ids),
48
+ controller_state=Mock(return_value='run'),
49
+ load_downloaded_reference=Mock(return_value=self.ids),
50
+ )
51
+ patcher.start()
52
+ self.addCleanup(patcher.stop)
53
+ self.request = {
54
+ 'project': 'Application.project',
55
+ 'online': {},
56
+ 'baseline': {'applicationGuid': 'application-guid', 'controllerIds': self.ids},
57
+ }
58
+
59
+ def prepare(self, expression, value):
60
+ if value is None:
61
+ self.prepared.pop(expression, None)
62
+ else:
63
+ self.prepared[expression] = value
64
+
65
+ def test_open_logs_in_to_the_running_application_after_checking_its_identity(self):
66
+ login = codesys_login.open_login(self.engine, self.request)
67
+ self.core.Logout.assert_called_once()
68
+ self.core.Login.assert_called_once_with(True)
69
+ self.assertIs(login['online'], self.online)
70
+
71
+ def test_open_refuses_a_controller_running_another_application(self):
72
+ codesys_login.controller_ids.return_value = {'codeId': 'other', 'dataId': 'data'}
73
+ with self.assertRaisesRegex(RuntimeError, 'different application'):
74
+ codesys_login.open_login(self.engine, self.request)
75
+ self.core.Login.assert_not_called()
76
+ self.online.Dispose.assert_called_once()
77
+ self.project.close.assert_called_once()
78
+
79
+ def test_force_applies_prepared_values_and_reports_forced_expressions(self):
80
+ login = codesys_login.open_login(self.engine, self.request)
81
+ seen = {}
82
+ self.online.force_prepared_values.side_effect = lambda: seen.update(self.prepared)
83
+ result = codesys_login.run_command(
84
+ self.engine, login, {'command': 'force', 'values': {'Line.enabled': 'TRUE'}})
85
+ self.assertEqual(seen, {'Line.enabled': 'TRUE'})
86
+ self.assertEqual(self.prepared, {})
87
+ self.assertEqual(result, {'state': 'run', 'forced': ['Line.enabled']})
88
+
89
+ def test_rejected_write_names_the_expressions_and_clears_preparation(self):
90
+ login = codesys_login.open_login(self.engine, self.request)
91
+ self.online.write_prepared_values.side_effect = ValuesFailed(['Line.speed'])
92
+ with self.assertRaisesRegex(RuntimeError, 'rejected Line.speed'):
93
+ codesys_login.run_command(
94
+ self.engine, login, {'command': 'write', 'values': {'Line.speed': '200'}})
95
+ self.assertEqual(self.prepared, {})
96
+
97
+ def test_writing_a_forced_expression_names_the_force(self):
98
+ login = codesys_login.open_login(self.engine, self.request)
99
+ with self.assertRaisesRegex(RuntimeError, 'line.ENABLED is forced'):
100
+ codesys_login.run_command(
101
+ self.engine, login, {'command': 'write', 'values': {'line.ENABLED': 'TRUE'}})
102
+ self.online.write_prepared_values.assert_not_called()
103
+
104
+ def test_unforce_releases_named_expressions_or_everything(self):
105
+ login = codesys_login.open_login(self.engine, self.request)
106
+ codesys_login.run_command(
107
+ self.engine, login, {'command': 'unforce', 'expressions': ['Line.enabled'], 'restore': True})
108
+ self.online.set_unforce_value.assert_called_once_with('Line.enabled', True)
109
+ self.online.force_prepared_values.assert_called_once()
110
+ codesys_login.run_command(self.engine, login, {'command': 'unforce', 'expressions': [], 'restore': False})
111
+ self.online.unforce_all_values.assert_called_once()
112
+
113
+ def test_reset_uses_the_named_reset_kind(self):
114
+ login = codesys_login.open_login(self.engine, self.request)
115
+ codesys_login.run_command(self.engine, login, {'command': 'reset', 'kind': 'Cold'})
116
+ self.online.reset.assert_called_once_with('cold')
117
+
118
+
119
+ if __name__ == '__main__':
120
+ unittest.main()