From 211883393ec0ef86d356ceee8b19c58899605d2b Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 15 Feb 2026 01:38:28 +0000 Subject: [PATCH] feat(console): add formatting utils and VIN API client Co-Authored-By: Claude Opus 4.6 --- console/tests/test_utils.py | 168 ++++++++++++++++++++++++++++++++++++ console/utils/formatting.py | 86 ++++++++++++++++++ console/utils/vin_api.py | 93 ++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 console/tests/test_utils.py create mode 100644 console/utils/formatting.py create mode 100644 console/utils/vin_api.py diff --git a/console/tests/test_utils.py b/console/tests/test_utils.py new file mode 100644 index 0000000..9df8c6f --- /dev/null +++ b/console/tests/test_utils.py @@ -0,0 +1,168 @@ +""" +Tests for the formatting utility functions. + +VIN API tests are excluded because they require network access. +""" + +import pytest + +from console.utils.formatting import ( + format_currency, + format_number, + truncate, + pad_right, + format_table_row, + quality_bar, +) + + +# ========================================================================= +# format_currency +# ========================================================================= + +class TestFormatCurrency: + def test_none_returns_dash(self): + assert format_currency(None) == "──" + + def test_zero_returns_zero_dollars(self): + assert format_currency(0) == "$0.00" + + def test_positive_value(self): + assert format_currency(45.99) == "$45.99" + + def test_integer_value(self): + assert format_currency(100) == "$100.00" + + def test_large_value_with_commas(self): + assert format_currency(1234.56) == "$1,234.56" + + def test_small_decimal(self): + assert format_currency(0.5) == "$0.50" + + +# ========================================================================= +# format_number +# ========================================================================= + +class TestFormatNumber: + def test_none_returns_zero(self): + assert format_number(None) == "0" + + def test_zero(self): + assert format_number(0) == "0" + + def test_thousands_separator(self): + assert format_number(13685) == "13,685" + + def test_small_number(self): + assert format_number(42) == "42" + + def test_million(self): + assert format_number(1000000) == "1,000,000" + + +# ========================================================================= +# truncate +# ========================================================================= + +class TestTruncate: + def test_none_returns_empty(self): + assert truncate(None, 10) == "" + + def test_short_string_unchanged(self): + assert truncate("hello", 10) == "hello" + + def test_exact_length_unchanged(self): + assert truncate("hello", 5) == "hello" + + def test_long_string_truncated_with_ellipsis(self): + assert truncate("hello world!", 8) == "hello..." + + def test_very_short_max_len(self): + result = truncate("hello world", 3) + assert result == "..." + + +# ========================================================================= +# pad_right +# ========================================================================= + +class TestPadRight: + def test_none_returns_empty(self): + assert pad_right(None, 10) == "" + + def test_short_string_padded(self): + result = pad_right("hi", 5) + assert result == "hi " + assert len(result) == 5 + + def test_exact_length_unchanged(self): + result = pad_right("hello", 5) + assert result == "hello" + + def test_long_string_truncated(self): + result = pad_right("hello world", 5) + assert result == "hello" + assert len(result) == 5 + + +# ========================================================================= +# format_table_row +# ========================================================================= + +class TestFormatTableRow: + def test_basic_row(self): + result = format_table_row(["A", "B", "C"], [5, 5, 5]) + assert " │ " in result + assert len(result.split(" │ ")) == 3 + + def test_values_padded_to_widths(self): + result = format_table_row(["hi", "there"], [5, 7]) + parts = result.split(" │ ") + assert len(parts[0]) == 5 + assert len(parts[1]) == 7 + + def test_custom_separator(self): + result = format_table_row(["A", "B"], [3, 3], separator=" | ") + assert " | " in result + + def test_truncation_when_value_exceeds_width(self): + result = format_table_row(["toolongvalue", "ok"], [5, 5]) + parts = result.split(" │ ") + assert len(parts[0]) == 5 + + +# ========================================================================= +# quality_bar +# ========================================================================= + +class TestQualityBar: + def test_oem(self): + result = quality_bar("oem") + assert "█" in result + assert len(result) > 0 + + def test_premium(self): + result = quality_bar("premium") + assert "█" in result + + def test_standard(self): + result = quality_bar("standard") + assert "█" in result + assert "░" in result + + def test_economy(self): + result = quality_bar("economy") + assert "█" in result + assert "░" in result + + def test_oem_longer_than_economy(self): + oem = quality_bar("oem") + economy = quality_bar("economy") + oem_blocks = oem.count("█") + economy_blocks = economy.count("█") + assert oem_blocks > economy_blocks + + def test_unknown_tier_returns_string(self): + result = quality_bar("unknown") + assert isinstance(result, str) diff --git a/console/utils/formatting.py b/console/utils/formatting.py new file mode 100644 index 0000000..b14bdb4 --- /dev/null +++ b/console/utils/formatting.py @@ -0,0 +1,86 @@ +""" +Display formatting utilities for the AUTOPARTES console application. + +Functions for currency, numbers, text truncation, table layout, and +quality-tier visual bars. +""" + + +def format_currency(value) -> str: + """Format a numeric value as USD currency. + + None -> '──' + 0 -> '$0.00' + 45.99 -> '$45.99' + """ + if value is None: + return "──" + return f"${value:,.2f}" + + +def format_number(value) -> str: + """Format an integer with thousands separators. + + None -> '0' + 13685 -> '13,685' + """ + if value is None: + return "0" + return f"{value:,}" + + +def truncate(text, max_len) -> str: + """Truncate text to *max_len* characters, appending '...' if trimmed. + + None -> '' + fits -> text unchanged + too long -> text[:max_len-3] + '...' + """ + if text is None: + return "" + if len(text) <= max_len: + return text + return text[: max_len - 3] + "..." + + +def pad_right(text, width) -> str: + """Pad *text* to *width* with spaces on the right, or truncate if longer. + + None -> '' + fits -> ljust(width) + too long -> text[:width] + """ + if text is None: + return "" + if len(text) > width: + return text[:width] + return text.ljust(width) + + +def format_table_row(values, widths, separator=" │ ") -> str: + """Join *values* padded to corresponding *widths* with *separator*. + + Each value is passed through :func:`pad_right` to ensure uniform column + widths, then all columns are joined by the separator string. + """ + cells = [pad_right(str(v), w) for v, w in zip(values, widths)] + return separator.join(cells) + + +# ── Quality-tier bars ────────────────────────────────────────────────── + +_QUALITY_BARS = { + "oem": "███████████", + "premium": "██████████░", + "standard": "███████░░░░", + "economy": "█████░░░░░░", +} + + +def quality_bar(tier) -> str: + """Return a Unicode block-bar representing a quality tier. + + Recognised tiers: oem, premium, standard, economy. + Unknown tiers fall back to a minimal bar. + """ + return _QUALITY_BARS.get(tier, "░░░░░░░░░░░") diff --git a/console/utils/vin_api.py b/console/utils/vin_api.py new file mode 100644 index 0000000..afefb8e --- /dev/null +++ b/console/utils/vin_api.py @@ -0,0 +1,93 @@ +""" +NHTSA VIN Decoder API client for the AUTOPARTES console application. + +Wraps the National Highway Traffic Safety Administration (NHTSA) Vehicle +Product Information Catalog (vPIC) DecodeVin endpoint to retrieve vehicle +specifications from a 17-character VIN. +""" + +import requests + +from console.config import NHTSA_API_URL + + +# NHTSA result variables we care about, mapped to our internal keys. +_FIELD_MAP = { + "Make": "make", + "Model": "model", + "Model Year": "year", + "Body Class": "body_class", + "Drive Type": "drive_type", + "Displacement (L)": "displacement_l", + "Engine Number of Cylinders": "cylinders", + "Fuel Type - Primary": "fuel_type", + "Engine Brake (hp) From": "power_hp", +} + + +def decode_vin_nhtsa(vin: str) -> dict: + """Decode a VIN using the NHTSA vPIC API. + + Parameters + ---------- + vin : str + A 17-character Vehicle Identification Number. + + Returns + ------- + dict + On success:: + + { + "make": "TOYOTA", + "model": "Corolla", + "year": "2020", + "body_class": "Sedan/Saloon", + "drive_type": "FWD", + "engine_info": { + "displacement_l": "2.0", + "cylinders": "4", + "fuel_type": "Gasoline", + "power_hp": "169", + "raw": { ... full variable->value mapping ... }, + }, + } + + On error:: + + {"error": ""} + """ + try: + url = f"{NHTSA_API_URL}/{vin}" + response = requests.get(url, params={"format": "json"}, timeout=15) + response.raise_for_status() + + data = response.json() + results = data.get("Results", []) + + # Build a flat lookup: variable name -> value (skip empty/None) + raw: dict[str, str] = {} + for item in results: + var = item.get("Variable", "") + val = item.get("Value") + if val and str(val).strip(): + raw[var] = str(val).strip() + + # Extract top-level vehicle fields + vehicle: dict = {} + engine_info: dict = {"raw": raw} + + engine_keys = {"displacement_l", "cylinders", "fuel_type", "power_hp"} + + for nhtsa_var, our_key in _FIELD_MAP.items(): + value = raw.get(nhtsa_var, "") + if our_key in engine_keys: + engine_info[our_key] = value + else: + vehicle[our_key] = value + + vehicle["engine_info"] = engine_info + return vehicle + + except Exception as e: + return {"error": str(e)}