Skip to content

Commit 10fe25a

Browse files
committed
Handle CDIVar size as max for "string" and add tests for edge cases.
1 parent 95d942a commit 10fe25a

4 files changed

Lines changed: 186 additions & 11 deletions

File tree

openlcb/cdivar.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11

22
import struct
33

4-
from openlcb import emit_cast
4+
from logging import getLogger
55
from typing import List, Type, Union
66

7+
from openlcb import emit_cast
78
from openlcb.eventid import EventID
89
from openlcb.openlcbaction import OpenLCBAction
910

11+
logger = getLogger(__name__)
1012

1113
NUM_TYPES = {'int': int, 'float': float} # type: dict[str, Type]
12-
# Assumes "IEEE" in LCC CDI Standard means IEEE 754-2008:
14+
# Assumes "IEEE" in OpenLCB CDI Standard means IEEE 754-2008:
1315
FLOAT_MAXIMUMS = {16: 65504.0, 32: 3.40e38, 64: 1.80e308} # type: dict[int, float] # noqa: E501
1416
CLASSNAME_TYPES = {'int': int, 'float': float, 'string': str,
1517
'blob': bytearray, 'eventid': EventID,
@@ -45,7 +47,7 @@ class CDIVar:
4547
(for className == "float").
4648
signed (bool): Whether the value is signed (False unless min is
4749
negative). Defaults to True.
48-
See LCC "Configuration Description Information" Standard.
50+
See OpenLCB "Configuration Description Information" Standard.
4951
_data (bytes): The value read from the device or ready to
5052
write. Only None if not read yet, otherwise length
5153
must be .size.
@@ -139,8 +141,13 @@ def stringToData(self, value: str) -> bytes:
139141
return value.encode("utf-8")
140142

141143
def setString(self, value: str):
142-
self.data = self.stringToData(value)
143-
self.size = len(self.data)
144+
# self.data = self.stringToData(value)
145+
# self.size = len(self.data)
146+
# assert self.className == "string"
147+
encoded = value.encode("utf-8")
148+
assert self.size is not None
149+
assert len(encoded) + 1 <= self.size # size is max *only* if "string"
150+
self.data = encoded + b"\x00" # null-terminated for OpenLCB network
144151

145152
def dataToInt(self, data) -> Union[int, None]:
146153
assert self.className == "int"
@@ -173,4 +180,17 @@ def dataToString(self, data) -> Union[str, None]:
173180
return data.decode("utf-8")
174181

175182
def getString(self) -> Union[str, None]:
176-
return self.dataToString(self.data)
183+
# return self.dataToString(self.data)
184+
if self.data is None or len(self.data) == 0:
185+
return None
186+
# Return content up to (but not including) first null
187+
null_pos = self.data.find(b"\x00")
188+
if null_pos == -1:
189+
logger.error(f"No null terminator in {repr(self.data)}")
190+
content = self.data
191+
else:
192+
content = self.data[:null_pos]
193+
# try:
194+
return content.decode("utf-8")
195+
# except UnicodeDecodeError:
196+
# return None # or raise

python-openlcb.code-workspace

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"offvalue",
7676
"onvalue",
7777
"openlcb",
78+
"openlcbaction",
7879
"openlcbnetwork",
7980
"padx",
8081
"pady",

tests/test_cdivar.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ def test_initialization_valid(self):
1818
self.assertEqual(cdivar_float.max, 100.0)
1919
self.assertEqual(cdivar_float.size, 4)
2020

21+
maxSize = 100
2122
cdivar_string = CDIVar(className='string',
22-
_default=bytearray(b'Hello'))
23+
_default=bytearray(b'Hello'),
24+
_size=maxSize)
2325
self.assertEqual(cdivar_string.className, 'string')
2426
self.assertEqual(cdivar_string.default, bytearray(b'Hello'))
2527
assert cdivar_string.default is not None
26-
self.assertEqual(cdivar_string.size, len(cdivar_string.default))
28+
# self.assertEqual(cdivar_string.size, len(cdivar_string.default))
29+
self.assertEqual(cdivar_string.size, maxSize)
2730

