diff --git a/tests/test_charset_tables.py b/tests/test_charset_tables.py new file mode 100644 index 0000000..cdf258b --- /dev/null +++ b/tests/test_charset_tables.py @@ -0,0 +1,107 @@ +"""Invariant tests for the encoding tables in ``barcode.charsets``. + +The tables are data, but they encode the symbology specifications: +EAN's A/B/C pattern relationships, ITF's two-of-five property, the +Code 39 reference map, and so on. Pinning the invariants catches +accidental edits to the tables that no behavioural test would notice. +""" + +from __future__ import annotations + +from barcode.charsets import codabar +from barcode.charsets import code39 +from barcode.charsets import code128 +from barcode.charsets import ean +from barcode.charsets import itf +from barcode.charsets import upc + + +def test_ean_left_pattern_table() -> None: + assert len(ean.LEFT_PATTERN) == 10 + for pattern in ean.LEFT_PATTERN: + assert len(pattern) == 6 + assert set(pattern) <= {"A", "B"} + + +def test_ean_code_tables_shape() -> None: + assert set(ean.CODES) == {"A", "B", "C"} + for table in ean.CODES.values(): + assert len(table) == 10 + for pattern in table: + assert len(pattern) == 7 + assert set(pattern) <= {"0", "1"} + + +def test_ean_c_patterns_are_the_complement_of_a() -> None: + for i in range(10): + complement = "".join("1" if b == "0" else "0" for b in ean.CODES["A"][i]) + assert ean.CODES["C"][i] == complement + + +def test_ean_b_patterns_are_the_reversed_c_patterns() -> None: + for i in range(10): + assert ean.CODES["B"][i] == ean.CODES["C"][i][::-1] + + +def test_upc_tables_reuse_the_ean_tables() -> None: + assert upc.CODES["L"] == ean.CODES["A"] + assert upc.CODES["R"] == ean.CODES["C"] + assert upc.EDGE == ean.EDGE == "101" + assert upc.MIDDLE == ean.MIDDLE == "01010" + + +def test_itf_table_has_the_two_of_five_property() -> None: + assert itf.START == "NnNn" + assert itf.STOP == "WnN" + assert len(itf.CODES) == 10 + for pattern in itf.CODES: + assert len(pattern) == 5 + assert set(pattern) <= {"N", "W"} + # Interleaved 2 of 5: exactly two of the five elements are wide. + assert pattern.count("W") == 2 + + +def test_code39_tables_shape() -> None: + assert len(code39.REF) == 43 + assert len(code39.CODES) == 43 + assert len(code39.MAP) == 43 + for pattern in code39.CODES: + assert len(pattern) == 15 + assert set(pattern) <= {"0", "1"} + assert code39.EDGE == "100010111011101" + assert code39.MIDDLE == "0" + + +def test_code39_map_assigns_sequential_values() -> None: + for index, char in enumerate(code39.REF): + value, pattern = code39.MAP[char] + assert value == index + assert pattern == code39.CODES[index] + + +def test_code39_map_spot_values() -> None: + assert code39.MAP["0"] == (0, "101000111011101") + assert code39.MAP["A"] == (10, "111010100010111") + assert code39.MAP["%"] == (42, "101000100010001") + + +def test_code128_pattern_table_shape() -> None: + assert len(code128.CODES) == 106 + assert len(set(code128.CODES)) == 106 + for pattern in code128.CODES: + assert len(pattern) == 11 + assert set(pattern) <= {"0", "1"} + assert code128.STOP == "11000111010" + + +def test_codabar_tables_shape() -> None: + assert len(codabar.CODES) == 16 + for pattern in codabar.CODES.values(): + assert len(pattern) == 7 + assert set(pattern) <= {"N", "n", "W", "w"} + assert codabar.STARTSTOP == { + "A": "NnWwNwN", + "B": "NwNwNnW", + "C": "NnNwNwW", + "D": "NnNwWwN", + } diff --git a/tests/test_codabar.py b/tests/test_codabar.py new file mode 100644 index 0000000..58a9fb4 --- /dev/null +++ b/tests/test_codabar.py @@ -0,0 +1,75 @@ +"""Tests for Codabar / NW-7 (``barcode.codabar``). + +Codabar previously had no dedicated tests. Validation happens lazily +in ``build()`` rather than in the constructor, which these tests pin. +""" + +from __future__ import annotations + +import pytest + +from barcode.codabar import CODABAR +from barcode.errors import BarcodeError +from barcode.errors import IllegalCharacterError + + +def test_codabar_str_and_fullcode_return_the_code() -> None: + code = CODABAR("A123B") + assert str(code) == "A123B" + assert code.get_fullcode() == "A123B" + + +def test_codabar_build_pattern_with_default_widths() -> None: + code = CODABAR("A123B").build() + assert code == [ + "1100111110000011000001100110011001111100000110011001100000" + "11001111100111110000011001100110011000001100000110011111" + ] + assert len(code[0]) == 114 + + +def test_codabar_build_pattern_with_custom_widths() -> None: + assert CODABAR("A123B", narrow=1, wide=3).build() == [ + "101110001000101010111000101010001011101110001010101000100010111" + ] + + +def test_codabar_all_special_characters_build() -> None: + # Digits plus the six specials $ : / . + - are all legal in the body. + assert len(CODABAR("A$:/.+-B").build()[0]) == 192 + + +def test_codabar_constructor_does_not_validate() -> None: + # Construction stores the code verbatim; only build() complains. + code = CODABAR("X123A") + assert code.code == "X123A" + + +def test_codabar_bad_start_character_raises_on_build() -> None: + with pytest.raises(BarcodeError, match="Codabar should start with either A,B,C or D"): + CODABAR("X123A").build() + + +def test_codabar_lowercase_start_character_raises_on_build() -> None: + with pytest.raises(BarcodeError, match="Codabar should start with either"): + CODABAR("a123b").build() + + +def test_codabar_bad_end_character_raises_on_build() -> None: + with pytest.raises(BarcodeError, match="Codabar should end with either A,B,C or D"): + CODABAR("A123X").build() + + +def test_codabar_illegal_body_character_raises_on_build() -> None: + with pytest.raises( + IllegalCharacterError, match="Codabar can only contain numerics" + ): + CODABAR("A1@3B").build() + + +def test_codabar_empty_code_raises_index_error_on_build() -> None: + # There is no explicit emptiness check; indexing the first + # character of an empty code raises IndexError. Pins the current + # behaviour. + with pytest.raises(IndexError): + CODABAR("").build() diff --git a/tests/test_code128_charsets.py b/tests/test_code128_charsets.py new file mode 100644 index 0000000..56baafe --- /dev/null +++ b/tests/test_code128_charsets.py @@ -0,0 +1,105 @@ +"""Tests for the Code 128 charset tables and encoder sequences. + +``test_code128.py`` covers build stability and ``test_gs1_128.py`` +covers GS1 sequences; these tests pin the charset dictionaries +themselves and plain Code 128 symbol sequences (start-code folding, +charset C digit pairing, odd trailing digits, control characters). +""" + +from __future__ import annotations + +import pytest + +from barcode.charsets import code128 +from barcode.codex import Code128 +from barcode.errors import IllegalCharacterError + + +@pytest.mark.parametrize( + ("char", "value"), + [ + (" ", 0), + ("0", 16), + ("A", 33), + ("\x00", 64), + ("SHIFT", 98), + ("TO_C", 99), + ("TO_B", 100), + ], +) +def test_code128_charset_a_values(char: str, value: int) -> None: + assert code128.A[char] == value + + +@pytest.mark.parametrize( + ("char", "value"), + [ + ("0", 16), + ("`", 64), + ("a", 65), + ("TO_C", 99), + ("TO_A", 101), + ], +) +def test_code128_charset_b_values(char: str, value: int) -> None: + assert code128.B[char] == value + + +def test_code128_charset_sizes() -> None: + assert len(code128.A) == 103 + assert len(code128.B) == 103 + + +def test_code128_charset_c_switch_codes() -> None: + assert code128.C == {"TO_B": 100, "TO_A": 101, "\xf1": 102} + + +def test_code128_start_and_switch_tables() -> None: + assert code128.START_CODES == {"A": 103, "B": 104, "C": 105} + assert code128.TO == {101: 103, 100: 104, 99: 105} + + +def test_code128_fnc1_character() -> None: + # The FNC1 character U+00F1 is "ñ"; it is part of the legal input + # set and maps to value 102 in charset C (and, coincidentally, + # also in charset B). + assert "\xf1" in code128.ALL + assert code128.B["\xf1"] == 102 + + +@pytest.mark.parametrize( + ("given", "encoded"), + [ + ("1234", [105, 12, 34]), # pure charset C digit pairs + ("12", [105, 12]), + ("1", [104, 17]), # single digit: folded to START_B + ("12345", [105, 12, 34, 100, 21]), # trailing digit via charset B + ("AB", [104, 33, 34]), # folded to START_B + ("ab", [104, 65, 66]), # lowercase requires charset B + ("A\x01B", [104, 33, 101, 65, 34]), # control char switches to A + ], +) +def test_code128_encoded_sequences(given: str, encoded: list[int]) -> None: + assert Code128(given).encoded == encoded + + +def test_code128_build_length_and_pattern() -> None: + code = Code128("1234").build() + assert len(code[0]) == 57 + assert Code128("12A").build() == [ + "11010011100101100111001011110111010100011000100100011001100011101011" + ] + + +def test_code128_illegal_character_raises() -> None: + with pytest.raises( + IllegalCharacterError, match="not valid for Code 128: €" + ): + Code128("A€B") + + +def test_code128_empty_input_raises_index_error() -> None: + # _try_to_optimize indexes the second symbol unconditionally, so + # an empty code raises IndexError. Pins the current behaviour. + with pytest.raises(IndexError): + Code128("").encoded diff --git a/tests/test_code39_pzn.py b/tests/test_code39_pzn.py new file mode 100644 index 0000000..0772174 --- /dev/null +++ b/tests/test_code39_pzn.py @@ -0,0 +1,126 @@ +"""Tests for Code 39 and PZN (``barcode.codex``). + +``test_checksums.py`` pins a single checksum example for each; these +tests cover the wider behaviour: upper-casing, the checksum character +for a range of inputs, building every legal character, the error +paths, and PZN length/checksum validation. +""" + +from __future__ import annotations + +import pytest + +from barcode.charsets import code39 as code39_charset +from barcode.codex import PZN +from barcode.codex import PZN8 +from barcode.codex import Code39 +from barcode.errors import BarcodeError +from barcode.errors import IllegalCharacterError +from barcode.errors import NumberOfDigitsError + + +@pytest.mark.parametrize( + ("given", "full"), + [ + ("ABC", "ABCX"), + ("A", "AA"), + ("0", "00"), + ("HELLO", "HELLOB"), + ("12345", "12345F"), + ("-", "--"), + (" ", " "), + ], +) +def test_code39_appended_checksum_characters(given: str, full: str) -> None: + code = Code39(given) + assert code.code == full + assert code.get_fullcode() == full + assert str(code) == full + + +def test_code39_input_is_uppercased() -> None: + assert Code39("abc").code == "ABCX" + + +def test_code39_without_checksum_keeps_code_unchanged() -> None: + code = Code39("ABC", add_checksum=False) + assert code.code == "ABC" + assert code.get_fullcode() == "ABC" + + +def test_code39_empty_input_produces_checksum_zero_character() -> None: + # The checksum of an empty string is 0, whose character is "0". + assert Code39("").code == "0" + + +def test_code39_illegal_character_without_checksum() -> None: + with pytest.raises( + IllegalCharacterError, match="not valid for Code 39: @" + ): + Code39("A@B", add_checksum=False) + + +def test_code39_illegal_character_with_checksum_raises_key_error() -> None: + # With the default add_checksum=True, the checksum is calculated + # before the character validation runs, so an illegal character + # surfaces as a KeyError from the charset map instead of an + # IllegalCharacterError. This pins the current behaviour. + with pytest.raises(KeyError): + Code39("A@B") + with pytest.raises(KeyError): + Code39("a_b") + + +def test_code39_calculate_checksum_resums_the_full_code() -> None: + # calculate_checksum() sums self.code as-is. After construction the + # code already includes the checksum character, so calling it again + # yields a different value ("N" for "ABCX", whose real checksum + # character is "X"). This pins the current behaviour. + assert Code39("ABC").calculate_checksum() == "N" + + +def test_code39_build_for_a_single_character() -> None: + assert Code39("A", add_checksum=False).build() == [ + "10001011101110101110101000101110100010111011101" + ] + + +@pytest.mark.parametrize("char", list(code39_charset.REF)) +def test_code39_builds_every_legal_character(char: str) -> None: + # Each symbol is framed by the start/stop pattern (EDGE) and joined + # with the single-module separator (MIDDLE). + expected = code39_charset.MIDDLE.join( + [code39_charset.EDGE, code39_charset.MAP[char][1], code39_charset.EDGE] + ) + assert Code39(char, add_checksum=False).build() == [expected] + + +def test_code39_name() -> None: + assert Code39.name == "Code 39" + + +def test_pzn_too_short_raises_number_of_digits() -> None: + with pytest.raises(NumberOfDigitsError, match="PZN must have 6 digits, not 5"): + PZN("12345") + + +def test_pzn_non_digit_raises_illegal_character() -> None: + with pytest.raises(IllegalCharacterError, match="PZN can only contain numbers"): + PZN("12345a") + + +def test_pzn_input_longer_than_six_digits_is_trimmed() -> None: + assert PZN("123456789").get_fullcode() == "PZN-1234562" + + +def test_pzn_checksum_of_ten_is_rejected() -> None: + # 000003 has weighted sum 3 * 7 = 21, and 21 % 11 == 10, which is + # not a representable PZN check digit. + with pytest.raises(BarcodeError, match="Checksum can not be 10 for PZN"): + PZN("000003") + + +def test_pzn8_uses_seven_digits() -> None: + assert PZN8("1234567").get_fullcode() == "PZN-12345673" + with pytest.raises(NumberOfDigitsError, match="PZN must have 7 digits, not 6"): + PZN8("123456") diff --git a/tests/test_ean_family.py b/tests/test_ean_family.py new file mode 100644 index 0000000..48ca2dc --- /dev/null +++ b/tests/test_ean_family.py @@ -0,0 +1,191 @@ +"""Tests for the EAN family (EAN-13, EAN-8, EAN-14, JAN). + +Focuses on input validation, trimming of overlong input, checksum +calculation for a range of values, the guard-bar variants and JAN +country-code boundaries. The checksum/no_checksum interplay for +EAN-13 itself is already covered by ``test_ean.py``. +""" + +from __future__ import annotations + +import pytest + +from barcode.ean import EAN8 +from barcode.ean import EAN8_GUARD +from barcode.ean import EAN13 +from barcode.ean import EAN13_GUARD +from barcode.ean import EAN14 +from barcode.ean import JAN +from barcode.errors import IllegalCharacterError +from barcode.errors import NumberOfDigitsError +from barcode.errors import WrongCountryCodeError + + +def test_ean13_trims_input_longer_than_twelve_digits() -> None: + # Only the first 12 digits are used; the checksum is then (re)calculated. + assert EAN13("8421671433229999").ean == "8421671433225" + + +def test_ean13_too_short_raises_number_of_digits() -> None: + with pytest.raises( + NumberOfDigitsError, match="EAN must have 12 digits, received 3" + ): + EAN13("123") + + +def test_ean13_non_digit_raises_illegal_character() -> None: + with pytest.raises(IllegalCharacterError, match="EAN code can only contain numbers"): + EAN13("84216714332a") + + +def test_ean13_empty_input_raises_illegal_character() -> None: + with pytest.raises(IllegalCharacterError): + EAN13("") + + +def test_ean13_checksum_of_explicit_value() -> None: + ean = EAN13("842167143322") + assert ean.calculate_checksum("123456789012") == 8 + assert ean.calculate_checksum("000000000000") == 0 + + +def test_ean13_build_is_95_modules() -> None: + code = EAN13("842167143322").build() + assert code == [ + "1010100011001101100110010000101001000100110010101010111" + "0010000101000010110110011011001001110101" + ] + assert len(code[0]) == 95 + + +def test_ean13_guard_variant_fullcode_and_pattern() -> None: + ean = EAN13_GUARD("842167143322") + assert ean.get_fullcode() == "8 421671 433225 >" + assert "G" in ean.build()[0] + + +def test_ean13_to_ascii_uses_bars_and_spaces() -> None: + ascii_code = EAN13("842167143322").to_ascii() + assert ascii_code == ( + "| | | || || || || | | | | | || | | | | ||| | " + "| | | || || || || | ||| | |" + ) + assert set(ascii_code) <= {"|", " "} + + +def test_ean13_repr_and_attributes() -> None: + ean = EAN13("842167143322") + assert repr(ean) == "" + assert EAN13.name == "EAN-13" + assert EAN13.digits == 12 + + +@pytest.mark.parametrize( + ("given", "full", "checksum"), + [ + ("1234567", "12345670", 0), + ("0000000", "00000000", 0), + ("9999999", "99999995", 5), + ("4026770", "40267708", 8), + ], +) +def test_ean8_checksums(given: str, full: str, checksum: int) -> None: + ean = EAN8(given) + assert ean.ean == full + assert ean.calculate_checksum() == checksum + + +def test_ean8_trims_input_longer_than_seven_digits() -> None: + assert EAN8("12345678901").ean == "12345670" + + +def test_ean8_too_short_raises_number_of_digits() -> None: + with pytest.raises(NumberOfDigitsError, match="EAN must have 7 digits, received 3"): + EAN8("123") + + +def test_ean8_non_digit_raises_illegal_character() -> None: + with pytest.raises(IllegalCharacterError, match="EAN code can only contain numbers"): + EAN8("123456a") + + +def test_ean8_build_is_67_modules() -> None: + assert len(EAN8("6032299").build()[0]) == 67 + assert EAN8.name == "EAN-8" + assert EAN8.digits == 7 + + +def test_ean8_guard_variant_fullcode() -> None: + assert EAN8_GUARD("6032299").get_fullcode() == "< 6032 2999 >" + assert EAN8("6032299").get_fullcode() == "60322999" + + +@pytest.mark.parametrize( + ("given", "full", "checksum"), + [ + ("1234567890123", "12345678901231", 1), + ("0000000000000", "00000000000000", 0), + ("0590123412345", "05901234123457", 7), + ("9999999999999", "99999999999997", 7), + ], +) +def test_ean14_checksums(given: str, full: str, checksum: int) -> None: + ean = EAN14(given) + assert ean.ean == full + assert ean.calculate_checksum() == checksum + + +def test_ean14_too_short_raises_number_of_digits() -> None: + with pytest.raises( + NumberOfDigitsError, match="EAN must have 13 digits, received 3" + ): + EAN14("123") + + +def test_ean14_non_digit_raises_illegal_character() -> None: + with pytest.raises(IllegalCharacterError, match="EAN code can only contain numbers"): + EAN14("123456789012a") + + +def test_ean14_build_is_102_modules() -> None: + assert len(EAN14("1234567890123").build()[0]) == 102 + assert EAN14.name == "EAN-14" + assert EAN14.digits == 13 + + +@pytest.mark.parametrize( + ("given", "full"), + [ + ("450123456789", "4501234567896"), + ("459123456789", "4591234567897"), + ("490123456789", "4901234567894"), + ("499123456789", "4991234567895"), + ], +) +def test_jan_accepts_country_codes_at_the_boundaries(given: str, full: str) -> None: + assert JAN(given).get_fullcode() == full + + +@pytest.mark.parametrize( + "given", + ["460123456789", "489123456789", "500123456789", "123456789012"], +) +def test_jan_rejects_country_codes_outside_the_ranges(given: str) -> None: + with pytest.raises( + WrongCountryCodeError, match="Country code isn't between 450-460 or 490-500" + ): + JAN(given) + + +def test_jan_country_code_is_checked_before_length() -> None: + # Even a two-digit input reports the country code, not the length. + with pytest.raises(WrongCountryCodeError): + JAN("49") + + +def test_jan_non_numeric_prefix_raises_value_error() -> None: + # JAN parses the first three characters with int() before validating, + # so a non-numeric prefix surfaces a plain ValueError (not a + # BarcodeError). This pins the current behaviour. + with pytest.raises(ValueError, match="invalid literal for int"): + JAN("49a123456789") diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..8fcf501 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,48 @@ +"""Tests for ``barcode.errors`` and the ``Barcode`` base class.""" + +from __future__ import annotations + +import pytest + +from barcode.base import Barcode +from barcode.ean import EAN13 +from barcode.errors import BarcodeError +from barcode.errors import BarcodeNotFoundError +from barcode.errors import IllegalCharacterError +from barcode.errors import NumberOfDigitsError +from barcode.errors import WrongCountryCodeError +from barcode.writer import SVGWriter + + +def test_barcode_error_stores_and_renders_its_message() -> None: + err = BarcodeError("boom") + assert err.msg == "boom" + assert str(err) == "boom" + + +@pytest.mark.parametrize( + "error_class", + [ + IllegalCharacterError, + BarcodeNotFoundError, + NumberOfDigitsError, + WrongCountryCodeError, + ], +) +def test_specific_errors_are_barcode_errors(error_class: type[BarcodeError]) -> None: + assert issubclass(error_class, BarcodeError) + assert issubclass(error_class, Exception) + assert str(error_class("details")) == "details" + + +def test_base_barcode_cannot_be_instantiated() -> None: + with pytest.raises(NotImplementedError): + Barcode("123") + + +def test_default_writer_is_the_svg_writer_class() -> None: + assert Barcode.default_writer is SVGWriter + + +def test_instances_get_an_svg_writer_by_default() -> None: + assert isinstance(EAN13("842167143322").writer, SVGWriter) diff --git a/tests/test_isxn_validation.py b/tests/test_isxn_validation.py new file mode 100644 index 0000000..5995a82 --- /dev/null +++ b/tests/test_isxn_validation.py @@ -0,0 +1,118 @@ +"""Tests for ISBN-13 / ISBN-10 / ISSN validation (``barcode.isxn``). + +``test_checksums.py`` pins one checksum example per type; these tests +cover the surrounding contract: hyphen stripping, the ISBN-13 prefix +rules (978 / 979), ISBN-10's "supplied check digit is recomputed" +behaviour including ``X`` check digits, and ISSN conversion to EAN-13. +""" + +from __future__ import annotations + +import pytest + +from barcode.errors import BarcodeError +from barcode.errors import NumberOfDigitsError +from barcode.errors import WrongCountryCodeError +from barcode.isxn import ISBN10 +from barcode.isxn import ISBN13 +from barcode.isxn import ISSN + + +def test_isbn13_strips_hyphens() -> None: + isbn = ISBN13("978-3-16-148410-0") + assert isbn.isbn13 == "9783161484100" + assert isbn.get_fullcode() == "9783161484100" + + +def test_isbn13_rejects_non_bookland_prefix() -> None: + with pytest.raises( + WrongCountryCodeError, match="ISBN must start with 978 or 979" + ): + ISBN13("977123456789") + + +@pytest.mark.parametrize("given", ["979012345678", "979212345678"]) +def test_isbn13_979_with_wrong_fourth_digit_raises_plain_barcode_error( + given: str, +) -> None: + with pytest.raises(BarcodeError, match="ISBN must start with 97910 or 97911") as e: + ISBN13(given) + # The base BarcodeError is raised, not one of its subclasses. + assert type(e.value) is BarcodeError + + +def test_isbn13_979_prefixes() -> None: + assert ISBN13("979123456789").get_fullcode() == "9791234567896" + # The check actually accepts a fourth digit of 1 or 8, even though + # the error message only mentions 97910 and 97911. + assert ISBN13("979812345678").get_fullcode() == "9798123456781" + + +def test_isbn13_too_short_raises_number_of_digits() -> None: + with pytest.raises(NumberOfDigitsError): + ISBN13("978123") + + +def test_isbn10_documented_example() -> None: + isbn = ISBN10("0132354187") + assert isbn.isbn10 == "0132354187" + assert str(isbn) == "0132354187" + assert isbn.get_fullcode() == "9780132354189" + + +def test_isbn10_supplied_check_digit_is_ignored_and_recomputed() -> None: + # Only the first nine digits are read; the check digit is always + # recalculated, so a wrong (or placeholder) last character is fixed. + assert ISBN10("0132354180").isbn10 == "0132354187" + assert ISBN10("013235418X").isbn10 == "0132354187" + + +def test_isbn10_strips_hyphens() -> None: + assert ISBN10("3-12-517154-7").isbn10 == "3125171547" + + +@pytest.mark.parametrize( + ("given", "full"), + [ + ("013235411", "013235411X"), + ("123456789", "123456789X"), + ("000000006", "000000006X"), + ("999999993", "999999993X"), + ], +) +def test_isbn10_x_check_digit(given: str, full: str) -> None: + # When the weighted sum mod 11 is 10, the check digit is "X". + assert ISBN10(given).isbn10 == full + + +def test_issn_checksum_x_and_conversion_to_ean() -> None: + issn = ISSN("1144875") + assert issn.issn == "1144875X" + assert str(issn) == "1144875X" + assert issn.get_fullcode() == "9771144875007" + + +def test_issn_make_ean_keeps_the_x_placeholder() -> None: + # make_ean() interpolates the ISSN check digit verbatim (here "X"); + # the EAN-13 layer later replaces it with its own numeric checksum. + assert ISSN("1144875").make_ean() == "977114487500X" + + +def test_issn_hyphens_and_supplied_check_digit_are_ignored() -> None: + assert ISSN("1144-875").issn == "1144875X" + assert ISSN("1144875X").issn == "1144875X" + + +def test_issn_numeric_checksum() -> None: + issn = ISSN("2049363") + assert issn.issn == "20493630" + assert issn.get_fullcode() == "9772049363002" + + +def test_issn_short_input_is_not_validated() -> None: + # ISSN performs no length validation: a five-digit input is padded + # into a (meaningless) EAN-13 instead of raising. This pins the + # current behaviour; see the PR description. + issn = ISSN("12345") + assert issn.issn == "123455" + assert issn.get_fullcode() == "9771234550029" diff --git a/tests/test_itf.py b/tests/test_itf.py new file mode 100644 index 0000000..2d7f43d --- /dev/null +++ b/tests/test_itf.py @@ -0,0 +1,50 @@ +"""Tests for ITF (Interleaved 2 of 5, ``barcode.itf``). + +Covers the odd-length zero padding, the interleaved bar/space +pattern produced by ``build()``, the narrow/wide scaling, and input +validation. Previously ITF only appeared in ``test_manually.py``. +""" + +from __future__ import annotations + +import pytest + +from barcode.errors import IllegalCharacterError +from barcode.itf import ITF + + +def test_itf_even_length_is_kept_as_is() -> None: + assert ITF("42").code == "42" + assert str(ITF("42")) == "42" + assert ITF("42").get_fullcode() == "42" + + +def test_itf_odd_length_is_padded_with_a_leading_zero() -> None: + # Interleaved 2 of 5 encodes digit pairs, so odd input is padded. + assert ITF("123").code == "0123" + assert ITF("123").get_fullcode() == "0123" + + +def test_itf_build_pattern_for_a_digit_pair() -> None: + assert ITF("12").build() == ["1100110011111001100000110011001111100000111110011"] + assert ITF("00").build() == ["1100110011001100111110000011111000001100111110011"] + + +def test_itf_build_length_scales_with_pairs() -> None: + assert len(ITF("12").build()[0]) == 49 + assert len(ITF("12345678").build()[0]) == 145 + + +def test_itf_narrow_and_wide_widths_scale_the_pattern() -> None: + itf = ITF("12", narrow=1, wide=2) + assert itf.build() == ["1010110100101011001101"] + default = ITF("12") + assert (default.narrow, default.wide) == (2, 5) + + +@pytest.mark.parametrize("given", ["12a", "", "12 34", "12-34"]) +def test_itf_non_digits_raise_illegal_character(given: str) -> None: + with pytest.raises( + IllegalCharacterError, match="ITF code can only contain numbers" + ): + ITF(given) diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..315ca49 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,133 @@ +"""Tests for the barcode registry in ``barcode/__init__.py``. + +Covers ``PROVIDED_BARCODES``, name lookup (including aliases and +case-insensitivity), and the ``BarcodeNotFoundError`` raised for +unknown names. Existing tests only exercise ``generate()`` and use +``get_barcode`` incidentally; the lookup contract itself is pinned here. +""" + +from __future__ import annotations + +import pytest + +import barcode +from barcode.base import Barcode +from barcode.codabar import CODABAR +from barcode.codex import PZN +from barcode.codex import Code128 +from barcode.codex import Code39 +from barcode.codex import Gs1_128 +from barcode.ean import EAN8 +from barcode.ean import EAN8_GUARD +from barcode.ean import EAN13 +from barcode.ean import EAN13_GUARD +from barcode.ean import EAN14 +from barcode.ean import JAN +from barcode.errors import BarcodeNotFoundError +from barcode.isxn import ISBN10 +from barcode.isxn import ISBN13 +from barcode.isxn import ISSN +from barcode.itf import ITF +from barcode.upc import UPCA + +EXPECTED_CLASSES = { + "codabar": CODABAR, + "code128": Code128, + "code39": Code39, + "ean": EAN13, + "ean13": EAN13, + "ean13-guard": EAN13_GUARD, + "ean14": EAN14, + "ean8": EAN8, + "ean8-guard": EAN8_GUARD, + "gs1": ISBN13, + "gs1_128": Gs1_128, + "gtin": EAN14, + "isbn": ISBN13, + "isbn10": ISBN10, + "isbn13": ISBN13, + "issn": ISSN, + "itf": ITF, + "jan": JAN, + "nw-7": CODABAR, + "pzn": PZN, + "upc": UPCA, + "upca": UPCA, +} + + +def test_provided_barcodes_is_the_sorted_name_list() -> None: + assert barcode.PROVIDED_BARCODES == sorted(EXPECTED_CLASSES) + assert len(barcode.PROVIDED_BARCODES) == 22 + + +@pytest.mark.parametrize("name", sorted(EXPECTED_CLASSES)) +def test_every_provided_name_resolves_to_its_class(name: str) -> None: + cls = barcode.get_barcode_class(name) + assert cls is EXPECTED_CLASSES[name] + assert isinstance(cls, type) + assert issubclass(cls, Barcode) + + +@pytest.mark.parametrize( + ("alias", "canonical"), + [ + ("ean", "ean13"), + ("gtin", "ean14"), + ("isbn", "isbn13"), + ("gs1", "isbn13"), + ("nw-7", "codabar"), + ("upc", "upca"), + ], +) +def test_aliases_resolve_to_the_same_class(alias: str, canonical: str) -> None: + assert barcode.get_barcode_class(alias) is barcode.get_barcode_class(canonical) + + +@pytest.mark.parametrize("name", ["EAN13", "Ean8", "UPCA", "CODE128", "Gs1_128"]) +def test_lookup_is_case_insensitive(name: str) -> None: + assert barcode.get_barcode_class(name) is EXPECTED_CLASSES[name.lower()] + + +@pytest.mark.parametrize("name", ["unknown", "", "ean13 ", "code-39", "upce"]) +def test_unknown_name_raises_barcode_not_found(name: str) -> None: + with pytest.raises(BarcodeNotFoundError, match="is not known"): + barcode.get_barcode_class(name) + + +def test_unknown_name_error_message_names_the_barcode() -> None: + with pytest.raises(BarcodeNotFoundError) as excinfo: + barcode.get_barcode("nope") + assert str(excinfo.value) == "The barcode 'nope' is not known." + assert excinfo.value.msg == "The barcode 'nope' is not known." + + +def test_unknown_name_raises_even_when_a_code_is_given() -> None: + with pytest.raises(BarcodeNotFoundError): + barcode.get_barcode("unknown", "123") + + +def test_get_without_code_returns_the_class() -> None: + assert barcode.get_barcode("ean13") is EAN13 + assert barcode.get_barcode("ean13", None) is EAN13 + + +def test_get_with_code_returns_an_instance() -> None: + result = barcode.get_barcode("ean13", "842167143322") + assert isinstance(result, EAN13) + assert isinstance(result, Barcode) + + +def test_get_passes_options_through_to_the_constructor() -> None: + ean8 = barcode.get_barcode("ean8", "6032299", options={"guardbar": True}) + assert isinstance(ean8, EAN8) + assert ean8.get_fullcode() == "< 6032 2999 >" + + +def test_get_class_is_an_alias_of_get_barcode_class() -> None: + assert barcode.get_class is barcode.get_barcode_class + assert barcode.get_class("upc") is UPCA + + +def test_get_barcode_is_the_get_function() -> None: + assert barcode.get_barcode is barcode.get diff --git a/tests/test_upc.py b/tests/test_upc.py new file mode 100644 index 0000000..531cca9 --- /dev/null +++ b/tests/test_upc.py @@ -0,0 +1,84 @@ +"""Tests for UPC-A (``barcode.upc``). + +UPC-A previously had no dedicated tests: only a single rendering in +``test_manually.py``. These pin checksum calculation, input trimming, +validation errors, and the ``make_ean`` conversion. +""" + +from __future__ import annotations + +import pytest + +from barcode.errors import IllegalCharacterError +from barcode.errors import NumberOfDigitsError +from barcode.upc import UPCA + + +@pytest.mark.parametrize( + ("given", "full", "checksum"), + [ + ("03600029145", "036000291452", 2), + ("01234567890", "012345678905", 5), + ("99999999999", "999999999993", 3), + ("00000000000", "000000000000", 0), + ("12345678901", "123456789012", 2), + ], +) +def test_upca_checksums(given: str, full: str, checksum: int) -> None: + upc = UPCA(given) + assert upc.get_fullcode() == full + assert upc.calculate_checksum() == checksum + assert str(upc) == full + + +def test_upca_twelve_digit_input_is_trimmed_and_recomputed() -> None: + # A 12th digit is dropped and the checksum recalculated from the + # first eleven digits, so even a *wrong* supplied digit is ignored. + assert UPCA("036000291452").get_fullcode() == "036000291452" + assert UPCA("036000291459").get_fullcode() == "036000291452" + + +def test_upca_too_short_raises_number_of_digits() -> None: + with pytest.raises(NumberOfDigitsError, match="UPC must have 11 digits, not 3"): + UPCA("123") + + +def test_upca_non_digit_raises_illegal_character() -> None: + with pytest.raises( + IllegalCharacterError, match="UPC code can only contain numbers" + ): + UPCA("0360002914a") + + +def test_upca_make_ean_prefixes_a_zero() -> None: + upc = UPCA("03600029145", make_ean=True) + assert str(upc) == "0036000291452" + assert upc.get_fullcode() == "0036000291452" + # The stored code itself keeps its 12 digits (no leading zero). + assert upc.upc == "036000291452" + + +def test_upca_without_make_ean_has_no_leading_zero() -> None: + upc = UPCA("03600029145") + assert str(upc) == "036000291452" + assert upc.get_fullcode() == "036000291452" + + +def test_upca_build_is_95_modules() -> None: + code = UPCA("03600029145").build() + assert code == [ + "1010001101011110101011110001101000110100011010101011011" + "0011101001100110101110010011101101100101" + ] + assert len(code[0]) == 95 + + +def test_upca_to_ascii_uses_bars_and_underscores() -> None: + ascii_code = UPCA("03600029145").to_ascii() + assert ascii_code.startswith("|_|___||_|_||||_|_|_") + assert set(ascii_code) <= {"|", "_"} + + +def test_upca_attributes() -> None: + assert UPCA.name == "UPC-A" + assert UPCA.digits == 11