|
| 1 | +# Last run: 2026-04-24, 4 passed in 0.26s (pytest-9.0.3, Python 3.14.3). |
| 2 | +# tests/test_bridge_fix.py::test_t1_nl_annotation_roundtrip_ascii_unicode_byte_identical PASSED |
| 3 | +# tests/test_bridge_fix.py::test_t2_validator_parity_ascii_unicode_equivalent_issue_sets PASSED |
| 4 | +# tests/test_bridge_fix.py::test_t3_macro_chain_ascii_arrow_validates_against_asd PASSED |
| 5 | +# tests/test_bridge_fix.py::test_t4_unicode_corpus_byte_identical_to_golden PASSED |
| 6 | +""" |
| 7 | +Bridge-fix unit tests — T1..T4 |
| 8 | +
|
| 9 | +Covers the 2026-04-24 bridge fix that added ASCII `->` as a frame-boundary |
| 10 | +operator alongside the Unicode `→` in osmp/protocol.py and legacy src/osmp.py. |
| 11 | +
|
| 12 | +- T1: NL annotation round-trip. ASCII `->` and Unicode `→` produce byte- |
| 13 | + identical NL output; "then" appears in both. |
| 14 | +- T2: Validator parity. validate_composition(...) on ASCII-arrow and |
| 15 | + Unicode-arrow forms produces equivalent issue sets (same rules, same |
| 16 | + severities, same frame identifiers). |
| 17 | +- T3: Macro chain with ASCII arrow. Registering a MacroTemplate whose |
| 18 | + chain_template uses `->` validates against an ASD containing the |
| 19 | + referenced opcodes. Pre-fix, the chain was treated as one frame. |
| 20 | +- T4: Regression on Unicode corpus. 10 Unicode-arrow SAL frames decode to |
| 21 | + byte-identical golden NL outputs, ensuring the ASCII-arrow addition |
| 22 | + did not regress Unicode handling. |
| 23 | +
|
| 24 | +Run: pytest tests/test_bridge_fix.py -v |
| 25 | +""" |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +import os |
| 29 | +import sys |
| 30 | + |
| 31 | +# Make the sdk/python source tree importable without install. |
| 32 | +_SDK_PYTHON = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 33 | +if _SDK_PYTHON not in sys.path: |
| 34 | + sys.path.insert(0, _SDK_PYTHON) |
| 35 | + |
| 36 | +from osmp.protocol import ( |
| 37 | + AdaptiveSharedDictionary, |
| 38 | + MacroRegistry, |
| 39 | + MacroTemplate, |
| 40 | + SALDecoder, |
| 41 | + validate_composition, |
| 42 | +) |
| 43 | + |
| 44 | + |
| 45 | +# --------------------------------------------------------------------------- |
| 46 | +# T1 — NL annotation round-trip |
| 47 | +# --------------------------------------------------------------------------- |
| 48 | + |
| 49 | +def test_t1_nl_annotation_roundtrip_ascii_unicode_byte_identical(): |
| 50 | + """ASCII `->` and Unicode `→` must decode to byte-identical NL strings, |
| 51 | + both containing the operator word 'then'.""" |
| 52 | + decoder = SALDecoder() |
| 53 | + ascii_out = decoder.decode_natural_language("H:HR>130->U:ALERT") |
| 54 | + unicode_out = decoder.decode_natural_language("H:HR>130\u2192U:ALERT") |
| 55 | + |
| 56 | + assert "then" in ascii_out, f"ASCII arrow output missing 'then': {ascii_out!r}" |
| 57 | + assert "then" in unicode_out, f"Unicode arrow output missing 'then': {unicode_out!r}" |
| 58 | + assert ascii_out == unicode_out, ( |
| 59 | + f"ASCII-vs-Unicode divergence:\n ascii={ascii_out!r}\n uni ={unicode_out!r}" |
| 60 | + ) |
| 61 | + |
| 62 | + |
| 63 | +# --------------------------------------------------------------------------- |
| 64 | +# T2 — Validator parity |
| 65 | +# --------------------------------------------------------------------------- |
| 66 | + |
| 67 | +def _issue_key(issue): |
| 68 | + # Ignore .message text (may vary on arrow form); compare rule + severity + frame. |
| 69 | + return (issue.rule, issue.severity, issue.frame) |
| 70 | + |
| 71 | + |
| 72 | +def test_t2_validator_parity_ascii_unicode_equivalent_issue_sets(): |
| 73 | + """validate_composition on `A:BAR->B:QUX` and `A:BAR→B:QUX` must fire |
| 74 | + the same rules with the same severities on the same frames.""" |
| 75 | + ascii_result = validate_composition("A:BAR->B:QUX") |
| 76 | + unicode_result = validate_composition("A:BAR\u2192B:QUX") |
| 77 | + |
| 78 | + ascii_keys = sorted(_issue_key(i) for i in ascii_result.issues) |
| 79 | + unicode_keys = sorted(_issue_key(i) for i in unicode_result.issues) |
| 80 | + assert ascii_keys == unicode_keys, ( |
| 81 | + f"Issue-set divergence:\n ascii={ascii_keys}\n uni ={unicode_keys}" |
| 82 | + ) |
| 83 | + |
| 84 | + |
| 85 | +# --------------------------------------------------------------------------- |
| 86 | +# T3 — Macro chain with ASCII arrow |
| 87 | +# --------------------------------------------------------------------------- |
| 88 | + |
| 89 | +def test_t3_macro_chain_ascii_arrow_validates_against_asd(): |
| 90 | + """A macro whose chain_template uses the ASCII arrow must validate |
| 91 | + successfully when the referenced opcodes exist in the ASD. Pre-fix, |
| 92 | + the whole string was parsed as one frame and failed lookup.""" |
| 93 | + asd = AdaptiveSharedDictionary() |
| 94 | + # Add test opcodes A:BAR and B:QUX to the ASD for this macro. |
| 95 | + asd.apply_delta( |
| 96 | + "A", "BAR", "test opcode A:BAR", |
| 97 | + AdaptiveSharedDictionary.UpdateMode.ADDITIVE, "test", |
| 98 | + ) |
| 99 | + asd.apply_delta( |
| 100 | + "B", "QUX", "test opcode B:QUX", |
| 101 | + AdaptiveSharedDictionary.UpdateMode.ADDITIVE, "test", |
| 102 | + ) |
| 103 | + registry = MacroRegistry(asd) |
| 104 | + template = MacroTemplate( |
| 105 | + macro_id="TEST:MACRO", |
| 106 | + chain_template="A:BAR->B:QUX", |
| 107 | + slots=(), |
| 108 | + description="Test macro using ASCII arrow frame separator.", |
| 109 | + ) |
| 110 | + # Pre-fix: "A:BAR->B:QUX" parsed as single frame "A:BAR->B:QUX", ASD lookup |
| 111 | + # for namespace "A" opcode "BAR->B:QUX" would fail → ValueError. |
| 112 | + # Post-fix: split into ["A:BAR", "B:QUX"] → both lookups succeed. |
| 113 | + registry.register(template) # must not raise |
| 114 | + |
| 115 | + # Verify the template is registered. |
| 116 | + assert "TEST:MACRO" in registry._macros |
| 117 | + |
| 118 | + |
| 119 | +# --------------------------------------------------------------------------- |
| 120 | +# T4 — Regression on Unicode corpus |
| 121 | +# --------------------------------------------------------------------------- |
| 122 | + |
| 123 | +# Golden dict: Unicode-arrow SAL frames → expected NL decoder output. |
| 124 | +# Captured 2026-04-24 against sdk/python/osmp/protocol.py (post-fix). |
| 125 | +# If any of these fail byte-equal, the bridge fix regressed Unicode handling. |
| 126 | +T4_GOLDEN = { |
| 127 | + "H:HR>130\u2192U:ALERT": |
| 128 | + "(clinical) [clinical] heart rate above 130 then [operator] urgent operator alert", |
| 129 | + "H:HR>130\u2227H:SPO2<90": |
| 130 | + "[clinical] heart rate above 130 and [clinical] oxygen saturation below 90", |
| 131 | + "H:HR>130\u2228H:SPO2<90": |
| 132 | + "[clinical] heart rate above 130 or [clinical] oxygen saturation below 90", |
| 133 | + "A:ACK\u2194U:CONFIRM": |
| 134 | + "(protocol) [protocol] positive acknowledgment iff [operator] request human confirmation", |
| 135 | + "H:VITALS\u2192U:ALERT": |
| 136 | + "(clinical) [clinical] composite vitals status then [operator] urgent operator alert", |
| 137 | + "H:SPO2<90\u2192U:ALERT": |
| 138 | + "(clinical) [clinical] oxygen saturation below 90 then [operator] urgent operator alert", |
| 139 | + "B:FIRE\u2192M:EVA": |
| 140 | + "(building) [building] FIRE then [emergency] evacuation", |
| 141 | + "W:WIND>60\u2192M:EVA": |
| 142 | + "(weather) [weather] wind speed and direction above 60 then [emergency] evacuation", |
| 143 | + "X:STORE<10\u2192U:ESCALATE": |
| 144 | + "(energy) [energy] storage state below 10 then [operator] escalate to human decision maker", |
| 145 | + "E:GPS\u2192U:ALERT": |
| 146 | + "(sensor) [sensor] gps coordinates then [operator] urgent operator alert", |
| 147 | +} |
| 148 | + |
| 149 | + |
| 150 | +def test_t4_unicode_corpus_byte_identical_to_golden(): |
| 151 | + """Decode 10 Unicode-arrow SAL frames; each NL output must be byte- |
| 152 | + identical to the golden string. Catches regression in Unicode handling |
| 153 | + introduced by the ASCII-arrow frame-split fix.""" |
| 154 | + decoder = SALDecoder() |
| 155 | + mismatches = [] |
| 156 | + for sal, expected in T4_GOLDEN.items(): |
| 157 | + actual = decoder.decode_natural_language(sal) |
| 158 | + if actual != expected: |
| 159 | + mismatches.append((sal, expected, actual)) |
| 160 | + assert not mismatches, ( |
| 161 | + "Unicode decoder regression(s):\n" |
| 162 | + + "\n".join(f" {sal!r}\n expected={exp!r}\n actual ={act!r}" |
| 163 | + for sal, exp, act in mismatches) |
| 164 | + ) |
0 commit comments