diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f51720..ecfb356 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -116,7 +116,7 @@ jobs: # unrun for exactly the reason this job exists. - name: Pascal and Python helpers agree run: | - python -m pytest tests/test_cross_validate.py tests/test_pascal_runner.py -q -rs --durations=10 | Tee-Object -Variable captured + python -m pytest tests/test_cross_validate.py tests/test_pascal_runner.py tests/test_sch_properties_match_their_interface.py -q -rs --durations=10 | Tee-Object -Variable captured # Read the exit code BEFORE anything else runs. Piping a native # command into Tee-Object means the step's own success is the # pipeline's, not pytest's, so without this a failing diff --git a/docs/RELEASE_VERIFICATION.md b/docs/RELEASE_VERIFICATION.md index 745bd18..b738b67 100644 --- a/docs/RELEASE_VERIFICATION.md +++ b/docs/RELEASE_VERIFICATION.md @@ -1,7 +1,7 @@ # Release verification: 2026.09.23.4 -Everything below is Pascal that FPC and the linter have checked and that -**Altium's DelphiScript engine has never executed**. The two are not the +Unless a section records live verification explicitly, the Pascal below has +been checked by FPC and the linter but **not executed by Altium's DelphiScript engine**. The two are not the same: each accepts identifiers the other rejects, and an undeclared one faults at runtime where `Try/Except` cannot catch it, halting the polling loop. @@ -1034,3 +1034,71 @@ it. A library edit is real in memory and absent from disk until then. Altium's font size is not in mils and the conversion is undocumented, so the source range is reported rather than guessed. Calibrating it needs a live measurement. + + +## Generic property rejection on Altium 21 (live verified) + +Recorded on Altium 21.4.1.30: `IsHidden` on `eNetLabel` and `Text` on +`eParameterSet` opened an undeclared-identifier dialog and stopped polling. +The fix guards reads and writes before accessing those members. It does not +claim a complete property capability table for every Altium version. + +Unsupported query fields remain empty and are listed in +`properties.unreadable`; other requested fields remain available. Unsupported +writes are listed in `properties.unknown` and are not reported as applied. +Single creation returns `UNSUPPORTED_PROPERTY` with the property and object +type in the error message. Batch creation rejects only that item, without +registering it, and includes `reason: "UNSUPPORTED_PROPERTY"`, `property`, +`object_type`, and the zero-based `index` in `failures`. Valid items continue. +This preflight covers the guarded Text and IsHidden combinations, not every +unknown property. Existing inputs and successful response fields are unchanged. + +Use visible net labels with `Text`; do not hide them with `IsHidden`. Query a +parameter-set directive's coordinates without `Text`. The bridge does not +substitute a different field for the rejected property. + +Live verification of commit `89c017f` on Altium 21.4.1.30 passed on +2026-09-29 after a full Altium restart. The deployed Generic.pas SHA-256 was +`36f9c0dde95de366a1b0ea60bc653793ed6f915c1c7714d08ee20bb791aef57e`. + +- Unsupported reads returned empty fields plus unreadable diagnostics, while + coordinates and the directive Name remained readable. +- Single writes returned success=false with unknown-property diagnostics; + batch writes reported both rejected properties in their diagnostic list. +- Both single creates failed without changing object counts. The mixed batch + returned created=2, failed=2, total=4, with failures at indexes 0 and 2 and + the expected property/type. Queries confirmed only the valid objects existed. +- Ordinary parameter IsHidden read back false, true, then false after restore. +- Pings and valid queries succeeded after every rejecting call. No error modal + or polling restart was needed. The recorded response assertions passed. +- Free Pascal execution remains separately pending: local FPC is absent and + the upstream GitHub run is action_required with no jobs executed. + +For future acceptance runs, close unrelated design documents first or retain +byte-for-byte backups. The bridge's save-all operation rewrites open clean +schematics too; it must not be treated as a no-op just because app_context +reports no unsaved documents. + +Repeatable acceptance, on a disposable schematic only: + +1. Save normal work, install the candidate scripts, restart Altium (scripts + are cached), start the bridge, and confirm `app_ping` succeeds. +2. Create an ordinary visible net label and parameter-set directive. Record + their count and positions. Query valid label Text and directive coordinates. +3. Query `IsHidden` on the label and `Text` on the directive, each alongside + coordinates. Expect empty unsupported fields, unreadable diagnostics, and + unchanged valid coordinates. Neither call may open a modal or stop polling. +4. Attempt those writes with `obj_modify` and `obj_batch_modify`. Expect + failure diagnostics, unchanged objects, and no modal. These operations + retain existing partial-write semantics for other valid assignments. +5. Attempt each unsupported pair through `obj_create` and `obj_batch_create`. + Verify failed objects are absent. In a batch containing invalid, valid, + invalid, and valid items, expect two creations, two indexed failures and + no property diagnostic leaking from one item into another. +6. Exercise IsHidden on an ordinary parameter supporting visibility and + confirm read/write behaviour is unchanged. Restore its original value. +7. After each rejection, run `app_ping` and a valid query. Both must complete + without dismissing an error or restarting the polling loop. + +Record exact Altium and bridge versions, responses, and object counts. Offline +source guards and Free Pascal tests do not establish live Altium acceptance. diff --git a/scripts/altium/Generic.pas b/scripts/altium/Generic.pas index 1c2d5b3..516c385 100644 --- a/scripts/altium/Generic.pas +++ b/scripts/altium/Generic.pas @@ -161,11 +161,57 @@ Begin Result := True; If Obj = Nil Then Exit; - { Both of these name themselves with Name, not Text. } - If (Obj.ObjectId = ePort) Or (Obj.ObjectId = eSheetEntry) Then + { Ports, sheet entries and parameter-set directives do not expose Text. } + If (Obj.ObjectId = ePort) Or (Obj.ObjectId = eSheetEntry) + Or (Obj.ObjectId = eParameterSet) Then Result := False; End; +{ AD21 net labels do not expose IsHidden. Test before member access: an + undeclared identifier opens a modal that Try/Except cannot contain. } +Function SchObjectHasIsHidden(Obj : ISch_GraphicalObject) : Boolean; +Begin + Result := False; + If Obj = Nil Then Exit; + Result := Obj.ObjectId <> eNetLabel; +End; + +{ Preflight only the known unsupported property/type pairs. Preserve the + existing treatment of other names; do not guess a complete capability map. } +Function UnsupportedSchProperty(Obj : ISch_GraphicalObject; SetStr : String) : String; +Var + Remaining, Assignment, PropName : String; + PipePos, EqPos : Integer; +Begin + Result := ''; + Remaining := SetStr; + While Remaining <> '' Do + Begin + PipePos := Pos('|', Remaining); + If PipePos > 0 Then + Begin + Assignment := Copy(Remaining, 1, PipePos - 1); + Remaining := Copy(Remaining, PipePos + 1, Length(Remaining)); + End + Else + Begin + Assignment := Remaining; + Remaining := ''; + End; + EqPos := Pos('=', Assignment); + If EqPos > 0 Then + Begin + PropName := Copy(Assignment, 1, EqPos - 1); + If ((PropName = 'Text') And (Not SchObjectHasText(Obj))) + Or ((PropName = 'IsHidden') And (Not SchObjectHasIsHidden(Obj))) Then + Begin + Result := PropName; + Exit; + End; + End; + End; +End; + Function SchObjectHasOrientation(Obj : ISch_GraphicalObject) : Boolean; Begin Result := True; @@ -642,7 +688,13 @@ Result := GetSchVertexProperty(Obj, PropName) // Boolean properties - Else If PropName = 'IsHidden' Then Result := BoolToJsonStr(Obj.IsHidden) + Else If PropName = 'IsHidden' Then + Begin + If SchObjectHasIsHidden(Obj) Then + Result := BoolToJsonStr(Obj.IsHidden) + Else + NotePropertyDiag('unreadable', PropName); + End Else If PropName = 'IsSolid' Then Result := BoolToJsonStr(Obj.IsSolid) Else If PropName = 'IsMirrored' Then Result := BoolToJsonStr(Obj.IsMirrored); Except @@ -759,7 +811,7 @@ If SchObjectHasText(Obj) Then Obj.Text := Value Else - NotePropertyDiag('unknown', PropName); + Matched := False; End Else If PropName = 'Name' Then Obj.Name := Value Else If PropName = 'LibReference' Then Obj.LibReference := Value @@ -860,7 +912,13 @@ Else If PropName = 'YSize' Then Obj.YSize := MilsToCoord(StrToIntDef(Value, 0)) // Boolean properties - Else If PropName = 'IsHidden' Then Obj.IsHidden := StrToBool(Value) + Else If PropName = 'IsHidden' Then + Begin + If SchObjectHasIsHidden(Obj) Then + Obj.IsHidden := StrToBool(Value) + Else + Matched := False; + End Else If PropName = 'IsSolid' Then Obj.IsSolid := StrToBool(Value) { MIRROR IS NOT A PLAIN PROPERTY WRITE. } { } @@ -1974,13 +2032,15 @@ Function Gen_CreateObject(Params : String; RequestId : String) : String; Var - ObjTypeStr, PropsStr, Container : String; + ObjTypeStr, PropsStr, Container, UnsupportedProp : String; ObjTypeInt : Integer; SchDoc : ISch_Document; SchLib : ISch_Lib; Component : ISch_Component; NewObj : ISch_GraphicalObject; Begin + ResetPropertyDiag(0); + SchDoc := Nil; ObjTypeStr := ExtractJsonValue(Params, 'object_type'); PropsStr := ExtractJsonValue(Params, 'properties'); Container := ExtractJsonValue(Params, 'container'); @@ -2001,6 +2061,15 @@ Exit; End; + UnsupportedProp := UnsupportedSchProperty(NewObj, PropsStr); + If UnsupportedProp <> '' Then + Begin + SchServer.DestroySchObject(NewObj); + Result := BuildErrorResponse(RequestId, 'UNSUPPORTED_PROPERTY', + 'Property ' + UnsupportedProp + ' is not supported on ' + ObjTypeStr); + Exit; + End; + // Set properties ApplySetProperties(NewObj, PropsStr); @@ -7722,9 +7791,11 @@ NewObj : ISch_GraphicalObject; ActiveDoc : ISch_Document; ContainerStr : String; - FailuresJson, ItemReason : String; + FailuresJson, ItemReason, UnsupportedProp : String; FirstFailure : Boolean; Begin + ResetPropertyDiag(0); + SchDoc := Nil; Operations := ExtractJsonValue(Params, 'operations'); If Operations = '' Then Begin @@ -7749,6 +7820,8 @@ If Op = '' Then Break; OpCount := OpCount + 1; ItemReason := ''; + UnsupportedProp := ''; + ResetPropertyDiag(0); Scope := GetBatchField(Op, 'scope'); If Scope = '' Then Scope := 'active_doc'; ObjTypeStr := GetBatchField(Op, 'object_type'); @@ -7772,48 +7845,58 @@ End Else Begin - ApplySetProperties(NewObj, PropsStr); - - If ContainerStr = 'component' Then + UnsupportedProp := UnsupportedSchProperty(NewObj, PropsStr); + If UnsupportedProp <> '' Then + Begin + SchServer.DestroySchObject(NewObj); + Inc(Failed); + ItemReason := 'UNSUPPORTED_PROPERTY'; + End + Else Begin - SchLib := SchServer.GetCurrentSchDocument; - If (SchLib <> Nil) And (SchLib.ObjectId = eSchLib) Then + ApplySetProperties(NewObj, PropsStr); + + If ContainerStr = 'component' Then Begin - Component := SchLib.CurrentSchComponent; - If Component <> Nil Then + SchLib := SchServer.GetCurrentSchDocument; + If (SchLib <> Nil) And (SchLib.ObjectId = eSchLib) Then Begin - Component.AddSchObject(NewObj); - SchRegisterObject(Component, NewObj); - Inc(Created); + Component := SchLib.CurrentSchComponent; + If Component <> Nil Then + Begin + Component.AddSchObject(NewObj); + SchRegisterObject(Component, NewObj); + Inc(Created); + End + Else + Begin + SchServer.DestroySchObject(NewObj); + Inc(Failed); + ItemReason := 'NO_COMPONENT'; + End; End Else Begin SchServer.DestroySchObject(NewObj); Inc(Failed); - ItemReason := 'NO_COMPONENT'; + ItemReason := 'NO_SCHLIB'; End; End Else Begin - SchServer.DestroySchObject(NewObj); - Inc(Failed); - ItemReason := 'NO_SCHLIB'; - End; - End - Else - Begin - SchDoc := ActiveDoc; - If SchDoc = Nil Then - Begin - SchServer.DestroySchObject(NewObj); - Inc(Failed); - ItemReason := 'NO_SCHEMATIC'; - End - Else - Begin - SchDoc.RegisterSchObjectInContainer(NewObj); - SchRegisterObject(SchDoc, NewObj); - Inc(Created); + SchDoc := ActiveDoc; + If SchDoc = Nil Then + Begin + SchServer.DestroySchObject(NewObj); + Inc(Failed); + ItemReason := 'NO_SCHEMATIC'; + End + Else + Begin + SchDoc.RegisterSchObjectInContainer(NewObj); + SchRegisterObject(SchDoc, NewObj); + Inc(Created); + End; End; End; End; @@ -7826,7 +7909,11 @@ FailuresJson := FailuresJson + '{"index":' + IntToStr(OpCount - 1) + ',"object_type":"' + EscapeJsonString(ObjTypeStr) + - '","reason":"' + ItemReason + '"}'; + '","reason":"' + ItemReason + '"'; + If UnsupportedProp <> '' Then + FailuresJson := FailuresJson + ',"property":"' + + EscapeJsonString(UnsupportedProp) + '"'; + FailuresJson := FailuresJson + '}'; End; End; Finally diff --git a/src/eda_agent/tools/generic.py b/src/eda_agent/tools/generic.py index 4320fb8..9eceb24 100644 --- a/src/eda_agent/tools/generic.py +++ b/src/eda_agent/tools/generic.py @@ -2424,7 +2424,11 @@ async def obj_batch_create( ]) Returns: - Dict with created, failed, total counts. + Dict with created, failed, total counts and indexed failures. + Known unsupported properties (for example IsHidden on eNetLabel + or Text on eParameterSet) reject that item before registration. + Its failure includes reason UNSUPPORTED_PROPERTY, property and + object_type. Other valid items still run. """ op_strs: list[str] = [] for op in operations: diff --git a/tests/test_sch_properties_match_their_interface.py b/tests/test_sch_properties_match_their_interface.py index b9cbfea..334427c 100644 --- a/tests/test_sch_properties_match_their_interface.py +++ b/tests/test_sch_properties_match_their_interface.py @@ -22,6 +22,7 @@ """ from __future__ import annotations +import os import re from pathlib import Path @@ -40,11 +41,12 @@ def _decommented(text: str) -> str: return re.sub(r"//.*", " ", text) -@pytest.mark.parametrize("prop", ["Text", "Orientation"]) +@pytest.mark.parametrize("prop", ["Text", "Orientation", "IsHidden"]) def test_the_access_is_gated_by_type(prop): """Every read and write of these goes through a type check.""" code = _decommented(_source()) - guard = {"Text": "SchObjectHasText", "Orientation": "SchObjectHasOrientation"}[prop] + guard = {"Text": "SchObjectHasText", "Orientation": "SchObjectHasOrientation", + "IsHidden": "SchObjectHasIsHidden"}[prop] # \bObj\. and not just Obj\. : PowerObj.Orientation is a TYPED local # where the access is already correct, and matching it flagged code @@ -134,3 +136,318 @@ def test_no_hardcoded_boundary_literals_elsewhere(): offenders.append(f"{path.name}:{i}") assert not offenders, ( f"32-bit boundary literals outside the constant: {offenders}") + +# AD21 property failures: inspect the real dispatcher, not a copied table. +def _routine(code: str, name: str) -> str: + start = re.search(rf"(?mi)^(?:Function|Procedure) {name}\b", code) + assert start, f"missing routine {name}" + end = re.search(r"(?m)^End;", code[start.start():]) + assert end, f"missing end of {name}" + body = code[start.start():start.start() + end.end()] + # Keep quoted JSON/string literals intact while stripping Pascal comments. + return re.sub(r"'(?:(?:'')|[^'])*'|\{[^}]*\}|//[^\n]*", + lambda m: m[0] if m[0].startswith("'") else " ", + body, flags=re.S) + + +def _assert_property_contract(code: str, prop: str, guard: str, rejected: str): + capability = _routine(code, guard) + assert re.search(rf"Obj\.ObjectId\s*(?:=|<>)\s*{rejected}\b", capability) + assert "Result := False" in capability + for routine, diagnostic in (("GetSchProperty", "unreadable"), + ("SetSchProperty", "unknown")): + body = _routine(code, routine) + start = re.search(rf"Else If PropName = '{prop}'\s+Then", body) + assert start, f"missing {routine} {prop} dispatch" + tail = body[start.end():] + branch = re.split(r"\bElse If PropName\b", tail, maxsplit=1)[0] + assert f"If {guard}(Obj) Then" in branch + assert branch.index(guard) < branch.index(f"Obj.{prop}") + if routine == "GetSchProperty": + assert f"NotePropertyDiag('{diagnostic}', PropName)" in branch + assert "Result := ''" in body[:start.start()] + else: + assert "Matched := False" in branch + assert "If Not Matched Then Result := 0" in body + assert "If Result = 0 Then NotePropertyDiag('unknown', PropName)" in body + + +@pytest.mark.parametrize("prop,guard,rejected", [ + ("Text", "SchObjectHasText", "eParameterSet"), + ("IsHidden", "SchObjectHasIsHidden", "eNetLabel"), +]) +def test_ad21_unsupported_property_contract(prop, guard, rejected): + _assert_property_contract(_source(), prop, guard, rejected) + + +@pytest.mark.parametrize("prop,guard,rejected", [ + ("Text", "SchObjectHasText", "eParameterSet"), + ("IsHidden", "SchObjectHasIsHidden", "eNetLabel"), +]) +def test_property_regression_detects_removed_guards(prop, guard, rejected): + """Mutate only an in-memory copy; each original crash must be detected.""" + source = _source() + _assert_property_contract(source, prop, guard, rejected) + for routine in ("GetSchProperty", "SetSchProperty"): + original = _routine(source, routine) + broken = original.replace(f"If {guard}(Obj) Then", "If True Then") + assert broken != original + # _routine strips comments, so substitute by original routine offsets. + start = source.index(f"Function {routine}(") + end = source.index("\nEnd;", start) + len("\nEnd;") + mutant = source[:start] + broken + source[end:] + with pytest.raises(AssertionError): + _assert_property_contract(mutant, prop, guard, rejected) + mutant = re.sub(rf"(Obj\.ObjectId\s*(?:=|<>)\s*){rejected}\b", r"\1eDummy", source) + with pytest.raises(AssertionError): + _assert_property_contract(mutant, prop, guard, rejected) + + +@pytest.mark.parametrize("name", ["Gen_CreateObject", "Gen_BatchCreate"]) +def test_create_preflights_before_writing_or_registering(name): + body = _routine(_source(), name) + preflight = body.index("UnsupportedSchProperty(NewObj, PropsStr)") + apply = body.index("ApplySetProperties(NewObj, PropsStr)") + assert preflight < apply + rejection = body[preflight:apply] + assert "SchServer.DestroySchObject(NewObj)" in rejection + assert "UNSUPPORTED_PROPERTY" in rejection + assert "ResetPropertyDiag(0)" in body[:preflight] + for registration in ("Component.AddSchObject(NewObj)", + "RegisterSchObjectInContainer(NewObj)"): + assert apply < body.index(registration) + if name == "Gen_CreateObject": + assert "BuildErrorResponse" in rejection and "Exit;" in rejection + else: + # Failure must fall through to item reporting and the next iteration. + assert "Inc(Failed)" in rejection + assert "Exit;" not in rejection and "Break;" not in rejection + assert "Else" in rejection + loop = body.index("While True Do") + assert loop < body.index("ResetPropertyDiag(0)", loop) < preflight + assert '"property"' in body and '"object_type"' in body + assert "Inc(Created)" in body[apply:] + + +def test_extracted_pascal_capabilities_and_creation_preflight(tmp_path): + """Execute production guards/parser with FPC; this is not an Altium test.""" + import shutil + import subprocess + + fpc = shutil.which("fpc") + if not fpc: + pytest.skip("Free Pascal Compiler (fpc) is not installed or not on PATH") + source = _source() + routines = "\n".join(_routine(source, name) for name in + ("SchObjectHasText", "SchObjectHasIsHidden", + "UnsupportedSchProperty")) + # Match all identifiers used by the real guards, so existing denylist + # exclusions remain part of the executable test. + types = sorted(set(re.findall(r"\be[A-Z]\w*", routines)) | + {"eNetLabel", "eParameterSet", "eParameter", "ePort", "eSheetEntry"}) + constants = "\n".join(f" {name} = {i};" for i, name in enumerate(types)) + checks = [ + ("eParameterSet", "Text=bad", "Text"), + ("eParameterSet", "Location.X=10|Text=bad|Location.Y=20", "Text"), + ("eParameterSet", "Location.X=10|Location.Y=20", ""), + ("eNetLabel", "Text=GOOD|IsHidden=true", "IsHidden"), + ("eNetLabel", "IsHidden=false|Text=GOOD", "IsHidden"), + ("eNetLabel", "Text=GOOD|Location.X=10|Location.Y=20", ""), + ("eParameter", "Text=GOOD|IsHidden=true", ""), + ("ePort", "Text=bad", "Text"), + ("eSheetEntry", "Text=bad", "Text"), + ("eNetLabel", "IsHidden|Text=GOOD", ""), + ("eNetLabel", "Text=contains=equals", ""), + ("eNetLabel", "", ""), + ] + calls = "\n".join( + f" Obj.ObjectId := {kind}; if UnsupportedSchProperty(Obj, '{props}') <> '{expected}' then Halt({i});" + for i, (kind, props, expected) in enumerate(checks, 1)) + program = ("program property_preflight;\n{$mode delphi}\nuses SysUtils;\nconst\n" + constants + + "\ntype ISch_GraphicalObject = class\n ObjectId: Integer;\nend;\n" + routines + + "\nvar Obj: ISch_GraphicalObject;\nbegin\n Obj := ISch_GraphicalObject.Create;\n" + + calls + "\n Obj.Free;\nend.\n") + path = tmp_path / "property_preflight.pas" + path.write_text(program, encoding="utf-8") + compiled = subprocess.run([fpc, str(path)], cwd=tmp_path, capture_output=True, text=True) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + executable = tmp_path / ("property_preflight.exe" if os.name == "nt" else "property_preflight") + result = subprocess.run([str(executable)], capture_output=True, text=True) + assert result.returncode == 0, f"production preflight failed case {result.returncode}: {result.stderr}" + + +def test_extracted_pascal_single_and_mixed_batch_creation(tmp_path): + """Execute actual creation routines against observable Altium API stubs. + + Transport JSON decoding and API objects are mocked; the preflight, + destruction, registration, counters and batch JSON assembly are production + Pascal. No claim about the real Altium scripting engine is made here. + """ + import json + import shutil + import subprocess + + fpc = shutil.which("fpc") + if not fpc: + pytest.skip("Free Pascal Compiler (fpc) is not installed or not on PATH") + source = _source() + main = MAIN.read_text(encoding="utf-8") + guards = "\n".join(_routine(source, name) for name in + ("SchObjectHasText", "SchObjectHasIsHidden", "UnsupportedSchProperty")) + creators = "\n".join(_routine(source, name) for name in + ("Gen_CreateObject", "Gen_BatchCreate")) + parsers = "\n".join(_routine(main, name) for name in ("NextBatchOp", "GetBatchField")) + stubs = r''' +program creation_regression; +{$mode delphi} +uses SysUtils; +const ePort=1; eSheetEntry=2; eParameterSet=3; eNetLabel=4; + eParameter=5; eSchLib=6; eCreate_Default=0; +type + TSchObject = class + ObjectId: Integer; + DocumentName: String; + CurrentSchComponent: TSchObject; + Registered: Boolean; + procedure AddSchObject(Obj: TSchObject); + procedure RegisterSchObjectInContainer(Obj: TSchObject); + procedure GraphicallyInvalidate; + end; + ISch_GraphicalObject = TSchObject; + ISch_Document = TSchObject; + ISch_Lib = TSchObject; + ISch_Component = TSchObject; + TProcessControl = class + procedure PreProcess(Doc: TSchObject; Context: String); + procedure PostProcess(Doc: TSchObject; Context: String); + end; + TSchServer = class + ProcessControl: TProcessControl; + Doc: TSchObject; + function SchObjectFactory(Kind, Mode: Integer): TSchObject; + procedure DestroySchObject(Obj: TSchObject); + function GetCurrentSchDocument: TSchObject; + end; +var SchServer: TSchServer; + RegisteredCount, DestroyedCount, AppliedCount, ResetCount: Integer; + DiagDirty: Boolean; +procedure TSchObject.AddSchObject(Obj: TSchObject); +begin + if Obj.Registered then Halt(51); + Obj.Registered := True; + Inc(RegisteredCount); +end; +procedure TSchObject.RegisterSchObjectInContainer(Obj: TSchObject); +begin AddSchObject(Obj); end; +procedure TSchObject.GraphicallyInvalidate; +begin end; +procedure TProcessControl.PreProcess(Doc: TSchObject; Context: String); +begin end; +procedure TProcessControl.PostProcess(Doc: TSchObject; Context: String); +begin end; +function TSchServer.SchObjectFactory(Kind, Mode: Integer): TSchObject; +begin + if DiagDirty then Halt(52); + Result := TSchObject.Create; + Result.ObjectId := Kind; +end; +procedure TSchServer.DestroySchObject(Obj: TSchObject); +begin + if Obj.Registered then Halt(53); + Inc(DestroyedCount); + DiagDirty := True; + Obj.Free; +end; +function TSchServer.GetCurrentSchDocument: TSchObject; +begin Result := Doc; end; +procedure ResetPropertyDiag(Dummy: Integer); +begin DiagDirty := False; Inc(ResetCount); end; +procedure SchRegisterObject(Container, Obj: TSchObject); +begin if not Obj.Registered then Halt(54); end; +procedure MarkDocDirtyByPath(Path: String); +begin end; +function ObjectTypeFromString(S: String): Integer; +begin + Result := -1; + if S='eParameterSet' then Result:=eParameterSet; + if S='eNetLabel' then Result:=eNetLabel; + if S='eParameter' then Result:=eParameter; +end; +function UnknownObjectTypeMessage(S: String): String; +begin Result := S; end; +function EscapeJsonString(S: String): String; +begin Result := S; end; +function BuildErrorResponse(Id, Code, Message: String): String; +begin Result := '{"code":"'+Code+'","message":"'+Message+'"}'; end; +function BuildSuccessResponse(Id, Payload: String): String; +begin Result := Payload; end; +''' + transport = r''' +function ExtractJsonValue(Params, Key: String): String; +begin + if Key='operations' then Result:=Params + else Result:=GetBatchField(Params, Key); +end; +''' + application = r''' +procedure ApplySetProperties(Obj: TSchObject; Props: String); +begin + if UnsupportedSchProperty(Obj, Props)<>'' then Halt(55); + if DiagDirty then Halt(56); + Inc(AppliedCount); + DiagDirty := True; +end; +''' + execution = r''' +procedure PrintCounts; +begin + WriteLn(RegisteredCount, ',', DestroyedCount, ',', AppliedCount, ',', ResetCount); +end; +begin + SchServer := TSchServer.Create; + SchServer.ProcessControl := TProcessControl.Create; + SchServer.Doc := TSchObject.Create; + DiagDirty := True; + WriteLn(Gen_CreateObject('object_type=eNetLabel;properties=IsHidden=true', '1')); + PrintCounts; + WriteLn(Gen_CreateObject('object_type=eParameterSet;properties=Text=bad', '2')); + PrintCounts; + WriteLn(Gen_CreateObject('object_type=eNetLabel;properties=Text=GOOD', '3')); + PrintCounts; + RegisteredCount:=0; DestroyedCount:=0; AppliedCount:=0; ResetCount:=0; + DiagDirty := True; + WriteLn(Gen_BatchCreate( + 'object_type=eNetLabel;properties=IsHidden=true~~' + + 'object_type=eNetLabel;properties=Text=GOOD~~' + + 'object_type=eParameterSet;properties=Text=bad~~' + + 'object_type=eParameterSet;properties=Location.X=10|Location.Y=20', '4')); + PrintCounts; + WriteLn(Gen_CreateObject('object_type=eParameter;properties=IsHidden=true', '5')); + PrintCounts; +end. +''' + path = tmp_path / "creation_regression.pas" + path.write_text(stubs + parsers + transport + guards + application + creators + execution, + encoding="utf-8") + compiled = subprocess.run([fpc, str(path)], cwd=tmp_path, capture_output=True, text=True) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + executable = path.with_suffix(".exe" if os.name == "nt" else "") + result = subprocess.run([str(executable)], capture_output=True, text=True) + assert result.returncode == 0, f"mock API invariant failed ({result.returncode}): {result.stdout} {result.stderr}" + lines = result.stdout.splitlines() + assert json.loads(lines[0]) == {"code": "UNSUPPORTED_PROPERTY", "message": "Property IsHidden is not supported on eNetLabel"} + assert lines[1] == "0,1,0,1" + assert json.loads(lines[2]) == {"code": "UNSUPPORTED_PROPERTY", "message": "Property Text is not supported on eParameterSet"} + assert lines[3] == "0,2,0,2" + assert json.loads(lines[4]) == {"created": True, "object_type": "eNetLabel"} + assert lines[5] == "1,2,1,3" + assert json.loads(lines[6]) == { + "created": 2, "failed": 2, "total": 4, + "failures": [ + {"index": 0, "object_type": "eNetLabel", "reason": "UNSUPPORTED_PROPERTY", "property": "IsHidden"}, + {"index": 2, "object_type": "eParameterSet", "reason": "UNSUPPORTED_PROPERTY", "property": "Text"}, + ], + } + assert lines[7] == "2,2,2,5" + assert json.loads(lines[8]) == {"created": True, "object_type": "eParameter"} + assert lines[9] == "3,2,3,6"