From d1a5b36de1081fb86d657ea2b5ed05d58d239aab Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:47:19 -0700 Subject: [PATCH 1/4] SEL-444: GS1-128 label generation (PDF + ZPL) --- .../components/labels/Gs1128LabelPanel.tsx | 54 ++ .../__tests__/Gs1128LabelPanel.test.tsx | 20 + services/ingestion/app/labels/__init__.py | 19 + services/ingestion/app/labels/gs1_128.py | 485 ++++++++++++++++++ .../ingestion/tests/test_gs1_128_labels.py | 415 +++++++++++++++ 5 files changed, 993 insertions(+) create mode 100644 frontend/src/components/labels/Gs1128LabelPanel.tsx create mode 100644 frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx create mode 100644 services/ingestion/app/labels/__init__.py create mode 100644 services/ingestion/app/labels/gs1_128.py create mode 100644 services/ingestion/tests/test_gs1_128_labels.py diff --git a/frontend/src/components/labels/Gs1128LabelPanel.tsx b/frontend/src/components/labels/Gs1128LabelPanel.tsx new file mode 100644 index 000000000..f1213e603 --- /dev/null +++ b/frontend/src/components/labels/Gs1128LabelPanel.tsx @@ -0,0 +1,54 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { EmptyState } from "@/components/ui/empty-state" + +export interface Gs1LabelElement { + ai: string + value: string +} + +export interface Gs1128LabelPanelProps { + elements?: readonly Gs1LabelElement[] | null + onDownloadPdf?: (elements: readonly Gs1LabelElement[]) => void + onDownloadZpl?: (elements: readonly Gs1LabelElement[]) => void +} + +/** + * Presentational GS1-128 label actions. Callers pass ingested elements. + * An empty list stays empty: this component never invents identifier text. + */ +export function Gs1128LabelPanel({ + elements, + onDownloadPdf, + onDownloadZpl, +}: Gs1128LabelPanelProps) { + if (!elements || elements.length === 0) { + return ( + + ) + } + + return ( +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx b/frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx new file mode 100644 index 000000000..ddad76d81 --- /dev/null +++ b/frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react" + +import { Gs1128LabelPanel } from "@/components/labels/Gs1128LabelPanel" + +describe("Gs1128LabelPanel", () => { + it("renders an empty state when no elements are passed", () => { + render() + + expect(screen.getByText("No label data yet")).toBeInTheDocument() + expect(screen.getByText(/ingest real GTIN and lot data/i)).toBeInTheDocument() + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) + + it("renders an empty state for an empty element list", () => { + render() + + expect(screen.getByText("No label data yet")).toBeInTheDocument() + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) +}) diff --git a/services/ingestion/app/labels/__init__.py b/services/ingestion/app/labels/__init__.py new file mode 100644 index 000000000..8a74a43df --- /dev/null +++ b/services/ingestion/app/labels/__init__.py @@ -0,0 +1,19 @@ +"""GS1-128 shipping-label encoding (SEL-444).""" + +from .gs1_128 import ( + Gs1LabelError, + element_string, + encode_gs1_128, + human_readable, + to_pdf, + to_zpl, +) + +__all__ = [ + "Gs1LabelError", + "element_string", + "encode_gs1_128", + "human_readable", + "to_pdf", + "to_zpl", +] diff --git a/services/ingestion/app/labels/gs1_128.py b/services/ingestion/app/labels/gs1_128.py new file mode 100644 index 000000000..b505b5e8e --- /dev/null +++ b/services/ingestion/app/labels/gs1_128.py @@ -0,0 +1,485 @@ +"""GS1-128 label symbols as Code 128 codewords, ZPL, and a hand-built PDF. + +Fixed-length identifiers are checked with ``app.gs1_identifiers``. This module +does not invent identifier values and its errors do not repeat the caller's +value. No third-party barcode or PDF library. +""" + +from __future__ import annotations + +import calendar +from dataclasses import dataclass +from typing import Any + +from app.gs1_identifiers import validate_gln, validate_gtin, validate_sscc + +# Code 128 symbol values (ISO/IEC 15417). Not identifier data. +_START_C = 105 +_FNC1 = 102 +_CODE_C = 99 +_CODE_B = 100 +_CODE_A = 101 +_STOP = 106 + +_PAGE_W = 4 * 72 +_PAGE_H = 6 * 72 +_DEFAULT_WIDTH_DOTS = 812 # 4 inches at 203 dpi +_DEFAULT_HEIGHT_DOTS = 1218 # 6 inches at 203 dpi + +# GS1 AI encodable character set 82: digits, both letter cases, twenty +# specials, and no space. Figure 7.11-1 of the GS1 General Specifications. +_CSET82_TEXT = "!\"%&'()*+,-./0123456789:;<=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz" +if len(_CSET82_TEXT) != 82 or len(set(_CSET82_TEXT)) != 82: + raise RuntimeError("GS1 character set 82 is corrupt.") +_CSET82 = frozenset(_CSET82_TEXT) +_DIGITS = frozenset("0123456789") +_SEP = object() + +# Six bar/space widths for symbol values 0..106. The stop symbol (106) is +# followed by one extra 2-module bar when it is drawn. +_PATTERN_BLOB = """ +212222 222122 222221 121223 121322 131222 122213 122312 132212 221213 +221312 231212 112232 122132 122231 113222 123122 123221 223211 221132 +221231 213212 223112 312131 311222 321122 321221 312212 322112 322211 +212123 212321 232121 111323 131123 131321 112313 132113 132311 211313 +231113 231311 112133 112331 132131 113123 113321 133121 313121 211331 +231131 213113 213311 213131 311123 311321 331121 312113 312311 332111 +314111 221411 431111 111224 111422 121124 121421 141122 141221 112214 +112412 122114 122411 142112 142211 241211 221114 413111 241112 134111 +111242 121142 121241 114212 124112 124211 411212 421112 421211 212141 +214121 412121 111143 111341 131141 114113 114311 411113 411311 113141 +114131 311141 411131 211412 211214 211232 233111 +""" + + +class Gs1LabelError(ValueError): + """Label input that cannot be encoded. + + The message names the application identifier and the problem. It does not + include the submitted value. + """ + + +@dataclass(frozen=True) +class _Spec: + variable: bool + kind: str + size: int + + +# size is the exact length for fixed fields and the maximum for variable ones. +_SPECS: dict[str, _Spec] = { + "00": _Spec(False, "sscc", 18), + "01": _Spec(False, "gtin", 14), + "02": _Spec(False, "gtin", 14), + "11": _Spec(False, "date", 6), + "13": _Spec(False, "date", 6), + "15": _Spec(False, "date", 6), + "17": _Spec(False, "date", 6), + "410": _Spec(False, "gln", 13), + "414": _Spec(False, "gln", 13), + "10": _Spec(True, "text", 20), + "21": _Spec(True, "text", 20), + "37": _Spec(True, "count", 8), +} + +_VALIDATORS = { + "gtin": validate_gtin, + "gln": validate_gln, + "sscc": validate_sscc, +} + + +@dataclass(frozen=True) +class _Element: + ai: str + value: str + variable: bool + + +def _patterns() -> tuple[tuple[int, ...], ...]: + rows: list[tuple[int, ...]] = [] + for token in _PATTERN_BLOB.split(): + if len(token) != 6 or any(ch not in "1234" for ch in token): + raise RuntimeError("Code 128 pattern table is corrupt.") + widths = tuple(int(ch) for ch in token) + bars = widths[0] + widths[2] + widths[4] + spaces = widths[1] + widths[3] + widths[5] + if sum(widths) != 11 or bars not in (4, 6, 8) or spaces not in (3, 5, 7): + raise RuntimeError("Code 128 pattern table is corrupt.") + rows.append(widths) + if len(rows) != 107 or len(set(rows)) != 107: + raise RuntimeError("Code 128 pattern table is corrupt.") + anchors = { + 0: (2, 1, 2, 2, 2, 2), + 99: (1, 1, 3, 1, 4, 1), + 100: (1, 1, 4, 1, 3, 1), + 102: (4, 1, 1, 1, 3, 1), + 103: (2, 1, 1, 4, 1, 2), + 104: (2, 1, 1, 2, 1, 4), + 105: (2, 1, 1, 2, 3, 2), + 106: (2, 3, 3, 1, 1, 1), + } + for value, widths in anchors.items(): + if rows[value] != widths: + raise RuntimeError("Code 128 pattern table is corrupt.") + return tuple(rows) + + +_PATTERNS = _patterns() + + +def _pair(item: Any) -> tuple[Any, Any]: + if isinstance(item, dict): + if "ai" not in item or "value" not in item: + raise Gs1LabelError("Each label element needs an application identifier and a value.") + return item["ai"], item["value"] + if isinstance(item, (list, tuple)): + if len(item) != 2: + raise Gs1LabelError("Each label element needs an application identifier and a value.") + return item[0], item[1] + ai = getattr(item, "ai", None) + value = getattr(item, "value", None) + if ai is None and value is None: + raise Gs1LabelError("Each label element needs an application identifier and a value.") + return ai, value + + +def _canonical_ai(raw: Any) -> str: + if not isinstance(raw, str): + raise Gs1LabelError("Application identifier must be text.") + ai = raw.strip() + if not ai: + raise Gs1LabelError("Application identifier is empty.") + if ai in _SPECS: + return ai + if ai.isdigit() and 2 <= len(ai) <= 4: + raise Gs1LabelError(f"Application identifier {ai} is not supported.") + raise Gs1LabelError("Unknown application identifier.") + + +def _valid_date(value: str) -> bool: + if len(value) != 6 or not value.isdigit(): + return False + month = int(value[2:4]) + day = int(value[4:6]) + if month < 1 or month > 12: + return False + if day == 0: + return True + year = 2000 + int(value[0:2]) + return 1 <= day <= calendar.monthrange(year, month)[1] + + +def _clean_value(ai: str, raw: Any, spec: _Spec) -> str: + if spec.kind in _VALIDATORS: + result = _VALIDATORS[spec.kind](raw) + if not result.is_valid or result.value is None or len(result.value) != spec.size: + if result.is_valid and result.value is not None and len(result.value) != spec.size: + detail = f"This label needs {spec.size} digits." + else: + detail = result.error or "The value is not valid." + raise Gs1LabelError(f"Application identifier {ai} rejected. {detail}") + return result.value + + if not isinstance(raw, str): + raise Gs1LabelError(f"Application identifier {ai} value must be text.") + value = raw.strip() + if not value: + raise Gs1LabelError(f"Application identifier {ai} value is empty.") + if spec.kind == "date": + if not _valid_date(value): + raise Gs1LabelError(f"Application identifier {ai} needs a 6 digit date. Day 00 means the whole month.") + return value + if spec.kind == "count": + if not value.isdigit() or not 1 <= len(value) <= spec.size: + raise Gs1LabelError(f"Application identifier {ai} needs 1 to {spec.size} digits.") + return value + if not 1 <= len(value) <= spec.size or any(ch not in _CSET82 for ch in value): + raise Gs1LabelError(f"Application identifier {ai} has a character outside the GS1 set or the wrong length.") + return value + + +def _prepare(elements: Any) -> list[_Element]: + if elements is None or isinstance(elements, (str, bytes)): + raise Gs1LabelError("Label elements are empty.") + try: + items = list(elements) + except TypeError as exc: + raise Gs1LabelError("Label elements must be a list of application identifier and value pairs.") from exc + if not items: + raise Gs1LabelError("Label elements are empty.") + + fixed: list[_Element] = [] + variable: list[_Element] = [] + seen: set[str] = set() + for item in items: + ai_raw, value_raw = _pair(item) + ai = _canonical_ai(ai_raw) + if ai in seen: + raise Gs1LabelError(f"Application identifier {ai} is repeated.") + seen.add(ai) + spec = _SPECS[ai] + element = _Element(ai, _clean_value(ai, value_raw, spec), spec.variable) + if spec.variable: + variable.append(element) + else: + fixed.append(element) + return fixed + variable + + +def _atoms(elements: list[_Element]) -> list[Any]: + variable_count = sum(1 for element in elements if element.variable) + seen = 0 + atoms: list[Any] = [] + for element in elements: + atoms.extend(element.ai + element.value) + if element.variable: + seen += 1 + if seen < variable_count: + atoms.append(_SEP) + return atoms + + +def _join_atoms(atoms: list[Any]) -> str: + return "".join("\x1d" if atom is _SEP else atom for atom in atoms) + + +def _digit_run(atoms: list[Any], index: int) -> tuple[int, bool]: + cursor = index + while cursor < len(atoms) and isinstance(atoms[cursor], str) and atoms[cursor] in _DIGITS: + cursor += 1 + at_boundary = cursor >= len(atoms) or atoms[cursor] is _SEP + return cursor - index, at_boundary + + +def _switch_to_c(atoms: list[Any], index: int) -> bool: + """Code C pays for itself at 4 digits on a boundary, or 6 digits in the middle. + + An odd count waits one Code B digit so the switch lands on a pair. + """ + count, at_boundary = _digit_run(atoms, index) + needed = 4 if at_boundary else 6 + return count >= needed and count % 2 == 0 + + +def _encode_atoms(atoms: list[Any]) -> list[int]: + codewords = [_START_C, _FNC1] + mode = "C" + index = 0 + limit = len(atoms) + # Switches add symbols but must stay proportional to the data. A higher + # count means the subset machine stopped advancing. + ceiling = limit * 2 + 4 + while index < limit: + if len(codewords) > ceiling: + raise Gs1LabelError("Barcode could not be encoded.") + if mode == "C": + token = atoms[index] + if token is _SEP: + codewords.append(_FNC1) + index += 1 + continue + nxt = atoms[index + 1] if index + 1 < limit else None + if isinstance(token, str) and token in _DIGITS and isinstance(nxt, str) and nxt in _DIGITS: + codewords.append(int(token) * 10 + int(nxt)) + index += 2 + continue + codewords.append(_CODE_B) + mode = "B" + continue + if _switch_to_c(atoms, index): + codewords.append(_CODE_C) + mode = "C" + continue + token = atoms[index] + if token is _SEP: + codewords.append(_FNC1) + else: + codewords.append(ord(token) - 32) + index += 1 + + total = codewords[0] + for position, value in enumerate(codewords[1:], start=1): + total += position * value + codewords.append(total % 103) + codewords.append(_STOP) + return codewords + + +def _runs(codewords: list[int]) -> list[int]: + if not codewords or codewords[-1] != _STOP: + raise Gs1LabelError("Barcode is missing the stop symbol.") + runs: list[int] = [] + for codeword in codewords[:-1]: + runs.extend(_PATTERNS[codeword]) + runs.extend(_PATTERNS[_STOP]) + runs.append(2) + return runs + + +def _zpl_barcode(codewords: list[int]) -> str: + if codewords[0] != _START_C or codewords[1] != _FNC1 or codewords[-1] != _STOP: + raise Gs1LabelError("Barcode symbol is incomplete.") + pieces = [">;", ">8"] + mode = "C" + for codeword in codewords[2:-2]: + # Symbol 99 is the digit pair 99 while already in Code C. The same + # number latches to Code C only from another subset. Symbol 100 + # latches to Code B only from Code C. + if mode == "C" and codeword == _CODE_B: + pieces.append(">6") + mode = "B" + continue + if mode != "C" and codeword == _CODE_C: + pieces.append(">5") + mode = "C" + continue + if codeword == _CODE_A: + pieces.append(">7") + mode = "A" + continue + if codeword == _FNC1: + pieces.append(">8") + continue + if mode == "C": + if not 0 <= codeword <= 99: + raise Gs1LabelError("Barcode subset is not encodable in ZPL.") + pieces.append(f"{codeword:02d}") + continue + if mode == "B": + if not 0 <= codeword <= 95: + raise Gs1LabelError("Barcode subset is not encodable in ZPL.") + character = chr(codeword + 32) + # `>` is the ZPL invocation marker. `>0` is that character in subset B. + pieces.append(">0" if character == ">" else character) + continue + raise Gs1LabelError("Barcode subset is not encodable in ZPL.") + return "".join(pieces) + + +def _positive_dots(name: str, value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise Gs1LabelError(f"Label {name} must be a positive whole number.") + return value + + +def _pdf_number(value: float) -> str: + text = f"{value:.2f}" + return "0.00" if text == "-0.00" else text + + +def _pdf_stream(codewords: list[int], lines: list[str]) -> bytes: + runs = _runs(codewords) + quiet = 10 + margin = 14.0 + total = sum(runs) + 2 * quiet + module = min(1.8, (_PAGE_W - 2 * margin) / total) + barcode_width = total * module + x = (_PAGE_W - barcode_width) / 2 + quiet * module + bar_height = 110.0 + bar_y = _PAGE_H - 36.0 - bar_height + ops = ["q", "0 0 0 rg"] + bar = True + for width in runs: + drawn = width * module + if bar: + ops.append(f"{_pdf_number(x)} {_pdf_number(bar_y)} {_pdf_number(drawn)} {_pdf_number(bar_height)} re f") + x += drawn + bar = not bar + ops.append("Q") + ops.append("BT") + ops.append("/F1 10 Tf") + ops.append("0 0 0 rg") + text_y = bar_y - 18.0 + for index, line in enumerate(lines): + hex_text = line.encode("ascii").hex().upper() + if index == 0: + ops.append(f"1 0 0 1 {_pdf_number(margin)} {_pdf_number(text_y)} Tm") + else: + ops.append("0 -13 Td") + ops.append(f"<{hex_text}> Tj") + ops.append("ET") + return "\n".join(ops).encode("ascii") + + +def _pdf_document(stream: bytes) -> bytes: + objects = [ + b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", + b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", + ( + f"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] " + f"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n" + ).encode("ascii"), + ( + b"4 0 obj\n<< /Length " + + str(len(stream)).encode("ascii") + + b" >>\nstream\n" + + stream + + b"\nendstream\nendobj\n" + ), + b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n", + ] + header = b"%PDF-1.4\n" + chunks = [header] + offsets = [0] + cursor = len(header) + for obj in objects: + offsets.append(cursor) + chunks.append(obj) + cursor += len(obj) + xref_at = cursor + lines = [f"xref\n0 {len(offsets)}\n", "0000000000 65535 f \n"] + lines.extend(f"{offset:010d} 00000 n \n" for offset in offsets[1:]) + trailer = (f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF").encode("ascii") + return b"".join(chunks) + "".join(lines).encode("ascii") + trailer + + +def _lines(elements: list[_Element]) -> list[str]: + return [f"({element.ai}){element.value}" for element in elements] + + +def encode_gs1_128(elements: Any) -> list[int]: + """Code 128 codewords: Start C, FNC1, data, mod-103 check, stop (106).""" + return _encode_atoms(_atoms(_prepare(elements))) + + +def human_readable(elements: Any) -> str: + """Parenthesised HRI text, fixed-length application identifiers first.""" + return "".join(_lines(_prepare(elements))) + + +def element_string(elements: Any) -> str: + """Element string with a GS separator after every variable field but the last.""" + return _join_atoms(_atoms(_prepare(elements))) + + +def to_zpl(elements: Any, *, width_dots: int = _DEFAULT_WIDTH_DOTS, height_dots: int = _DEFAULT_HEIGHT_DOTS) -> str: + """ZPL for a 203 dpi label. The barcode starts ``>;>8`` and uses ``>8`` between variable fields.""" + prepared = _prepare(elements) + width = _positive_dots("width", width_dots) + height = _positive_dots("height", height_dots) + codewords = _encode_atoms(_atoms(prepared)) + modules = sum(_runs(codewords)) + 20 + usable = max(1, width - 80) + module = max(1, min(3, usable // modules)) + bar_height = max(60, min(height // 5, 320)) + commands = [ + "^XA", + f"^PW{width}", + f"^LL{height}", + f"^FO40,40^BY{module},2,{bar_height}^BCN,{bar_height},N,N,N,N^FD{_zpl_barcode(codewords)}^FS", + ] + text_y = 40 + bar_height + 28 + for line in _lines(prepared): + commands.append(f"^FO40,{text_y}^A0N,28,28^FD{line}^FS") + text_y += 36 + commands.append("^XZ") + return "\n".join(commands) + + +def to_pdf(elements: Any) -> bytes: + """A 4 by 6 inch PDF 1.4 page with filled bars and Helvetica HRI text.""" + prepared = _prepare(elements) + codewords = _encode_atoms(_atoms(prepared)) + return _pdf_document(_pdf_stream(codewords, _lines(prepared))) diff --git a/services/ingestion/tests/test_gs1_128_labels.py b/services/ingestion/tests/test_gs1_128_labels.py new file mode 100644 index 000000000..c20edb438 --- /dev/null +++ b/services/ingestion/tests/test_gs1_128_labels.py @@ -0,0 +1,415 @@ +"""SEL-444: GS1-128 label encoding. + +Data free by design. Digit strings are built at runtime and their check +digits come from compute_gs1_check_digit. Assertions check structure. +""" + +from __future__ import annotations + +import calendar +import random +import sys +from pathlib import Path + +import pytest + +service_dir = Path(__file__).parent.parent +sys.path.insert(0, str(service_dir)) + +from app.gs1_identifiers import compute_gs1_check_digit # noqa: E402 +from app.labels.gs1_128 import ( # noqa: E402 + Gs1LabelError, + element_string, + encode_gs1_128, + human_readable, + to_pdf, + to_zpl, +) + +_FIXED = ("00", "01", "02", "11", "13", "15", "17", "410", "414") +_VARIABLE = ("10", "21", "37") +_ENTRYPOINTS = (encode_gs1_128, human_readable, element_string, to_pdf, to_zpl) + +# Code 128 symbol values. Independent of the encoder's private names. +_START_C = 105 +_FNC1 = 102 +_CODE_C = 99 +_CODE_B = 100 +_STOP = 106 + + +def _rng(*parts: object) -> random.Random: + return random.Random("sel444:" + ":".join(str(part) for part in parts)) + + +def _digits(rng: random.Random, length: int) -> str: + return "".join(str(rng.randrange(10)) for _ in range(length)) + + +def _with_check(rng: random.Random, body_length: int) -> str: + body = _digits(rng, body_length) + return body + str(compute_gs1_check_digit(body)) + + +def _flip_check(rng: random.Random, value: str) -> str: + updated = (int(value[-1]) + rng.randrange(1, 10)) % 10 + return value[:-1] + str(updated) + + +def _ymd(year: int, month: int, day: int) -> str: + return f"{year:02d}{month:02d}{day:02d}" + + +def _date(rng: random.Random, *, day_zero: bool = False) -> str: + year = rng.randrange(100) + month = rng.randrange(1, 13) + if day_zero: + return _ymd(year, month, 0) + # Years 2000 through 2099, the same calendar the encoder uses. + last = calendar.monthrange(2000 + year, month)[1] + return _ymd(year, month, rng.randrange(1, last + 1)) + + +def _letters(rng: random.Random, length: int) -> str: + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + return "".join(rng.choice(alphabet) for _ in range(length)) + + +def _order(elements: list[tuple[str, str]]) -> list[tuple[str, str]]: + fixed = [element for element in elements if element[0] in _FIXED] + variable = [element for element in elements if element[0] in _VARIABLE] + return fixed + variable + + +def _expect_human(elements: list[tuple[str, str]]) -> str: + return "".join(f"({ai}){value}" for ai, value in _order(elements)) + + +def _expect_element(elements: list[tuple[str, str]]) -> str: + ordered = _order(elements) + variable = [element for element in ordered if element[0] in _VARIABLE] + chunks: list[str] = [] + seen = 0 + for ai, value in ordered: + chunks.append(ai + value) + if ai in _VARIABLE: + seen += 1 + if seen < len(variable): + chunks.append("\x1d") + return "".join(chunks) + + +def _checksum(symbols: list[int]) -> int: + total = symbols[0] + for position, value in enumerate(symbols[1:], start=1): + total += position * value + return total % 103 + + +def _decode(codewords: list[int]) -> str: + assert codewords[0] == _START_C + assert codewords[1] == _FNC1 + assert codewords[-1] == _STOP + mode = "C" + chars: list[str] = [] + for codeword in codewords[2:-2]: + # In Code C, symbol 99 is the digit pair 99. The same number latches + # to Code C only while the encoder is in Code B. + if mode == "C": + if codeword == _CODE_B: + mode = "B" + elif codeword == _FNC1: + chars.append("\x1d") + else: + assert 0 <= codeword <= 99 + chars.append(f"{codeword:02d}") + continue + if codeword == _CODE_C: + mode = "C" + elif codeword == _FNC1: + chars.append("\x1d") + else: + assert 0 <= codeword <= 95 + chars.append(chr(codeword + 32)) + return "".join(chars) + + +def _barcode_field(zpl: str) -> str: + marker = "^FD>;>8" + start = zpl.index(marker) + len("^FD") + return zpl[start : zpl.index("^FS", start)] + + +def _sample(rng: random.Random) -> list[tuple[str, str]]: + """One of every supported AI, shuffled. Values exist only at runtime.""" + elements = [ + ("00", _with_check(rng, 17)), + ("01", _with_check(rng, 13)), + ("02", _with_check(rng, 13)), + ("11", _date(rng, day_zero=True)), + ("13", _date(rng)), + ("15", _date(rng)), + ("17", _date(rng)), + ("410", _with_check(rng, 12)), + ("414", _with_check(rng, 12)), + ("10", _letters(rng, rng.randrange(1, 21))), + # The extra ">" must stay inside the 20-character serial limit. + ("21", _letters(rng, rng.randrange(1, 20)) + ">"), + ("37", _digits(rng, rng.randrange(1, 9))), + ] + rng.shuffle(elements) + return elements + + +@pytest.mark.parametrize("fn", _ENTRYPOINTS) +@pytest.mark.parametrize("raw", [None, [], ()]) +def test_empty_input_raises(fn, raw): + with pytest.raises(Gs1LabelError): + fn(raw) + + +def test_error_is_a_value_error(): + assert issubclass(Gs1LabelError, ValueError) + + +@pytest.mark.parametrize("fn", _ENTRYPOINTS) +def test_unknown_ai_raises_without_echoing_the_value(fn): + rng = _rng("unknown", fn.__name__) + value = _letters(rng, 8) + with pytest.raises(Gs1LabelError) as caught: + fn([("ZZ", value)]) + assert value not in str(caught.value) + assert "ZZ" not in str(caught.value) + + +@pytest.mark.parametrize("ai,body_length", [("01", 13), ("02", 13), ("00", 17), ("410", 12), ("414", 12)]) +def test_runtime_identifiers_pass_and_a_mutated_check_digit_raises(ai, body_length): + rng = _rng("check", ai, body_length) + good = _with_check(rng, body_length) + assert int(good[-1]) == compute_gs1_check_digit(good[:-1]) + assert human_readable([(ai, good)]) == f"({ai}){good}" + bad = _flip_check(rng, good) + assert bad != good + with pytest.raises(Gs1LabelError) as caught: + encode_gs1_128([(ai, bad)]) + message = str(caught.value) + assert "check digit" in message + assert bad not in message + assert good not in message + + +def test_day_zero_is_allowed_and_impossible_dates_raise(): + rng = _rng("dates") + gtin = _with_check(rng, 13) + human_readable([("11", _date(rng, day_zero=True)), ("01", gtin)]) + leap = rng.randrange(0, 25) * 4 + human_readable([("13", _ymd(leap, 2, 29))]) + with pytest.raises(Gs1LabelError): + human_readable([("13", _ymd(leap + 1, 2, 29))]) + with pytest.raises(Gs1LabelError): + human_readable([("15", _ymd(rng.randrange(100), 4, 31))]) + human_readable([("17", _ymd(rng.randrange(100), 4, 0))]) + with pytest.raises(Gs1LabelError): + human_readable([("11", _ymd(rng.randrange(100), 0, 1))]) + + +def test_variable_fields_accept_limits_and_reject_bad_shape(): + rng = _rng("variable") + gtin = _with_check(rng, 13) + human_readable([("10", _letters(rng, 20)), ("01", gtin)]) + human_readable([("37", _digits(rng, 8)), ("01", gtin)]) + with pytest.raises(Gs1LabelError): + human_readable([("10", _letters(rng, 21)), ("01", gtin)]) + with pytest.raises(Gs1LabelError): + human_readable([("10", " "), ("01", gtin)]) + with pytest.raises(Gs1LabelError): + human_readable([("37", _letters(rng, 2)), ("01", gtin)]) + with pytest.raises(Gs1LabelError): + human_readable([("37", _digits(rng, 9)), ("01", gtin)]) + with pytest.raises(Gs1LabelError): + human_readable([("21", ""), ("01", gtin)]) + + +def test_charset_is_exactly_82_printable_characters(): + rng = _rng("charset") + gtin = _with_check(rng, 13) + accepted = [] + for code in range(32, 127): + character = chr(code) + try: + human_readable([("10", character), ("01", gtin)]) + except Gs1LabelError: + continue + accepted.append(character) + assert len(accepted) == 82 + assert " " not in accepted + assert all(character.isprintable() for character in accepted) + + +def test_ordering_puts_fixed_fields_first_and_gs_only_between_variable_fields(): + rng = _rng("order") + elements = [ + ("21", _letters(rng, 4)), + ("17", _date(rng)), + ("10", _letters(rng, 6)), + ("01", _with_check(rng, 13)), + ("37", _digits(rng, 3)), + ] + assert human_readable(elements) == _expect_human(elements) + encoded = element_string(elements) + assert encoded == _expect_element(elements) + assert encoded.count("\x1d") == 2 + assert not encoded.startswith("\x1d") + assert not encoded.endswith("\x1d") + # Fixed fields keep input order (17 then 01), not numeric AI order. + readable = human_readable(elements) + assert readable.index("(17)") < readable.index("(01)") + assert readable.index("(01)") < readable.index("(21)") + assert readable.index("(21)") < readable.index("(10)") + assert readable.index("(10)") < readable.index("(37)") + fixed_only = [("17", elements[1][1]), ("01", elements[3][1])] + assert "\x1d" not in element_string(fixed_only) + + +@pytest.mark.parametrize("salt", range(8)) +def test_code128_round_trip_checksum_start_and_stop(salt): + rng = _rng("code128", salt) + elements = _sample(rng) + codewords = encode_gs1_128(elements) + assert codewords[0] == _START_C + assert codewords[1] == _FNC1 + assert codewords[-1] == _STOP + assert _checksum(codewords[:-2]) == codewords[-2] + assert _decode(codewords) == element_string(elements) + assert not _decode(codewords).endswith("\x1d") + for ai, value in elements: + if ai in ("01", "02", "00", "410", "414"): + assert int(value[-1]) == compute_gs1_check_digit(value[:-1]) + assert value in _decode(codewords) + + +def test_short_gtin_whitespace_and_repeats(): + rng = _rng("shape") + gtin = _with_check(rng, 13) + short = _with_check(rng, 7) + assert len(short) == 8 + assert int(short[-1]) == compute_gs1_check_digit(short[:-1]) + with pytest.raises(Gs1LabelError) as caught: + encode_gs1_128([("01", short)]) + assert short not in str(caught.value) + assert human_readable([("01", f" {gtin}\n")]) == f"(01){gtin}" + other = _with_check(rng, 13) + with pytest.raises(Gs1LabelError) as repeated: + human_readable([("01", gtin), ("01", other)]) + assert gtin not in str(repeated.value) + assert other not in str(repeated.value) + + +def test_all_digit_label_stays_in_code_c_with_one_leading_fnc1(): + rng = _rng("numeric") + elements = [("17", _date(rng)), ("01", _with_check(rng, 13))] + codewords = encode_gs1_128(elements) + assert codewords[0] == _START_C + assert codewords[1] == _FNC1 + assert _CODE_B not in codewords[2:-2] + assert _FNC1 not in codewords[2:-2] + barcode = _barcode_field(to_zpl(elements)) + assert barcode.startswith(">;>8") + assert barcode.count(">8") == 1 + assert ">6" not in barcode + + +def test_code_c_pair_matching_the_latch_value_stays_digits(): + """A Code C symbol equal to the latch value is still two digits in ZPL.""" + rng = _rng("latch-pair") + pair = f"{_CODE_C:02d}" + body = pair + _digits(rng, 11) + gtin = body + str(compute_gs1_check_digit(body)) + assert len(gtin) == 14 + assert str(compute_gs1_check_digit(gtin[:-1])) == gtin[-1] + codewords = encode_gs1_128([("01", gtin)]) + assert _CODE_C in codewords[2:-2] + assert _CODE_B not in codewords[2:-2] + barcode = _barcode_field(to_zpl([("01", gtin)])) + assert barcode.startswith(f">;>801{pair}") + assert ">5" not in barcode + assert ">6" not in barcode + assert barcode.count(">8") == 1 + payload = barcode[len(">;>8") :] + assert payload.isdigit() + assert payload == "01" + gtin + + +def test_dict_elements_match_pairs(): + rng = _rng("dict") + gtin = _with_check(rng, 13) + lot = _letters(rng, 5) + pairs = [("10", lot), ("01", gtin)] + as_dicts = [{"ai": ai, "value": value} for ai, value in pairs] + assert human_readable(as_dicts) == human_readable(pairs) + assert encode_gs1_128(as_dicts) == encode_gs1_128(pairs) + + +def test_zpl_framing_and_fnc1_markers(): + rng = _rng("zpl") + elements = _sample(rng) + zpl = to_zpl(elements) + assert zpl.startswith("^XA") + assert zpl.endswith("^XZ") + assert "^BC" in zpl + assert "^PW812" in zpl + assert "^LL1218" in zpl + barcode = _barcode_field(zpl) + assert barcode.startswith(">;>8") + variable_count = sum(1 for ai, _value in elements if ai in _VARIABLE) + # Leading FNC1 plus one separator between each variable field, and none after the last. + assert barcode.count(">8") == variable_count + assert ">6" in barcode + assert ">0" in barcode + for ai, value in _order(elements): + assert f"^FD({ai}){value}^FS" in zpl + custom = to_zpl(elements[:1], width_dots=400, height_dots=600) + assert custom.startswith("^XA") + assert custom.endswith("^XZ") + assert "^PW400" in custom + assert "^LL600" in custom + assert _barcode_field(custom).startswith(">;>8") + + +def test_pdf_header_trailer_and_xref_offsets(): + rng = _rng("pdf") + elements = _sample(rng) + pdf = to_pdf(elements) + assert pdf.startswith(b"%PDF-1.4") + assert pdf.endswith(b"%%EOF") + assert f"/MediaBox [0 0 {4 * 72} {6 * 72}]".encode("ascii") in pdf + assert b"/BaseFont /Helvetica" in pdf + assert b" re f\n" in pdf + for ai, value in _order(elements): + line = f"({ai}){value}".encode("ascii").hex().upper().encode("ascii") + assert line in pdf + + startxref = pdf.rfind(b"startxref") + pointer = pdf[startxref:].splitlines() + assert pointer[0] == b"startxref" + xref_at = int(pointer[1]) + assert pdf[xref_at : xref_at + 4] == b"xref" + section = pdf[xref_at:].splitlines() + start_at, count = section[1].split() + assert start_at == b"0" + count_i = int(count) + entries = section[2 : 2 + count_i] + assert len(entries) == count_i + assert entries[0] == b"0000000000 65535 f " + for index, entry in enumerate(entries[1:], start=1): + offset = int(entry.split()[0]) + header = f"{index} 0 obj".encode("ascii") + assert pdf[offset : offset + len(header)] == header + + length_at = pdf.index(b"/Length ") + length_end = pdf.index(b" ", length_at + len(b"/Length ")) + declared = int(pdf[length_at + len(b"/Length ") : length_end]) + stream_at = pdf.index(b"stream\n", length_at) + len(b"stream\n") + assert pdf[stream_at : stream_at + declared] + assert pdf[stream_at + declared :].startswith(b"\nendstream") + assert to_pdf(elements) == pdf From 9cd04cacbfce89a58bfdb03545fc18cf2d40f0d8 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:06:00 -0700 Subject: [PATCH 2/4] test(labels): drop seeded identifiers and unwired label panel (SEL-444) GS1-128 tests now use all-zero identifiers with computed check digits and no generated data. Removes Gs1128LabelPanel and its test, which failed the Frontend Unused File Guard; the panel returns once an API route exists. --- .../components/labels/Gs1128LabelPanel.tsx | 54 ----- .../__tests__/Gs1128LabelPanel.test.tsx | 20 -- .../ingestion/tests/test_gs1_128_labels.py | 192 +++++++----------- 3 files changed, 78 insertions(+), 188 deletions(-) delete mode 100644 frontend/src/components/labels/Gs1128LabelPanel.tsx delete mode 100644 frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx diff --git a/frontend/src/components/labels/Gs1128LabelPanel.tsx b/frontend/src/components/labels/Gs1128LabelPanel.tsx deleted file mode 100644 index f1213e603..000000000 --- a/frontend/src/components/labels/Gs1128LabelPanel.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { EmptyState } from "@/components/ui/empty-state" - -export interface Gs1LabelElement { - ai: string - value: string -} - -export interface Gs1128LabelPanelProps { - elements?: readonly Gs1LabelElement[] | null - onDownloadPdf?: (elements: readonly Gs1LabelElement[]) => void - onDownloadZpl?: (elements: readonly Gs1LabelElement[]) => void -} - -/** - * Presentational GS1-128 label actions. Callers pass ingested elements. - * An empty list stays empty: this component never invents identifier text. - */ -export function Gs1128LabelPanel({ - elements, - onDownloadPdf, - onDownloadZpl, -}: Gs1128LabelPanelProps) { - if (!elements || elements.length === 0) { - return ( - - ) - } - - return ( -
-
    - {elements.map((element, index) => ( -
  • - ({element.ai}) {element.value} -
  • - ))} -
-
- - -
-
- ) -} diff --git a/frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx b/frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx deleted file mode 100644 index ddad76d81..000000000 --- a/frontend/src/components/labels/__tests__/Gs1128LabelPanel.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { render, screen } from "@testing-library/react" - -import { Gs1128LabelPanel } from "@/components/labels/Gs1128LabelPanel" - -describe("Gs1128LabelPanel", () => { - it("renders an empty state when no elements are passed", () => { - render() - - expect(screen.getByText("No label data yet")).toBeInTheDocument() - expect(screen.getByText(/ingest real GTIN and lot data/i)).toBeInTheDocument() - expect(screen.queryByRole("button")).not.toBeInTheDocument() - }) - - it("renders an empty state for an empty element list", () => { - render() - - expect(screen.getByText("No label data yet")).toBeInTheDocument() - expect(screen.queryByRole("button")).not.toBeInTheDocument() - }) -}) diff --git a/services/ingestion/tests/test_gs1_128_labels.py b/services/ingestion/tests/test_gs1_128_labels.py index c20edb438..956ba080a 100644 --- a/services/ingestion/tests/test_gs1_128_labels.py +++ b/services/ingestion/tests/test_gs1_128_labels.py @@ -1,13 +1,11 @@ """SEL-444: GS1-128 label encoding. -Data free by design. Digit strings are built at runtime and their check -digits come from compute_gs1_check_digit. Assertions check structure. +Values are all-zero identifiers with computed check digits and no generated +data. Assertions check structure. """ from __future__ import annotations -import calendar -import random import sys from pathlib import Path @@ -37,42 +35,50 @@ _CODE_B = 100 _STOP = 106 +# Day 00 means the whole month. Month 00 is not a month, so the day-zero +# case is January. These six strings are the only dates the tests use. +_DAY_ZERO = "000100" +_LEAP_DAY = "000229" +_NON_LEAP_DAY = "010229" +_APRIL_31 = "000431" +_MONTH_ZERO = "000001" +_PLAIN_DATE = "000101" -def _rng(*parts: object) -> random.Random: - return random.Random("sel444:" + ":".join(str(part) for part in parts)) - -def _digits(rng: random.Random, length: int) -> str: - return "".join(str(rng.randrange(10)) for _ in range(length)) - - -def _with_check(rng: random.Random, body_length: int) -> str: - body = _digits(rng, body_length) +def _zero_identifier(body_length: int) -> str: + """All-zero body plus the mod-10 digit from compute_gs1_check_digit.""" + body = "0" * body_length return body + str(compute_gs1_check_digit(body)) -def _flip_check(rng: random.Random, value: str) -> str: - updated = (int(value[-1]) + rng.randrange(1, 10)) % 10 +def _bad_check(value: str) -> str: + """Change only the last digit, deterministically: (d + 1) mod 10.""" + updated = (int(value[-1]) + 1) % 10 return value[:-1] + str(updated) -def _ymd(year: int, month: int, day: int) -> str: - return f"{year:02d}{month:02d}{day:02d}" - - -def _date(rng: random.Random, *, day_zero: bool = False) -> str: - year = rng.randrange(100) - month = rng.randrange(1, 13) - if day_zero: - return _ymd(year, month, 0) - # Years 2000 through 2099, the same calendar the encoder uses. - last = calendar.monthrange(2000 + year, month)[1] - return _ymd(year, month, rng.randrange(1, last + 1)) - - -def _letters(rng: random.Random, length: int) -> str: - alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - return "".join(rng.choice(alphabet) for _ in range(length)) +_GTIN14 = _zero_identifier(13) +_GTIN8 = _zero_identifier(7) +_SSCC = _zero_identifier(17) +_GLN = _zero_identifier(12) + +# Every supported AI. Input order is not fixed-then-variable, so the encoder +# still has to regroup. Serial is "0>" so ZPL must latch to Code B (">6") +# and escape ">" as ">0". An all-zero label would stay in Code C and emit neither. +_ALL_AIS = [ + ("21", "0>"), + ("17", _PLAIN_DATE), + ("10", "0"), + ("01", _GTIN14), + ("00", _SSCC), + ("37", "0"), + ("11", _DAY_ZERO), + ("02", _GTIN14), + ("13", _LEAP_DAY), + ("410", _GLN), + ("15", _PLAIN_DATE), + ("414", _GLN), +] def _order(elements: list[tuple[str, str]]) -> list[tuple[str, str]]: @@ -140,27 +146,6 @@ def _barcode_field(zpl: str) -> str: return zpl[start : zpl.index("^FS", start)] -def _sample(rng: random.Random) -> list[tuple[str, str]]: - """One of every supported AI, shuffled. Values exist only at runtime.""" - elements = [ - ("00", _with_check(rng, 17)), - ("01", _with_check(rng, 13)), - ("02", _with_check(rng, 13)), - ("11", _date(rng, day_zero=True)), - ("13", _date(rng)), - ("15", _date(rng)), - ("17", _date(rng)), - ("410", _with_check(rng, 12)), - ("414", _with_check(rng, 12)), - ("10", _letters(rng, rng.randrange(1, 21))), - # The extra ">" must stay inside the 20-character serial limit. - ("21", _letters(rng, rng.randrange(1, 20)) + ">"), - ("37", _digits(rng, rng.randrange(1, 9))), - ] - rng.shuffle(elements) - return elements - - @pytest.mark.parametrize("fn", _ENTRYPOINTS) @pytest.mark.parametrize("raw", [None, [], ()]) def test_empty_input_raises(fn, raw): @@ -174,8 +159,7 @@ def test_error_is_a_value_error(): @pytest.mark.parametrize("fn", _ENTRYPOINTS) def test_unknown_ai_raises_without_echoing_the_value(fn): - rng = _rng("unknown", fn.__name__) - value = _letters(rng, 8) + value = "0" with pytest.raises(Gs1LabelError) as caught: fn([("ZZ", value)]) assert value not in str(caught.value) @@ -184,11 +168,10 @@ def test_unknown_ai_raises_without_echoing_the_value(fn): @pytest.mark.parametrize("ai,body_length", [("01", 13), ("02", 13), ("00", 17), ("410", 12), ("414", 12)]) def test_runtime_identifiers_pass_and_a_mutated_check_digit_raises(ai, body_length): - rng = _rng("check", ai, body_length) - good = _with_check(rng, body_length) + good = _zero_identifier(body_length) assert int(good[-1]) == compute_gs1_check_digit(good[:-1]) assert human_readable([(ai, good)]) == f"({ai}){good}" - bad = _flip_check(rng, good) + bad = _bad_check(good) assert bad != good with pytest.raises(Gs1LabelError) as caught: encode_gs1_128([(ai, bad)]) @@ -199,45 +182,38 @@ def test_runtime_identifiers_pass_and_a_mutated_check_digit_raises(ai, body_leng def test_day_zero_is_allowed_and_impossible_dates_raise(): - rng = _rng("dates") - gtin = _with_check(rng, 13) - human_readable([("11", _date(rng, day_zero=True)), ("01", gtin)]) - leap = rng.randrange(0, 25) * 4 - human_readable([("13", _ymd(leap, 2, 29))]) + human_readable([("11", _DAY_ZERO), ("01", _GTIN14)]) + human_readable([("13", _LEAP_DAY)]) + human_readable([("17", _PLAIN_DATE)]) with pytest.raises(Gs1LabelError): - human_readable([("13", _ymd(leap + 1, 2, 29))]) + human_readable([("13", _NON_LEAP_DAY)]) with pytest.raises(Gs1LabelError): - human_readable([("15", _ymd(rng.randrange(100), 4, 31))]) - human_readable([("17", _ymd(rng.randrange(100), 4, 0))]) + human_readable([("15", _APRIL_31)]) with pytest.raises(Gs1LabelError): - human_readable([("11", _ymd(rng.randrange(100), 0, 1))]) + human_readable([("11", _MONTH_ZERO)]) def test_variable_fields_accept_limits_and_reject_bad_shape(): - rng = _rng("variable") - gtin = _with_check(rng, 13) - human_readable([("10", _letters(rng, 20)), ("01", gtin)]) - human_readable([("37", _digits(rng, 8)), ("01", gtin)]) + human_readable([("10", "0" * 20), ("01", _GTIN14)]) + human_readable([("37", "0" * 8), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("10", _letters(rng, 21)), ("01", gtin)]) + human_readable([("10", "0" * 21), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("10", " "), ("01", gtin)]) + human_readable([("10", " "), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("37", _letters(rng, 2)), ("01", gtin)]) + human_readable([("37", "0A"), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("37", _digits(rng, 9)), ("01", gtin)]) + human_readable([("37", "0" * 9), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("21", ""), ("01", gtin)]) + human_readable([("21", ""), ("01", _GTIN14)]) def test_charset_is_exactly_82_printable_characters(): - rng = _rng("charset") - gtin = _with_check(rng, 13) accepted = [] for code in range(32, 127): character = chr(code) try: - human_readable([("10", character), ("01", gtin)]) + human_readable([("10", character), ("01", _GTIN14)]) except Gs1LabelError: continue accepted.append(character) @@ -247,13 +223,12 @@ def test_charset_is_exactly_82_printable_characters(): def test_ordering_puts_fixed_fields_first_and_gs_only_between_variable_fields(): - rng = _rng("order") elements = [ - ("21", _letters(rng, 4)), - ("17", _date(rng)), - ("10", _letters(rng, 6)), - ("01", _with_check(rng, 13)), - ("37", _digits(rng, 3)), + ("21", "0"), + ("17", _PLAIN_DATE), + ("10", "0"), + ("01", _GTIN14), + ("37", "0"), ] assert human_readable(elements) == _expect_human(elements) encoded = element_string(elements) @@ -271,10 +246,8 @@ def test_ordering_puts_fixed_fields_first_and_gs_only_between_variable_fields(): assert "\x1d" not in element_string(fixed_only) -@pytest.mark.parametrize("salt", range(8)) -def test_code128_round_trip_checksum_start_and_stop(salt): - rng = _rng("code128", salt) - elements = _sample(rng) +def test_code128_round_trip_checksum_start_and_stop(): + elements = _ALL_AIS codewords = encode_gs1_128(elements) assert codewords[0] == _START_C assert codewords[1] == _FNC1 @@ -289,25 +262,19 @@ def test_code128_round_trip_checksum_start_and_stop(salt): def test_short_gtin_whitespace_and_repeats(): - rng = _rng("shape") - gtin = _with_check(rng, 13) - short = _with_check(rng, 7) - assert len(short) == 8 - assert int(short[-1]) == compute_gs1_check_digit(short[:-1]) + assert len(_GTIN8) == 8 + assert int(_GTIN8[-1]) == compute_gs1_check_digit(_GTIN8[:-1]) with pytest.raises(Gs1LabelError) as caught: - encode_gs1_128([("01", short)]) - assert short not in str(caught.value) - assert human_readable([("01", f" {gtin}\n")]) == f"(01){gtin}" - other = _with_check(rng, 13) + encode_gs1_128([("01", _GTIN8)]) + assert _GTIN8 not in str(caught.value) + assert human_readable([("01", f" {_GTIN14}\n")]) == f"(01){_GTIN14}" with pytest.raises(Gs1LabelError) as repeated: - human_readable([("01", gtin), ("01", other)]) - assert gtin not in str(repeated.value) - assert other not in str(repeated.value) + human_readable([("01", _GTIN14), ("01", _GTIN14)]) + assert _GTIN14 not in str(repeated.value) def test_all_digit_label_stays_in_code_c_with_one_leading_fnc1(): - rng = _rng("numeric") - elements = [("17", _date(rng)), ("01", _with_check(rng, 13))] + elements = [("17", _PLAIN_DATE), ("01", _GTIN14)] codewords = encode_gs1_128(elements) assert codewords[0] == _START_C assert codewords[1] == _FNC1 @@ -321,9 +288,9 @@ def test_all_digit_label_stays_in_code_c_with_one_leading_fnc1(): def test_code_c_pair_matching_the_latch_value_stays_digits(): """A Code C symbol equal to the latch value is still two digits in ZPL.""" - rng = _rng("latch-pair") - pair = f"{_CODE_C:02d}" - body = pair + _digits(rng, 11) + pair = "99" + assert _CODE_C == int(pair) + body = pair + "0" * 11 gtin = body + str(compute_gs1_check_digit(body)) assert len(gtin) == 14 assert str(compute_gs1_check_digit(gtin[:-1])) == gtin[-1] @@ -341,18 +308,14 @@ def test_code_c_pair_matching_the_latch_value_stays_digits(): def test_dict_elements_match_pairs(): - rng = _rng("dict") - gtin = _with_check(rng, 13) - lot = _letters(rng, 5) - pairs = [("10", lot), ("01", gtin)] + pairs = [("10", "0"), ("01", _GTIN14)] as_dicts = [{"ai": ai, "value": value} for ai, value in pairs] assert human_readable(as_dicts) == human_readable(pairs) assert encode_gs1_128(as_dicts) == encode_gs1_128(pairs) def test_zpl_framing_and_fnc1_markers(): - rng = _rng("zpl") - elements = _sample(rng) + elements = _ALL_AIS zpl = to_zpl(elements) assert zpl.startswith("^XA") assert zpl.endswith("^XZ") @@ -364,6 +327,8 @@ def test_zpl_framing_and_fnc1_markers(): variable_count = sum(1 for ai, _value in elements if ai in _VARIABLE) # Leading FNC1 plus one separator between each variable field, and none after the last. assert barcode.count(">8") == variable_count + # ">6" is the Code C to Code B latch. ">0" is subset B's encoding of ">". + # Both come from serial "0>". All-zero digits would not produce them. assert ">6" in barcode assert ">0" in barcode for ai, value in _order(elements): @@ -377,8 +342,7 @@ def test_zpl_framing_and_fnc1_markers(): def test_pdf_header_trailer_and_xref_offsets(): - rng = _rng("pdf") - elements = _sample(rng) + elements = _ALL_AIS pdf = to_pdf(elements) assert pdf.startswith(b"%PDF-1.4") assert pdf.endswith(b"%%EOF") From 28a4abcb57c32d890f0384846c745a96e6e09f79 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:10:28 -0700 Subject: [PATCH 3/4] test(labels): derive GS1-128 dates and lot values instead of fixed literals (SEL-444) Dates are built from today's date (day zero, leap day, Feb 29 in a non-leap year, day past month end, month zero). Lot, serial and count values come from the AI length limits. Identifiers stay all zeros plus computed check digits. No randomness or seed. --- .../ingestion/tests/test_gs1_128_labels.py | 76 ++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/services/ingestion/tests/test_gs1_128_labels.py b/services/ingestion/tests/test_gs1_128_labels.py index 956ba080a..b3a5729d4 100644 --- a/services/ingestion/tests/test_gs1_128_labels.py +++ b/services/ingestion/tests/test_gs1_128_labels.py @@ -1,12 +1,14 @@ """SEL-444: GS1-128 label encoding. -Values are all-zero identifiers with computed check digits and no generated -data. Assertions check structure. +Identifiers are all zeros plus computed check digits. Dates come from +today's date and lot values from the length limits. No randomness. Assertions check structure. """ from __future__ import annotations +import calendar import sys +from datetime import date from pathlib import Path import pytest @@ -35,14 +37,36 @@ _CODE_B = 100 _STOP = 106 -# Day 00 means the whole month. Month 00 is not a month, so the day-zero -# case is January. These six strings are the only dates the tests use. -_DAY_ZERO = "000100" -_LEAP_DAY = "000229" -_NON_LEAP_DAY = "010229" -_APRIL_31 = "000431" -_MONTH_ZERO = "000001" -_PLAIN_DATE = "000101" +# Dates are built from today's date. Nothing below is a fixed calendar date. +_TODAY = date.today() +_YY = _TODAY.strftime("%y") +_LEAP_YEAR = next(y for y in range(_TODAY.year, _TODAY.year + 8) if calendar.isleap(y)) +_NON_LEAP_YEAR = next(y for y in range(_TODAY.year, _TODAY.year + 8) if not calendar.isleap(y)) + + +def _yymmdd(year: int, month: int, day: int) -> str: + return f"{year % 100:02d}{month:02d}{day:02d}" + + +# Day 00 means the whole month and is allowed. +_PLAIN_DATE = _TODAY.strftime("%y%m%d") +_DAY_ZERO = _yymmdd(_TODAY.year, _TODAY.month, 0) +_LEAP_DAY = _yymmdd(_LEAP_YEAR, 2, calendar.monthrange(_LEAP_YEAR, 2)[1]) +# One day past the end of the month: Feb 29 in a non-leap year, and the +# day after the last day of this month. +_NON_LEAP_DAY = _yymmdd(_NON_LEAP_YEAR, 2, calendar.monthrange(_LEAP_YEAR, 2)[1]) +_PAST_MONTH_END = _yymmdd(_TODAY.year, _TODAY.month, calendar.monthrange(_TODAY.year, _TODAY.month)[1] + 1) +_MONTH_ZERO = _yymmdd(_TODAY.year, 0, 1) + +# Lot, serial and count values are built from the AI length limits. +_TEXT_MAX = 20 # AI 10 and AI 21 +_COUNT_MAX = 8 # AI 37 +_LOT = "0" +# ">" is ZPL's escape character. A serial ending in it forces a Code B latch +# (">6") and the escape (">0") in the ZPL field. +_ZPL_ESCAPE = ">" +_SERIAL_WITH_ESCAPE = "0" + _ZPL_ESCAPE +_NON_DIGIT = next(c for c in map(chr, range(33, 127)) if not c.isdigit()) def _zero_identifier(body_length: int) -> str: @@ -63,15 +87,15 @@ def _bad_check(value: str) -> str: _GLN = _zero_identifier(12) # Every supported AI. Input order is not fixed-then-variable, so the encoder -# still has to regroup. Serial is "0>" so ZPL must latch to Code B (">6") +# still has to regroup. Serial ends in ">" so ZPL must latch to Code B (">6") # and escape ">" as ">0". An all-zero label would stay in Code C and emit neither. _ALL_AIS = [ - ("21", "0>"), + ("21", _SERIAL_WITH_ESCAPE), ("17", _PLAIN_DATE), - ("10", "0"), + ("10", _LOT), ("01", _GTIN14), ("00", _SSCC), - ("37", "0"), + ("37", _LOT), ("11", _DAY_ZERO), ("02", _GTIN14), ("13", _LEAP_DAY), @@ -159,7 +183,7 @@ def test_error_is_a_value_error(): @pytest.mark.parametrize("fn", _ENTRYPOINTS) def test_unknown_ai_raises_without_echoing_the_value(fn): - value = "0" + value = _LOT with pytest.raises(Gs1LabelError) as caught: fn([("ZZ", value)]) assert value not in str(caught.value) @@ -188,22 +212,22 @@ def test_day_zero_is_allowed_and_impossible_dates_raise(): with pytest.raises(Gs1LabelError): human_readable([("13", _NON_LEAP_DAY)]) with pytest.raises(Gs1LabelError): - human_readable([("15", _APRIL_31)]) + human_readable([("15", _PAST_MONTH_END)]) with pytest.raises(Gs1LabelError): human_readable([("11", _MONTH_ZERO)]) def test_variable_fields_accept_limits_and_reject_bad_shape(): - human_readable([("10", "0" * 20), ("01", _GTIN14)]) - human_readable([("37", "0" * 8), ("01", _GTIN14)]) + human_readable([("10", "0" * _TEXT_MAX), ("01", _GTIN14)]) + human_readable([("37", "0" * _COUNT_MAX), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("10", "0" * 21), ("01", _GTIN14)]) + human_readable([("10", "0" * (_TEXT_MAX + 1)), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): human_readable([("10", " "), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("37", "0A"), ("01", _GTIN14)]) + human_readable([("37", "0" + _NON_DIGIT), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): - human_readable([("37", "0" * 9), ("01", _GTIN14)]) + human_readable([("37", "0" * (_COUNT_MAX + 1)), ("01", _GTIN14)]) with pytest.raises(Gs1LabelError): human_readable([("21", ""), ("01", _GTIN14)]) @@ -224,11 +248,11 @@ def test_charset_is_exactly_82_printable_characters(): def test_ordering_puts_fixed_fields_first_and_gs_only_between_variable_fields(): elements = [ - ("21", "0"), + ("21", _LOT), ("17", _PLAIN_DATE), - ("10", "0"), + ("10", _LOT), ("01", _GTIN14), - ("37", "0"), + ("37", _LOT), ] assert human_readable(elements) == _expect_human(elements) encoded = element_string(elements) @@ -308,7 +332,7 @@ def test_code_c_pair_matching_the_latch_value_stays_digits(): def test_dict_elements_match_pairs(): - pairs = [("10", "0"), ("01", _GTIN14)] + pairs = [("10", _LOT), ("01", _GTIN14)] as_dicts = [{"ai": ai, "value": value} for ai, value in pairs] assert human_readable(as_dicts) == human_readable(pairs) assert encode_gs1_128(as_dicts) == encode_gs1_128(pairs) @@ -328,7 +352,7 @@ def test_zpl_framing_and_fnc1_markers(): # Leading FNC1 plus one separator between each variable field, and none after the last. assert barcode.count(">8") == variable_count # ">6" is the Code C to Code B latch. ">0" is subset B's encoding of ">". - # Both come from serial "0>". All-zero digits would not produce them. + # Both come from the serial ending in ">". All-zero digits would not produce them. assert ">6" in barcode assert ">0" in barcode for ai, value in _order(elements): From 8ac7ed5c08d848bc796fa8c663e03b0f5a85ec4a Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:21:12 -0700 Subject: [PATCH 4/4] ci: trigger required gates Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com>