feat(console): add formatting utils and VIN API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
168
console/tests/test_utils.py
Normal file
168
console/tests/test_utils.py
Normal file
@@ -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)
|
||||||
86
console/utils/formatting.py
Normal file
86
console/utils/formatting.py
Normal file
@@ -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, "░░░░░░░░░░░")
|
||||||
93
console/utils/vin_api.py
Normal file
93
console/utils/vin_api.py
Normal file
@@ -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": "<description>"}
|
||||||
|
"""
|
||||||
|
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)}
|
||||||
Reference in New Issue
Block a user