# -*- coding: utf-8 -*- """`panellift.units` -- the feet-inches formatter/parser every 前端 and 后端 in this repo now goes through (owner's ruling, 2026-09-05: every displayed length is feet-inches, rounded to the nearest 1/8", never a decimal). This module is written in the intersection of Python 2 and 3 syntax on purpose (see its own docstring) because the pyRevit/IronPython side is meant to import this exact file later; nothing here exercises anything Python-3 -only, so a green run here says nothing about IronPython compatibility that reading the source doesn't already say, but it is still the same file that will make that trip. """ import random import pytest from panellift.units import fmt_ft_in, fmt_in, parse_length, parse_length_in # ------------------------------------------------------------- fmt_ft_in def test_canonical_form(): assert fmt_ft_in(8 + 4.5 / 12.0) == '8\'-4 1/2"' def test_zero_feet_still_prints_the_foot_mark(): # 6 3/8 in = 0.53125 ft assert fmt_ft_in(0.53125) == '0\'-6 3/8"' def test_negative_keeps_the_sign_out_front_not_inside(): assert fmt_ft_in(-0.5) == '-0\'-6"' def test_none_is_a_dash(): assert fmt_ft_in(None) == '-' def test_a_whole_number_of_feet_has_no_fraction_cell(): assert fmt_ft_in(5.0) == '5\'-0"' def test_twelve_inches_carries_into_the_foot_count(): # 7 ft + 11.96 in rounds to 8 in at 1/8" precision, but 7 ft + 11.98 in # rounds UP to a full 12 in, which must carry to the next foot. almost_eight_feet = 7.0 + 11.98 / 12.0 assert fmt_ft_in(almost_eight_feet) == '8\'-0"' def test_rounds_to_the_nearest_eighth_by_default(): # 4 5/16 in -> nearest 1/8" is 4 1/4 or 4 3/8; 5/16 = 0.3125, # which is exactly between 1/4 (0.25) and 3/8 (0.375) at 1/8" steps of # 0.125 -- round(0.3125 / 0.125) = round(2.5) = 2 (round-half-to-even), # i.e. 2/8 = 1/4". four_and_5_16 = 4.3125 / 12.0 assert fmt_ft_in(four_and_5_16) == '0\'-4 1/4"' def test_a_finer_precision_is_honoured(): # 1/16" precision: 6 1/16 in. six_and_1_16 = (6.0 + 1.0 / 16.0) / 12.0 assert fmt_ft_in(six_and_1_16, precision=16) == '0\'-6 1/16"' # at the default 1/8" precision the same value rounds down to 6". assert fmt_ft_in(six_and_1_16) == '0\'-6"' # ----------------------------------------------------------------- fmt_in def test_fmt_in_basic(): assert fmt_in(6.375) == '6 3/8"' def test_fmt_in_whole_number(): assert fmt_in(12.0) == '12"' def test_fmt_in_none_is_a_dash(): assert fmt_in(None) == '-' def test_fmt_in_carries_a_full_inch(): # 5.98 in at 1/8" precision rounds up to a full 6 in, not "5 8/8". assert fmt_in(5.98) == '6"' def test_fmt_in_negative(): assert fmt_in(-0.5) == '-0 1/2"' # ------------------------------------------------------- rounding boundary @pytest.mark.parametrize('eighths, expected', [ (0, '0"'), (1, '0 1/8"'), (2, '0 1/4"'), (3, '0 3/8"'), (4, '0 1/2"'), (5, '0 5/8"'), (6, '0 3/4"'), (7, '0 7/8"'), ]) def test_every_eighth_prints_its_own_reduced_fraction(eighths, expected): assert fmt_in(eighths / 8.0) == expected def test_exactly_half_a_sixteenth_rounds_to_even_at_default_precision(): """1/16" (0.0625 in) sits exactly halfway between 0 and 1/8" at the default precision -- Python's own `round()` (banker's rounding) sends an exact .5 to the nearest EVEN integer, so 0.5 eighths rounds to 0, not 1. This test pins that behaviour so a future change to the rounding rule shows up here rather than as a silently different printed fraction. """ assert fmt_in(1.0 / 16.0) == '0"' # 3/16" -> 1.5 eighths -> rounds to 2 (even) -> 1/4" assert fmt_in(3.0 / 16.0) == '0 1/4"' # --------------------------------------------------------------- parsing @pytest.mark.parametrize('text, expected_ft', [ ('8\'-4 1/2"', 8 + 4.5 / 12.0), ('8\'4"', 8 + 4.0 / 12.0), ("8' 4-1/2\"", 8 + 4.5 / 12.0), ('4 1/2"', 4.5 / 12.0), ('100"', 100.0 / 12.0), ("-0'-6\"", -0.5), ('8\'', 8.0), ]) def test_parse_length_accepts_every_named_form(text, expected_ft): assert parse_length(text) == pytest.approx(expected_ft, abs=1e-9) def test_a_bare_number_is_feet_by_default(): assert parse_length('5') == pytest.approx(5.0) def test_a_bare_number_can_be_read_as_inches(): assert parse_length('6', default_unit='in') == pytest.approx(0.5) def test_parse_length_in_reads_a_bare_number_as_inches_by_default(): assert parse_length_in('6') == pytest.approx(6.0) def test_parse_length_in_can_read_a_bare_number_as_feet(): assert parse_length_in('1', default_unit='ft') == pytest.approx(12.0) def test_parse_length_in_on_a_feet_inches_string(): assert parse_length_in('6 3/8"') == pytest.approx(6.375) @pytest.mark.parametrize('bad', [ 'garbage', '', ' ', "8'garbage", '1/0"', None, ]) def test_unparseable_text_raises_value_error_naming_the_text(bad): with pytest.raises(ValueError): parse_length(bad) def test_the_error_names_the_offending_text(): with pytest.raises(ValueError) as exc: parse_length('nonsense') assert 'nonsense' in str(exc.value) # ---------------------------------------------------------- negative forms def test_negative_feet_and_inches_together(): assert parse_length("-8'-4 1/2\"") == pytest.approx(-(8 + 4.5 / 12.0)) def test_negative_bare_inches(): assert parse_length('-6"') == pytest.approx(-0.5) def test_negative_bare_number(): assert parse_length('-5') == pytest.approx(-5.0) # --------------------------------------------------------------- round-trip def test_round_trip_property_across_a_range(): """parse_length(fmt_ft_in(x)) == x within 1/16", the max error a value can pick up from rounding to the nearest 1/8" for display. """ rng = random.Random(20260905) max_err_ft = (1.0 / 16.0) / 12.0 for _ in range(5000): x = rng.uniform(-60.0, 60.0) rendered = fmt_ft_in(x) back = parse_length(rendered) assert abs(back - x) <= max_err_ft + 1e-12, (x, rendered, back) def test_round_trip_property_holds_at_zero_and_small_values(): for x in (0.0, 1e-9, -1e-9, 1.0 / 12.0, -1.0 / 12.0): back = parse_length(fmt_ft_in(x)) assert abs(back - x) <= (1.0 / 16.0) / 12.0 + 1e-9 def test_round_trip_property_for_fmt_in_and_parse_length_in(): rng = random.Random(20260905) max_err_in = 1.0 / 16.0 for _ in range(5000): x = rng.uniform(-200.0, 200.0) rendered = fmt_in(x) back = parse_length_in(rendered) assert abs(back - x) <= max_err_in + 1e-9, (x, rendered, back)