new file mode 100644
@@ -0,0 +1,226 @@
+"""
+Unit tests for wic/ksparser.py's custom argparse types: sizetype,
+overheadtype, and systemidtype. These are wic's own input validators for
+kickstart/command-line values, so the tests pin the accepted forms, the
+rejected forms, and the boundaries between them.
+"""
+import sys
+from pathlib import Path
+
+import pytest
+
+_SRC = Path(__file__).resolve().parent.parent.parent / "src"
+if str(_SRC) not in sys.path:
+ sys.path.insert(0, str(_SRC))
+
+from argparse import ArgumentTypeError
+
+from wic.ksparser import sizetype, overheadtype, systemidtype
+
+
+class TestSizetype:
+ """sizetype(default, size_in_bytes=False) returns f(arg) that turns a
+ "<num>[S|s|K|k|M|G]" string into an integer. With size_in_bytes=False
+ the multiplier is 1 (K->1, M->1024, G->1024*1024, i.e. KiB counts);
+ with size_in_bytes=True the multiplier is 1024 and S/s means sectors of
+ 512 bytes. A bare number takes the default suffix."""
+
+ @pytest.mark.parametrize("arg,expected", [
+ ("0", 0),
+ ("0M", 0),
+ ("1K", 1),
+ ("1k", 1),
+ ("3K", 3),
+ ("7K", 7),
+ ("512k", 512),
+ ("999k", 999),
+ ("1024K", 1024),
+ ("1000K", 1000),
+ ("1M", 1 * 1024),
+ ("10M", 10 * 1024),
+ ("13M", 13 * 1024),
+ ("100M", 100 * 1024),
+ ("512M", 512 * 1024),
+ ("1G", 1 * 1024 * 1024),
+ ("2G", 2 * 1024 * 1024),
+ ("3G", 3 * 1024 * 1024),
+ # a large magnitude past 2**31, to catch any 32-bit truncation
+ ("5000000K", 5000000),
+ ])
+ def test_kib_units_with_M_default(self, arg, expected):
+ assert sizetype("M")(arg) == expected
+
+ def test_bare_number_uses_M_default(self):
+ assert sizetype("M")("100") == 100 * 1024
+
+ def test_bare_number_uses_K_default(self):
+ assert sizetype("K")("512") == 512
+
+ @pytest.mark.parametrize("arg,expected", [
+ ("1S", 512),
+ ("1s", 512),
+ ("2S", 2 * 512),
+ ("7S", 7 * 512),
+ ("1K", 1024),
+ ("3K", 3 * 1024),
+ ("17M", 17 * 1024 * 1024),
+ ("1M", 1024 * 1024),
+ ("1G", 1024 ** 3),
+ # past 2**63 to confirm Python's arbitrary-precision ints hold
+ ("10000000G", 10000000 * 1024 ** 3),
+ ])
+ def test_size_in_bytes_units(self, arg, expected):
+ assert sizetype("K", size_in_bytes=True)(arg) == expected
+
+ def test_sectors_only_when_size_in_bytes(self):
+ # 'S' is a valid suffix only in byte mode; without it the suffix is
+ # unknown and rejected.
+ with pytest.raises(ArgumentTypeError):
+ sizetype("M")("1S")
+
+ # --- suffix case sensitivity -------------------------------------------
+
+ @pytest.mark.parametrize("arg", ["1m", "1g"])
+ def test_lowercase_m_and_g_rejected(self, arg):
+ # Only k/K are case-insensitive; m and g are not accepted.
+ with pytest.raises(ArgumentTypeError):
+ sizetype("M")(arg)
+
+ # --- negative sizes -----------------------------------------------------
+
+ @pytest.mark.parametrize("arg", ["-1M", "-5", "-1K", "-1024K", "-1G"])
+ def test_negative_size_rejected(self, arg):
+ with pytest.raises(ArgumentTypeError):
+ sizetype("M")(arg)
+
+ # --- invalid formats ----------------------------------------------------
+
+ @pytest.mark.parametrize("bad", [
+ "abc", # no digits
+ "M", # suffix only
+ "1X", # unsupported suffix
+ "1.5M", # float not accepted
+ "1M ", # trailing space becomes the suffix
+ "1e3M", # scientific notation
+ "", # empty
+ " ", # whitespace only
+ "1;M", # shell metacharacters
+ "1|M",
+ "$(1)M",
+ "1`M",
+ ])
+ def test_invalid_format_rejected(self, bad):
+ with pytest.raises(ArgumentTypeError):
+ sizetype("M")(bad)
+
+ def test_none_rejected(self):
+ with pytest.raises((ArgumentTypeError, TypeError)):
+ sizetype("M")(None)
+
+
+class TestOverheadtype:
+ """overheadtype(arg) -> float, and the factor must be >= 1.0. Anything
+ below 1.0, non-numeric, or non-finite is an ArgumentTypeError."""
+
+ @pytest.mark.parametrize("arg,expected", [
+ ("1.0", 1.0),
+ ("1.001", 1.001),
+ ("1.1", 1.1),
+ ("1.3", 1.3),
+ ("1.37", 1.37),
+ ("3.14159", 3.14159),
+ ("7", 7.0),
+ ("2", 2.0),
+ ("2.0", 2.0),
+ ("100.0", 100.0),
+ ("1000000.0", 1000000.0),
+ ])
+ def test_valid_factor(self, arg, expected):
+ assert overheadtype(arg) == pytest.approx(expected)
+
+ def test_leading_whitespace_accepted(self):
+ # float() strips surrounding whitespace.
+ assert overheadtype(" 1.1") == pytest.approx(1.1)
+
+ def test_trailing_whitespace_accepted(self):
+ assert overheadtype("1.1 ") == pytest.approx(1.1)
+
+ # --- below 1.0: a clean ArgumentTypeError, not a TypeError crash --------
+
+ @pytest.mark.parametrize("arg", ["0.0", "0.5", "0.999", "-1.0", "-0.5"])
+ def test_below_one_rejected(self, arg):
+ with pytest.raises(ArgumentTypeError):
+ overheadtype(arg)
+
+ # --- non-finite ---------------------------------------------------------
+
+ @pytest.mark.parametrize("arg", ["nan", "NaN", "inf", "Infinity", "-inf"])
+ def test_non_finite_rejected(self, arg):
+ with pytest.raises(ArgumentTypeError):
+ overheadtype(arg)
+
+ # --- non-numeric --------------------------------------------------------
+
+ @pytest.mark.parametrize("bad", [
+ "abc", "1.1.1", "M", "1,3", "1 1", "--1.1", "",
+ ])
+ def test_non_numeric_rejected(self, bad):
+ with pytest.raises(ArgumentTypeError):
+ overheadtype(bad)
+
+ def test_none_rejected(self):
+ with pytest.raises((ArgumentTypeError, TypeError)):
+ overheadtype(None)
+
+
+class TestSystemidtype:
+ """systemidtype(arg) validates an MBR partition type id as an explicit
+ 0x-prefixed hex byte in 0x1..0xFF and returns the original string
+ unchanged."""
+
+ @pytest.mark.parametrize("arg", [
+ "0x1", "0x01", "0x0b", "0x0B", "0x82", "0x83", "0xFE", "0xFF",
+ "0xff", "0xFf", "0X82",
+ # odd, non-round bytes across the range
+ "0x7", "0x2a", "0x5b", "0x93", "0xa7", "0xd3",
+ ])
+ def test_valid_returned_unchanged(self, arg):
+ assert systemidtype(arg) == arg
+
+ # --- out of range -------------------------------------------------------
+
+ @pytest.mark.parametrize("arg", ["0x0", "0x00", "0x100", "0xFFFF"])
+ def test_out_of_range_rejected(self, arg):
+ with pytest.raises(ArgumentTypeError):
+ systemidtype(arg)
+
+ # --- must be an explicit 0x-prefixed hex string -------------------------
+
+ @pytest.mark.parametrize("arg", [
+ "82", # no 0x prefix (would parse as hex 130)
+ "130", # decimal that names a valid value
+ "+0x82", # leading sign
+ "-0x82",
+ "0b1010", # binary prefix
+ ])
+ def test_non_prefixed_forms_rejected(self, arg):
+ with pytest.raises(ArgumentTypeError):
+ systemidtype(arg)
+
+ @pytest.mark.parametrize("bad", [
+ "0x", # prefix with no digit
+ "0xGG", # invalid hex digit
+ "abc", # not hex
+ "0x 82", # embedded whitespace
+ " 0x82", # leading whitespace
+ "", # empty
+ " ", # whitespace only
+ "0x" + "F" * 10000, # very long
+ ])
+ def test_invalid_format_rejected(self, bad):
+ with pytest.raises(ArgumentTypeError):
+ systemidtype(bad)
+
+ def test_none_rejected(self):
+ with pytest.raises((ArgumentTypeError, TypeError)):
+ systemidtype(None)
Add direct unit coverage for sizetype(), overheadtype(), and systemidtype(): the accepted KiB/byte-mode units and default-suffix handling, the >= 1.0 overhead contract, and the 0x-prefixed system-id byte range, together with the boundary and malformed inputs each rejects. The cases pin behaviour that is easy to regress: sizetype() rejects a negative count, overheadtype() raises a clean ArgumentTypeError (not a TypeError) for a sub-1.0 factor and refuses nan/inf, and systemidtype() accepts only an explicit 0x-prefixed hex byte. AI-Generated: codex/claude-opus 4.8 (xhigh) Signed-off-by: Trevor Woerner <twoerner@gmail.com> --- tests/unit/test_ksparser_types.py | 226 ++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/unit/test_ksparser_types.py