2831
def test_initialization_invalid_classname(self):
2932
with self.assertRaises(AssertionError):
@@ -60,7 +63,7 @@ def test_set_get_float(self):
6063
self.assertAlmostEqual(got, 3.14, places=6)
6164

6265
def test_set_get_string(self):
63-
cdivar_string = CDIVar(className='string')
66+
cdivar_string = CDIVar(className='string', _size=100)
6467
cdivar_string.setString("Hello")
6568
self.assertEqual(cdivar_string.getString(), "Hello")
6669

@@ -75,8 +78,8 @@ def test_invalid_set_float(self):
7578
cdivar_float.setFloat("not a float") # type:ignore (assertRaises)
7679

7780
def test_invalid_set_string(self):
78-
cdivar_string = CDIVar(className='string')
79-
with self.assertRaises(AssertionError):
81+
cdivar_string = CDIVar(className='string', _size=100)
82+
with self.assertRaises(AttributeError): # number has no attribute 'encode'
8083
cdivar_string.setString(12345) # type:ignore (assertRaises)
8184

8285

tests/test_cdivar_edge_cases.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from typing import List, Tuple
2+
import unittest
3+
4+
from openlcb.cdivar import CDIVar
5+
6+
7+
class TestCDIVarNumericConversions(unittest.TestCase):
8+
9+
def assertBytesEqual(self, expected_hex: List[int], actual: bytes,
10+
msg: str = ""):
11+
expected = bytes(expected_hex)
12+
self.assertEqual(
13+
expected,
14+
actual,
15+
(f"{msg}\n Expected: {expected.hex(' ').upper()}"
16+
f"\n Got: {actual.hex(' ').upper()}")
17+
)
18+
19+
# -------------------------------------------------------------------------
20+
# Basic signed int conversions — edge cases (4 bytes)
21+
# -------------------------------------------------------------------------
22+
def test_setInt_getInt_4byte_edge_cases(self):
23+
cases: List[Tuple[int, List[int]]] = [
24+
(-1, [0xFF, 0xFF, 0xFF, 0xFF]),
25+
(-2147483648, [0x80, 0x00, 0x00, 0x00]), # INT32_MIN
26+
(2147483647, [0x7F, 0xFF, 0xFF, 0xFF]),
27+
(0, [0x00, 0x00, 0x00, 0x00]),
28+
(300, [0x00, 0x00, 0x01, 0x2C]),
29+
(0x12345678, [0x12, 0x34, 0x56, 0x78]),
30+
]
31+
32+
for value, expected_bytes in cases:
33+
with self.subTest(f"int {value} → bytes"):
34+
var = CDIVar("int", _size=4, _min=-1) # signed
35+
var.setInt(value)
36+
assert var.data is not None
37+
self.assertBytesEqual(expected_bytes, var.data)
38+
39+
restored = var.getInt()
40+
self.assertEqual(value, restored)
41+
42+
# -------------------------------------------------------------------------
43+
# Smaller sizes — sign extension behavior
44+
# -------------------------------------------------------------------------
45+
def test_small_int_sizes_sign_extension(self):
46+
cases = [
47+
# value, size, signed, bytes, expected getInt
48+
(-100, 2, True, [0xFF, 0x9C], -100),
49+
(0xABCD, 2, False, [0xAB, 0xCD], 0xABCD),
50+
(-128, 4, True, [0xFF, 0xFF, 0xFF, 0x80], -128),
51+
(0x5A, 1, False, [0x5A], 0x5A),
52+
]
53+
54+
for val, size, signed, exp_bytes, exp_restored in cases:
55+
with self.subTest(f"{val} @ {size} bytes signed={signed}"):
56+
var = CDIVar("int", _size=size, _min=-1 if signed else 0)
57+
var.setInt(val)
58+
assert var.data is not None
59+
self.assertBytesEqual(exp_bytes, var.data)
60+
61+
restored = var.getInt()
62+
self.assertEqual(exp_restored, restored)
63+
64+
# -------------------------------------------------------------------------
65+
# Strict IEEE 754 binary16 (half-precision) bit-exact tests
66+
# -------------------------------------------------------------------------
67+
def test_float16_strict_bit_exact(self):
68+
cases = [ # noqa: E501
69+
# value expected [high, low] description
70+
(0.0, [0x00, 0x00], "+0.0"),
71+
(5.9604644775390625e-8, [0x00, 0x01], "smallest positive subnormal"), # noqa: E501
72+
(-5.9604644775390625e-8, [0x80, 0x01], "smallest negative subnormal"), # noqa: E501
73+
(6.103515625e-5, [0x04, 0x00], "smallest positive normal"), # noqa: E501
74+
(-6.103515625e-5, [0x84, 0x00], "smallest negative normal"), # noqa: E501
75+
(1.0, [0x3C, 0x00], "1.0 exact"),
76+
(-1.0, [0xBC, 0x00], "-1.0"),
77+
(0.5, [0x38, 0x00], "0.5"),
78+
(-0.5, [0xB8, 0x00], "-0.5"),
79+
(65504.0, [0x7B, 0xFF], "max finite"),
80+
(-65504.0, [0xFB, 0xFF], "max negative finite"), # noqa: E501
81+
(float("inf"), [0x7C, 0x00], "+Inf"),
82+
(float("-inf"), [0xFC, 0x00], "-Inf"),
83+
# (float("nan"), [0x7E, 0x00], "canonical quiet NaN"), # noqa: E501
84+
# (65536.0, [0x7C, 0x00], "overflow → +Inf"), # noqa: E501
85+
# (1.00048828125, [0x3C, 0x01], "ties-to-even example"), # noqa: E501
86+
(float("nan"), [0x7E, 0x00], "canonical quiet NaN"), # noqa: E501
87+
# 65536.0 removed — Python struct raises OverflowError (expected)
88+
# (1.00048828125, [0x3C, 0x00], "ties-to-even rounds to even (down in this case)"), # noqa: E501
89+
# ^ becomes 1.0 due to float16 precision, so commented
90+
(1.0009765625, [0x3C, 0x01], "1 + 2⁻¹⁰ = exact in float16"), # noqa: E501
91+
# (1.00048828125 + 1e-12, [0x3C, 0x01], "slightly above midpoint → rounds up"), # noqa: E501
92+
# ^ AssertionError: 1.000488281251 != 1.0009765625 : Round-trip mismatch: 1.000488281251 → 1.0009765625 # noqa: E501
93+
# due to float16 precision
94+
]
95+
96+
for val, expected, message in cases:
97+
with self.subTest(f"float16 {val}"):
98+
var = CDIVar("float", _size=2)
99+
var.setFloat(val)
100+
assert var.data is not None
101+
self.assertBytesEqual(expected, var.data,
102+
f"setFloat 16 ({val}) {message} failed") # noqa: E501
103+
104+
# round-trip check
105+
restored = var.getFloat()
106+
assert restored is not None
107+
if val != val: # NaN
108+
self.assertTrue(restored != restored)
109+
elif abs(val) == float("inf"):
110+
self.assertTrue(
111+
abs(restored) == float("inf") and (restored > 0) == (val > 0), # noqa: E501
112+
f"setFloat 16 {message} failed"
113+
)
114+
else:
115+
# For representable values → should be bit-exact round-trip
116+
self.assertEqual(
117+
val, restored,
118+
f"Round-trip mismatch: {val}{restored}"
119+
)
120+
121+
# -------------------------------------------------------------------------
122+
# Basic null-terminated string behavior (modified methods)
123+
# -------------------------------------------------------------------------
124+
def test_string_null_terminated(self):
125+
cases = [
126+
("hello", b"hello\x00"),
127+
("", b"\x00"),
128+
("café π", "café π".encode("utf-8") + b"\x00"),
129+
]
130+
131+
for s, expected_bytes in cases:
132+
with self.subTest(f"setString({s!r})"):
133+
var = CDIVar("string", _size=100)
134+
var.setString(s)
135+
self.assertEqual(expected_bytes, var.data)
136+
137+
restored = var.getString()
138+
self.assertEqual(s, restored)
139+
140+
# Extra data after null is ignored
141+
var = CDIVar("string")
142+
var.data = b"test\x00junk"
143+
self.assertEqual("test", var.getString())
144+
145+
# No null → whole content
146+
var.data = b"no-null-here"
147+
self.assertEqual("no-null-here", var.getString())
148+
149+
150+
if __name__ == "__main__":
151+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)