Console README with usage instructions, keybindings reference, architecture overview, and test commands. Updated root README with console section, updated architecture diagram, and installation instructions. Includes approved design doc and implementation plan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1983 lines
63 KiB
Markdown
1983 lines
63 KiB
Markdown
# Pick-Style Console System - Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Build a keyboard-driven Pick/VT220-inspired console application for the autoparts catalog with two rendering modes (curses and textual).
|
|
|
|
**Architecture:** Three-layer architecture — DB layer (SQLite, swappable to PostgreSQL), business logic screens, and pluggable renderers (curses for VT220 aesthetic, textual for modern TUI). All screens define data/actions abstractly; renderers handle drawing.
|
|
|
|
**Tech Stack:** Python 3, curses (stdlib), textual/rich (pip), sqlite3 (stdlib), requests (for NHTSA VIN API)
|
|
|
|
---
|
|
|
|
## Task 1: Project Scaffold and Config
|
|
|
|
**Files:**
|
|
- Create: `console/__init__.py`
|
|
- Create: `console/main.py`
|
|
- Create: `console/config.py`
|
|
- Create: `console/core/__init__.py`
|
|
- Create: `console/screens/__init__.py`
|
|
- Create: `console/renderers/__init__.py`
|
|
- Create: `console/utils/__init__.py`
|
|
|
|
**Step 1: Create directory structure**
|
|
|
|
```bash
|
|
mkdir -p /home/Autopartes/console/{core,screens,renderers,utils}
|
|
touch /home/Autopartes/console/__init__.py
|
|
touch /home/Autopartes/console/core/__init__.py
|
|
touch /home/Autopartes/console/screens/__init__.py
|
|
touch /home/Autopartes/console/renderers/__init__.py
|
|
touch /home/Autopartes/console/utils/__init__.py
|
|
```
|
|
|
|
**Step 2: Write config.py**
|
|
|
|
```python
|
|
# console/config.py
|
|
import os
|
|
|
|
VERSION = "1.0.0"
|
|
APP_NAME = "AUTOPARTES"
|
|
APP_SUBTITLE = "Sistema de Catálogo de Autopartes"
|
|
|
|
# Database
|
|
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'vehicle_database', 'vehicle_database.db')
|
|
|
|
# VIN API
|
|
NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin"
|
|
VIN_CACHE_DAYS = 30
|
|
|
|
# Display
|
|
DEFAULT_MODE = "vt220" # "vt220" or "modern"
|
|
PAGE_SIZE = 15 # rows per page in lists/tables
|
|
|
|
# Color schemes for curses (name: (fg, bg))
|
|
COLORS_VT220 = {
|
|
'header': ('green', 'black'),
|
|
'footer': ('black', 'green'),
|
|
'normal': ('green', 'black'),
|
|
'highlight': ('black', 'green'),
|
|
'border': ('green', 'black'),
|
|
'title': ('white', 'black'),
|
|
'error': ('red', 'black'),
|
|
'info': ('cyan', 'black'),
|
|
'field_label': ('green', 'black'),
|
|
'field_value': ('white', 'black'),
|
|
'field_active': ('black', 'cyan'),
|
|
}
|
|
```
|
|
|
|
**Step 3: Write main.py entry point**
|
|
|
|
```python
|
|
# console/main.py
|
|
import argparse
|
|
import sys
|
|
from console.config import VERSION, DEFAULT_MODE
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description='Autopartes Console - Pick-Style Catalog System')
|
|
parser.add_argument('--mode', choices=['vt220', 'modern'], default=DEFAULT_MODE,
|
|
help='Rendering mode: vt220 (curses) or modern (textual)')
|
|
parser.add_argument('--version', action='version', version=f'Autopartes Console v{VERSION}')
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
if args.mode == 'vt220':
|
|
from console.renderers.curses_renderer import CursesRenderer
|
|
renderer = CursesRenderer()
|
|
else:
|
|
from console.renderers.textual_renderer import TextualRenderer
|
|
renderer = TextualRenderer()
|
|
|
|
from console.db import Database
|
|
from console.core.app import App
|
|
|
|
db = Database()
|
|
app = App(renderer, db)
|
|
app.run()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add console/
|
|
git commit -m "feat(console): scaffold project structure and config"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Database Abstraction Layer
|
|
|
|
**Files:**
|
|
- Create: `console/db.py`
|
|
- Create: `console/tests/__init__.py`
|
|
- Create: `console/tests/test_db.py`
|
|
|
|
**Step 1: Write test for DB layer**
|
|
|
|
```python
|
|
# console/tests/test_db.py
|
|
import os
|
|
import sys
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
from console.db import Database
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
return Database()
|
|
|
|
|
|
def test_get_brands(db):
|
|
brands = db.get_brands()
|
|
assert isinstance(brands, list)
|
|
assert len(brands) > 0
|
|
assert 'name' in brands[0]
|
|
|
|
|
|
def test_get_models_no_filter(db):
|
|
models = db.get_models()
|
|
assert isinstance(models, list)
|
|
assert len(models) > 0
|
|
|
|
|
|
def test_get_models_by_brand(db):
|
|
models = db.get_models(brand='TOYOTA')
|
|
assert isinstance(models, list)
|
|
assert len(models) > 0
|
|
|
|
|
|
def test_get_categories(db):
|
|
cats = db.get_categories()
|
|
assert isinstance(cats, list)
|
|
assert len(cats) == 12
|
|
|
|
|
|
def test_get_groups(db):
|
|
groups = db.get_groups(category_id=2)
|
|
assert isinstance(groups, list)
|
|
assert len(groups) > 0
|
|
assert 'name' in groups[0]
|
|
|
|
|
|
def test_get_parts_by_group(db):
|
|
parts = db.get_parts(group_id=17)
|
|
assert isinstance(parts, list)
|
|
|
|
|
|
def test_get_part_detail(db):
|
|
part = db.get_part(1)
|
|
assert part is not None
|
|
assert 'oem_part_number' in part
|
|
|
|
|
|
def test_get_alternatives(db):
|
|
alts = db.get_alternatives(1)
|
|
assert isinstance(alts, list)
|
|
|
|
|
|
def test_get_cross_references(db):
|
|
refs = db.get_cross_references(1)
|
|
assert isinstance(refs, list)
|
|
|
|
|
|
def test_search_parts_fts(db):
|
|
results = db.search_parts('brake')
|
|
assert isinstance(results, list)
|
|
assert len(results) > 0
|
|
|
|
|
|
def test_search_part_number(db):
|
|
results = db.search_part_number('04465')
|
|
assert isinstance(results, list)
|
|
|
|
|
|
def test_get_stats(db):
|
|
stats = db.get_stats()
|
|
assert 'brands' in stats
|
|
assert 'models' in stats
|
|
assert 'parts' in stats
|
|
assert stats['brands'] > 0
|
|
|
|
|
|
def test_get_years(db):
|
|
years = db.get_years(brand='TOYOTA', model='CAMRY')
|
|
assert isinstance(years, list)
|
|
|
|
|
|
def test_get_engines(db):
|
|
engines = db.get_engines(brand='TOYOTA', model='CAMRY')
|
|
assert isinstance(engines, list)
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /home/Autopartes && python -m pytest console/tests/test_db.py -v
|
|
```
|
|
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'console.db'`
|
|
|
|
**Step 3: Implement db.py**
|
|
|
|
```python
|
|
# console/db.py
|
|
import sqlite3
|
|
import os
|
|
from console.config import DB_PATH
|
|
|
|
|
|
class Database:
|
|
def __init__(self, db_path=None):
|
|
self.db_path = db_path or DB_PATH
|
|
|
|
def _connect(self):
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _query(self, sql, params=(), one=False):
|
|
conn = self._connect()
|
|
cursor = conn.cursor()
|
|
cursor.execute(sql, params)
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
if one:
|
|
return dict(rows[0]) if rows else None
|
|
return [dict(r) for r in rows]
|
|
|
|
def _execute(self, sql, params=()):
|
|
conn = self._connect()
|
|
cursor = conn.cursor()
|
|
cursor.execute(sql, params)
|
|
conn.commit()
|
|
last_id = cursor.lastrowid
|
|
conn.close()
|
|
return last_id
|
|
|
|
# === Vehicle Navigation ===
|
|
|
|
def get_brands(self):
|
|
return self._query("""
|
|
SELECT id, name, country FROM brands ORDER BY name
|
|
""")
|
|
|
|
def get_models(self, brand=None):
|
|
if brand:
|
|
return self._query("""
|
|
SELECT DISTINCT m.id, m.name
|
|
FROM models m
|
|
JOIN brands b ON m.brand_id = b.id
|
|
WHERE UPPER(b.name) = UPPER(?)
|
|
ORDER BY m.name
|
|
""", (brand,))
|
|
return self._query("SELECT id, name FROM models ORDER BY name")
|
|
|
|
def get_years(self, brand=None, model=None):
|
|
sql = """
|
|
SELECT DISTINCT y.id, y.year
|
|
FROM years y
|
|
JOIN model_year_engine mye ON mye.year_id = y.id
|
|
JOIN models m ON mye.model_id = m.id
|
|
JOIN brands b ON m.brand_id = b.id
|
|
WHERE 1=1
|
|
"""
|
|
params = []
|
|
if brand:
|
|
sql += " AND UPPER(b.name) = UPPER(?)"
|
|
params.append(brand)
|
|
if model:
|
|
sql += " AND UPPER(m.name) = UPPER(?)"
|
|
params.append(model)
|
|
sql += " ORDER BY y.year DESC"
|
|
return self._query(sql, params)
|
|
|
|
def get_engines(self, brand=None, model=None, year=None):
|
|
sql = """
|
|
SELECT DISTINCT e.id, e.name, e.displacement_cc, e.cylinders,
|
|
e.fuel_type, e.power_hp, e.torque_nm
|
|
FROM engines e
|
|
JOIN model_year_engine mye ON mye.engine_id = e.id
|
|
JOIN models m ON mye.model_id = m.id
|
|
JOIN brands b ON m.brand_id = b.id
|
|
WHERE 1=1
|
|
"""
|
|
params = []
|
|
if brand:
|
|
sql += " AND UPPER(b.name) = UPPER(?)"
|
|
params.append(brand)
|
|
if model:
|
|
sql += " AND UPPER(m.name) = UPPER(?)"
|
|
params.append(model)
|
|
if year:
|
|
sql += " AND mye.year_id IN (SELECT id FROM years WHERE year = ?)"
|
|
params.append(int(year))
|
|
sql += " ORDER BY e.name"
|
|
return self._query(sql, params)
|
|
|
|
def get_model_year_engine(self, brand, model, year, engine_id):
|
|
return self._query("""
|
|
SELECT mye.id, mye.trim_level, mye.drivetrain, mye.transmission
|
|
FROM model_year_engine mye
|
|
JOIN models m ON mye.model_id = m.id
|
|
JOIN brands b ON m.brand_id = b.id
|
|
JOIN years y ON mye.year_id = y.id
|
|
WHERE UPPER(b.name) = UPPER(?)
|
|
AND UPPER(m.name) = UPPER(?)
|
|
AND y.year = ?
|
|
AND mye.engine_id = ?
|
|
""", (brand, model, int(year), engine_id))
|
|
|
|
# === Parts Catalog ===
|
|
|
|
def get_categories(self):
|
|
return self._query("""
|
|
SELECT id, name, name_es, icon_name, slug, display_order
|
|
FROM part_categories ORDER BY display_order
|
|
""")
|
|
|
|
def get_groups(self, category_id):
|
|
return self._query("""
|
|
SELECT id, name, name_es, slug, display_order
|
|
FROM part_groups WHERE category_id = ? ORDER BY display_order
|
|
""", (category_id,))
|
|
|
|
def get_parts(self, group_id=None, mye_id=None, page=1, per_page=15):
|
|
offset = (page - 1) * per_page
|
|
if mye_id and group_id:
|
|
return self._query("""
|
|
SELECT p.id, p.oem_part_number, p.name, p.name_es,
|
|
pg.name AS group_name, vp.quantity_required, vp.position,
|
|
(SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
|
|
FROM parts p
|
|
JOIN vehicle_parts vp ON vp.part_id = p.id
|
|
JOIN part_groups pg ON p.group_id = pg.id
|
|
WHERE vp.model_year_engine_id = ? AND p.group_id = ?
|
|
ORDER BY p.name
|
|
LIMIT ? OFFSET ?
|
|
""", (mye_id, group_id, per_page, offset))
|
|
elif mye_id:
|
|
return self._query("""
|
|
SELECT p.id, p.oem_part_number, p.name, p.name_es,
|
|
pg.name AS group_name, vp.quantity_required, vp.position,
|
|
(SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
|
|
FROM parts p
|
|
JOIN vehicle_parts vp ON vp.part_id = p.id
|
|
JOIN part_groups pg ON p.group_id = pg.id
|
|
WHERE vp.model_year_engine_id = ?
|
|
ORDER BY pg.display_order, p.name
|
|
LIMIT ? OFFSET ?
|
|
""", (mye_id, per_page, offset))
|
|
elif group_id:
|
|
return self._query("""
|
|
SELECT p.id, p.oem_part_number, p.name, p.name_es,
|
|
pg.name AS group_name,
|
|
(SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
|
|
FROM parts p
|
|
JOIN part_groups pg ON p.group_id = pg.id
|
|
WHERE p.group_id = ?
|
|
ORDER BY p.name
|
|
LIMIT ? OFFSET ?
|
|
""", (group_id, per_page, offset))
|
|
return self._query("""
|
|
SELECT p.id, p.oem_part_number, p.name, p.name_es,
|
|
pg.name AS group_name,
|
|
(SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
|
|
FROM parts p
|
|
JOIN part_groups pg ON p.group_id = pg.id
|
|
ORDER BY p.name
|
|
LIMIT ? OFFSET ?
|
|
""", (per_page, offset))
|
|
|
|
def get_part(self, part_id):
|
|
return self._query("""
|
|
SELECT p.*, pg.name AS group_name, pg.category_id,
|
|
pc.name AS category_name, pc.name_es AS category_name_es
|
|
FROM parts p
|
|
JOIN part_groups pg ON p.group_id = pg.id
|
|
JOIN part_categories pc ON pg.category_id = pc.id
|
|
WHERE p.id = ?
|
|
""", (part_id,), one=True)
|
|
|
|
def get_alternatives(self, part_id):
|
|
return self._query("""
|
|
SELECT ap.*, m.name AS manufacturer_name, m.country AS manufacturer_country
|
|
FROM aftermarket_parts ap
|
|
JOIN manufacturers m ON ap.manufacturer_id = m.id
|
|
WHERE ap.oem_part_id = ?
|
|
ORDER BY
|
|
CASE ap.quality_tier
|
|
WHEN 'premium' THEN 1
|
|
WHEN 'standard' THEN 2
|
|
WHEN 'economy' THEN 3
|
|
ELSE 4
|
|
END
|
|
""", (part_id,))
|
|
|
|
def get_cross_references(self, part_id):
|
|
return self._query("""
|
|
SELECT * FROM part_cross_references
|
|
WHERE part_id = ? ORDER BY reference_type
|
|
""", (part_id,))
|
|
|
|
# === Search ===
|
|
|
|
def search_parts(self, query, page=1, per_page=15):
|
|
offset = (page - 1) * per_page
|
|
return self._query("""
|
|
SELECT p.id, p.oem_part_number, p.name, p.name_es,
|
|
pg.name AS group_name, pc.name AS category_name
|
|
FROM parts_fts fts
|
|
JOIN parts p ON fts.rowid = p.id
|
|
JOIN part_groups pg ON p.group_id = pg.id
|
|
JOIN part_categories pc ON pg.category_id = pc.id
|
|
WHERE parts_fts MATCH ?
|
|
ORDER BY rank
|
|
LIMIT ? OFFSET ?
|
|
""", (query, per_page, offset))
|
|
|
|
def search_part_number(self, number):
|
|
like = f'%{number}%'
|
|
results = []
|
|
# OEM
|
|
oem = self._query("""
|
|
SELECT p.id, p.oem_part_number AS part_number, p.name, p.name_es, 'OEM' AS type,
|
|
(SELECT b.name FROM brands b JOIN models m ON m.brand_id = b.id
|
|
JOIN model_year_engine mye ON mye.model_id = m.id
|
|
JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id
|
|
WHERE vp.part_id = p.id LIMIT 1) AS source
|
|
FROM parts p WHERE p.oem_part_number LIKE ?
|
|
""", (like,))
|
|
results.extend(oem)
|
|
# Aftermarket
|
|
after = self._query("""
|
|
SELECT ap.id, ap.part_number, ap.name, ap.name_es, 'AFTERMARKET' AS type,
|
|
m.name AS source
|
|
FROM aftermarket_parts ap
|
|
JOIN manufacturers m ON ap.manufacturer_id = m.id
|
|
WHERE ap.part_number LIKE ?
|
|
""", (like,))
|
|
results.extend(after)
|
|
# Cross-references
|
|
xref = self._query("""
|
|
SELECT pcr.id, pcr.cross_reference_number AS part_number,
|
|
p.name, p.name_es,
|
|
'CROSS-REF (' || pcr.reference_type || ')' AS type,
|
|
pcr.source
|
|
FROM part_cross_references pcr
|
|
JOIN parts p ON pcr.part_id = p.id
|
|
WHERE pcr.cross_reference_number LIKE ?
|
|
""", (like,))
|
|
results.extend(xref)
|
|
return results
|
|
|
|
# === VIN ===
|
|
|
|
def get_vin_cache(self, vin):
|
|
return self._query("""
|
|
SELECT * FROM vin_cache
|
|
WHERE vin = ? AND expires_at > datetime('now')
|
|
""", (vin,), one=True)
|
|
|
|
def save_vin_cache(self, vin, data, make, model, year, engine_info, body_class, drive_type):
|
|
self._execute("""
|
|
INSERT OR REPLACE INTO vin_cache
|
|
(vin, decoded_data, make, model, year, engine_info, body_class, drive_type, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+30 days'))
|
|
""", (vin, data, make, model, year, engine_info, body_class, drive_type))
|
|
|
|
# === Stats ===
|
|
|
|
def get_stats(self):
|
|
stats = {}
|
|
tables = ['brands', 'models', 'years', 'engines', 'model_year_engine',
|
|
'part_categories', 'part_groups', 'parts', 'vehicle_parts',
|
|
'manufacturers', 'aftermarket_parts', 'part_cross_references',
|
|
'diagrams', 'diagram_hotspots', 'vin_cache']
|
|
conn = self._connect()
|
|
cursor = conn.cursor()
|
|
for table in tables:
|
|
cursor.execute(f"SELECT COUNT(*) FROM [{table}]")
|
|
stats[table] = cursor.fetchone()[0]
|
|
|
|
# Top brands with parts
|
|
cursor.execute("""
|
|
SELECT b.name, COUNT(vp.id) AS cnt
|
|
FROM brands b
|
|
JOIN models m ON m.brand_id = b.id
|
|
JOIN model_year_engine mye ON mye.model_id = m.id
|
|
JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id
|
|
GROUP BY b.name ORDER BY cnt DESC LIMIT 5
|
|
""")
|
|
stats['top_brands'] = [dict(r) for r in cursor.fetchall()]
|
|
conn.close()
|
|
return stats
|
|
|
|
# === Admin CRUD ===
|
|
|
|
def create_part(self, data):
|
|
return self._execute("""
|
|
INSERT INTO parts (oem_part_number, name, name_es, group_id, description, description_es, weight_kg, material)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'],
|
|
data.get('description'), data.get('description_es'), data.get('weight_kg'), data.get('material')))
|
|
|
|
def update_part(self, part_id, data):
|
|
self._execute("""
|
|
UPDATE parts SET oem_part_number=?, name=?, name_es=?, group_id=?,
|
|
description=?, description_es=?, weight_kg=?, material=?, is_discontinued=?
|
|
WHERE id=?
|
|
""", (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'],
|
|
data.get('description'), data.get('description_es'), data.get('weight_kg'),
|
|
data.get('material'), data.get('is_discontinued', 0), part_id))
|
|
|
|
def delete_part(self, part_id):
|
|
self._execute("DELETE FROM parts WHERE id=?", (part_id,))
|
|
|
|
def create_manufacturer(self, data):
|
|
return self._execute("""
|
|
INSERT INTO manufacturers (name, type, quality_tier, country, website)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (data['name'], data.get('type', 'aftermarket'), data.get('quality_tier', 'standard'),
|
|
data.get('country'), data.get('website')))
|
|
|
|
def update_manufacturer(self, mfr_id, data):
|
|
self._execute("""
|
|
UPDATE manufacturers SET name=?, type=?, quality_tier=?, country=?, website=?
|
|
WHERE id=?
|
|
""", (data['name'], data.get('type'), data.get('quality_tier'),
|
|
data.get('country'), data.get('website'), mfr_id))
|
|
|
|
def delete_manufacturer(self, mfr_id):
|
|
self._execute("DELETE FROM manufacturers WHERE id=?", (mfr_id,))
|
|
|
|
def get_manufacturers(self):
|
|
return self._query("SELECT * FROM manufacturers ORDER BY name")
|
|
|
|
def create_crossref(self, data):
|
|
return self._execute("""
|
|
INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source, notes)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (data['part_id'], data['cross_reference_number'], data.get('reference_type', 'interchange'),
|
|
data.get('source'), data.get('notes')))
|
|
|
|
def update_crossref(self, xref_id, data):
|
|
self._execute("""
|
|
UPDATE part_cross_references
|
|
SET part_id=?, cross_reference_number=?, reference_type=?, source=?, notes=?
|
|
WHERE id=?
|
|
""", (data['part_id'], data['cross_reference_number'], data.get('reference_type'),
|
|
data.get('source'), data.get('notes'), xref_id))
|
|
|
|
def delete_crossref(self, xref_id):
|
|
self._execute("DELETE FROM part_cross_references WHERE id=?", (xref_id,))
|
|
|
|
def get_crossrefs_paginated(self, page=1, per_page=15):
|
|
offset = (page - 1) * per_page
|
|
return self._query("""
|
|
SELECT pcr.*, p.oem_part_number, p.name AS part_name
|
|
FROM part_cross_references pcr
|
|
JOIN parts p ON pcr.part_id = p.id
|
|
ORDER BY pcr.id
|
|
LIMIT ? OFFSET ?
|
|
""", (per_page, offset))
|
|
|
|
def get_vehicles_for_part(self, part_id):
|
|
return self._query("""
|
|
SELECT b.name AS brand, m.name AS model, y.year, e.name AS engine
|
|
FROM vehicle_parts vp
|
|
JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id
|
|
JOIN models m ON mye.model_id = m.id
|
|
JOIN brands b ON m.brand_id = b.id
|
|
JOIN years y ON mye.year_id = y.id
|
|
JOIN engines e ON mye.engine_id = e.id
|
|
WHERE vp.part_id = ?
|
|
ORDER BY b.name, m.name, y.year
|
|
""", (part_id,))
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /home/Autopartes && python -m pytest console/tests/test_db.py -v
|
|
```
|
|
|
|
Expected: All PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add console/db.py console/tests/
|
|
git commit -m "feat(console): add database abstraction layer with tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Core Framework — Key Bindings and Navigation
|
|
|
|
**Files:**
|
|
- Create: `console/core/keybindings.py`
|
|
- Create: `console/core/navigation.py`
|
|
- Create: `console/core/screens.py`
|
|
- Create: `console/tests/test_core.py`
|
|
|
|
**Step 1: Write tests**
|
|
|
|
```python
|
|
# console/tests/test_core.py
|
|
import os, sys
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
|
|
from console.core.keybindings import Key, KeyBindings
|
|
from console.core.navigation import Navigation
|
|
from console.core.screens import Screen
|
|
|
|
|
|
def test_key_constants():
|
|
assert Key.ESCAPE == 27
|
|
assert Key.ENTER == 10
|
|
assert Key.F1 is not None
|
|
|
|
|
|
def test_keybindings_register():
|
|
kb = KeyBindings()
|
|
called = []
|
|
kb.bind(Key.F1, lambda: called.append('help'))
|
|
kb.handle(Key.F1)
|
|
assert called == ['help']
|
|
|
|
|
|
def test_keybindings_unbound_key():
|
|
kb = KeyBindings()
|
|
result = kb.handle(999)
|
|
assert result is False
|
|
|
|
|
|
def test_navigation_push_pop():
|
|
nav = Navigation()
|
|
nav.push('menu', {})
|
|
nav.push('brands', {'filter': 'T'})
|
|
assert nav.current() == ('brands', {'filter': 'T'})
|
|
nav.pop()
|
|
assert nav.current() == ('menu', {})
|
|
|
|
|
|
def test_navigation_breadcrumb():
|
|
nav = Navigation()
|
|
nav.push('menu', {}, label='Menu')
|
|
nav.push('brands', {}, label='Marcas')
|
|
nav.push('models', {}, label='TOYOTA')
|
|
bc = nav.breadcrumb()
|
|
assert bc == ['Menu', 'Marcas', 'TOYOTA']
|
|
|
|
|
|
def test_navigation_empty():
|
|
nav = Navigation()
|
|
assert nav.current() is None
|
|
nav.pop() # should not crash
|
|
|
|
|
|
def test_screen_base():
|
|
s = Screen('test', 'Test Screen')
|
|
assert s.name == 'test'
|
|
assert s.title == 'Test Screen'
|
|
```
|
|
|
|
**Step 2: Run test to verify failure**
|
|
|
|
```bash
|
|
cd /home/Autopartes && python -m pytest console/tests/test_core.py -v
|
|
```
|
|
|
|
**Step 3: Implement keybindings.py**
|
|
|
|
```python
|
|
# console/core/keybindings.py
|
|
import curses
|
|
|
|
|
|
class Key:
|
|
"""Key constants matching curses key codes."""
|
|
ESCAPE = 27
|
|
ENTER = 10
|
|
TAB = 9
|
|
BACKSPACE = 127
|
|
UP = curses.KEY_UP
|
|
DOWN = curses.KEY_DOWN
|
|
LEFT = curses.KEY_LEFT
|
|
RIGHT = curses.KEY_RIGHT
|
|
PGUP = curses.KEY_PPAGE
|
|
PGDN = curses.KEY_NPAGE
|
|
HOME = curses.KEY_HOME
|
|
END = curses.KEY_END
|
|
F1 = curses.KEY_F1
|
|
F2 = curses.KEY_F2
|
|
F3 = curses.KEY_F3
|
|
F4 = curses.KEY_F4
|
|
F5 = curses.KEY_F5
|
|
F6 = curses.KEY_F6
|
|
F7 = curses.KEY_F7
|
|
F8 = curses.KEY_F8
|
|
F9 = curses.KEY_F9
|
|
F10 = curses.KEY_F10
|
|
|
|
|
|
class KeyBindings:
|
|
"""Registry of key-to-action bindings."""
|
|
|
|
def __init__(self):
|
|
self._bindings = {}
|
|
|
|
def bind(self, key, action):
|
|
self._bindings[key] = action
|
|
|
|
def handle(self, key):
|
|
if key in self._bindings:
|
|
self._bindings[key]()
|
|
return True
|
|
return False
|
|
|
|
def get_footer_labels(self):
|
|
"""Return list of (key_label, description) for footer display."""
|
|
return self._footer_labels if hasattr(self, '_footer_labels') else []
|
|
|
|
def set_footer(self, labels):
|
|
"""Set footer labels: [('F1', 'Ayuda'), ('ESC', 'Atrás'), ...]"""
|
|
self._footer_labels = labels
|
|
```
|
|
|
|
**Step 4: Implement navigation.py**
|
|
|
|
```python
|
|
# console/core/navigation.py
|
|
|
|
|
|
class Navigation:
|
|
"""Screen navigation stack with breadcrumb support."""
|
|
|
|
def __init__(self):
|
|
self._stack = [] # list of (screen_name, context, label)
|
|
|
|
def push(self, screen_name, context=None, label=None):
|
|
self._stack.append((screen_name, context or {}, label or screen_name))
|
|
|
|
def pop(self):
|
|
if self._stack:
|
|
return self._stack.pop()
|
|
return None
|
|
|
|
def current(self):
|
|
if self._stack:
|
|
entry = self._stack[-1]
|
|
return (entry[0], entry[1])
|
|
return None
|
|
|
|
def breadcrumb(self):
|
|
return [entry[2] for entry in self._stack]
|
|
|
|
def clear(self):
|
|
self._stack.clear()
|
|
|
|
def depth(self):
|
|
return len(self._stack)
|
|
```
|
|
|
|
**Step 5: Implement screens.py**
|
|
|
|
```python
|
|
# console/core/screens.py
|
|
|
|
|
|
class Screen:
|
|
"""Base class for all application screens."""
|
|
|
|
def __init__(self, name, title):
|
|
self.name = name
|
|
self.title = title
|
|
|
|
def on_enter(self, context, db, renderer):
|
|
"""Called when screen becomes active. Override in subclasses."""
|
|
pass
|
|
|
|
def on_key(self, key, context, db, renderer, nav):
|
|
"""Handle key press. Return screen_name to navigate, None to stay, 'back' to pop."""
|
|
return None
|
|
|
|
def render(self, context, db, renderer):
|
|
"""Draw the screen. Override in subclasses."""
|
|
pass
|
|
```
|
|
|
|
**Step 6: Run tests**
|
|
|
|
```bash
|
|
cd /home/Autopartes && python -m pytest console/tests/test_core.py -v
|
|
```
|
|
|
|
Expected: All PASS
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add console/core/ console/tests/test_core.py
|
|
git commit -m "feat(console): add core framework - keybindings, navigation, screen base"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Utilities — Formatting and VIN API
|
|
|
|
**Files:**
|
|
- Create: `console/utils/formatting.py`
|
|
- Create: `console/utils/vin_api.py`
|
|
- Create: `console/tests/test_utils.py`
|
|
|
|
**Step 1: Write tests**
|
|
|
|
```python
|
|
# console/tests/test_utils.py
|
|
import os, sys
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
|
|
from console.utils.formatting import format_currency, format_number, truncate, pad_right, format_table_row
|
|
|
|
|
|
def test_format_currency():
|
|
assert format_currency(45.99) == '$45.99'
|
|
assert format_currency(None) == '──'
|
|
assert format_currency(0) == '$0.00'
|
|
|
|
|
|
def test_format_number():
|
|
assert format_number(13685) == '13,685'
|
|
assert format_number(0) == '0'
|
|
|
|
|
|
def test_truncate():
|
|
assert truncate('Hello World', 8) == 'Hello...'
|
|
assert truncate('Short', 10) == 'Short'
|
|
|
|
|
|
def test_pad_right():
|
|
assert pad_right('Hi', 5) == 'Hi '
|
|
assert pad_right('Hello World', 5) == 'Hello'
|
|
|
|
|
|
def test_format_table_row():
|
|
row = format_table_row(['ID', 'Name', 'Price'], [4, 20, 8])
|
|
assert len(row) > 0
|
|
assert 'ID' in row
|
|
```
|
|
|
|
**Step 2: Run test to verify failure**
|
|
|
|
```bash
|
|
cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v
|
|
```
|
|
|
|
**Step 3: Implement formatting.py**
|
|
|
|
```python
|
|
# console/utils/formatting.py
|
|
|
|
|
|
def format_currency(value):
|
|
if value is None:
|
|
return '──'
|
|
return f'${value:,.2f}'
|
|
|
|
|
|
def format_number(value):
|
|
if value is None:
|
|
return '0'
|
|
return f'{int(value):,}'
|
|
|
|
|
|
def truncate(text, max_len):
|
|
if text is None:
|
|
return ''
|
|
text = str(text)
|
|
if len(text) <= max_len:
|
|
return text
|
|
return text[:max_len - 3] + '...'
|
|
|
|
|
|
def pad_right(text, width):
|
|
if text is None:
|
|
text = ''
|
|
text = str(text)
|
|
if len(text) > width:
|
|
return text[:width]
|
|
return text.ljust(width)
|
|
|
|
|
|
def format_table_row(values, widths, separator=' │ '):
|
|
"""Format a row with fixed column widths."""
|
|
parts = []
|
|
for val, width in zip(values, widths):
|
|
parts.append(pad_right(str(val) if val is not None else '', width))
|
|
return separator.join(parts)
|
|
|
|
|
|
def quality_bar(tier):
|
|
"""Return visual quality bar for quality tier."""
|
|
bars = {
|
|
'premium': '██████████',
|
|
'oem': '███████████',
|
|
'standard': '███████░░░',
|
|
'economy': '█████░░░░░',
|
|
}
|
|
return bars.get(tier, '░░░░░░░░░░')
|
|
```
|
|
|
|
**Step 4: Implement vin_api.py**
|
|
|
|
```python
|
|
# console/utils/vin_api.py
|
|
import json
|
|
import requests
|
|
from console.config import NHTSA_API_URL
|
|
|
|
|
|
def decode_vin_nhtsa(vin):
|
|
"""Decode a VIN using NHTSA API. Returns dict with vehicle info."""
|
|
try:
|
|
response = requests.get(
|
|
NHTSA_API_URL,
|
|
params={'format': 'json'},
|
|
timeout=10
|
|
)
|
|
# NHTSA uses path-based VIN
|
|
response = requests.get(
|
|
f"{NHTSA_API_URL}/{vin}",
|
|
params={'format': 'json'},
|
|
timeout=10
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
results = data.get('Results', [])
|
|
info = {}
|
|
for item in results:
|
|
var = item.get('Variable', '')
|
|
val = item.get('Value')
|
|
if val and val.strip():
|
|
if var == 'Make':
|
|
info['make'] = val.strip()
|
|
elif var == 'Model':
|
|
info['model'] = val.strip()
|
|
elif var == 'Model Year':
|
|
info['year'] = int(val.strip())
|
|
elif var == 'Body Class':
|
|
info['body_class'] = val.strip()
|
|
elif var == 'Drive Type':
|
|
info['drive_type'] = val.strip()
|
|
elif var == 'Displacement (L)':
|
|
info.setdefault('engine_info', {})['displacement_l'] = val.strip()
|
|
elif var == 'Engine Number of Cylinders':
|
|
info.setdefault('engine_info', {})['cylinders'] = val.strip()
|
|
elif var == 'Fuel Type - Primary':
|
|
info.setdefault('engine_info', {})['fuel_type'] = val.strip()
|
|
elif var == 'Engine Brake (hp) From':
|
|
info.setdefault('engine_info', {})['power_hp'] = val.strip()
|
|
|
|
engine = info.get('engine_info', {})
|
|
if engine:
|
|
parts = []
|
|
if engine.get('displacement_l'):
|
|
parts.append(f"{engine['displacement_l']}L")
|
|
if engine.get('cylinders'):
|
|
parts.append(f"{engine['cylinders']} cil.")
|
|
if engine.get('fuel_type'):
|
|
parts.append(engine['fuel_type'])
|
|
engine['raw'] = ' '.join(parts)
|
|
|
|
return info
|
|
except Exception as e:
|
|
return {'error': str(e)}
|
|
```
|
|
|
|
**Step 5: Run tests**
|
|
|
|
```bash
|
|
cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v
|
|
```
|
|
|
|
Expected: All PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add console/utils/ console/tests/test_utils.py
|
|
git commit -m "feat(console): add formatting utils and VIN API client"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Curses Renderer (VT220 Mode)
|
|
|
|
**Files:**
|
|
- Create: `console/renderers/curses_renderer.py`
|
|
- Create: `console/renderers/base.py`
|
|
|
|
**Step 1: Write base renderer interface**
|
|
|
|
```python
|
|
# console/renderers/base.py
|
|
|
|
|
|
class BaseRenderer:
|
|
"""Abstract renderer interface. All renderers must implement these methods."""
|
|
|
|
def init_screen(self):
|
|
raise NotImplementedError
|
|
|
|
def cleanup(self):
|
|
raise NotImplementedError
|
|
|
|
def get_size(self):
|
|
"""Return (height, width) of terminal."""
|
|
raise NotImplementedError
|
|
|
|
def clear(self):
|
|
raise NotImplementedError
|
|
|
|
def refresh(self):
|
|
raise NotImplementedError
|
|
|
|
def get_key(self):
|
|
"""Block and return key code."""
|
|
raise NotImplementedError
|
|
|
|
def draw_header(self, title, subtitle=''):
|
|
raise NotImplementedError
|
|
|
|
def draw_footer(self, key_labels):
|
|
"""key_labels: list of (key, description) tuples."""
|
|
raise NotImplementedError
|
|
|
|
def draw_menu(self, items, selected_index=0, title=''):
|
|
"""items: list of (number, label) tuples."""
|
|
raise NotImplementedError
|
|
|
|
def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1):
|
|
raise NotImplementedError
|
|
|
|
def draw_detail(self, fields, title=''):
|
|
"""fields: list of (label, value) tuples."""
|
|
raise NotImplementedError
|
|
|
|
def draw_form(self, fields, focused_index=0, title=''):
|
|
"""fields: list of {label, value, width, type} dicts."""
|
|
raise NotImplementedError
|
|
|
|
def draw_filter_list(self, items, filter_text, selected_index, title=''):
|
|
raise NotImplementedError
|
|
|
|
def draw_comparison(self, columns, title=''):
|
|
"""columns: list of {header, rows} dicts for side-by-side."""
|
|
raise NotImplementedError
|
|
|
|
def draw_text(self, row, col, text, style='normal'):
|
|
raise NotImplementedError
|
|
|
|
def draw_box(self, top, left, height, width, title=''):
|
|
raise NotImplementedError
|
|
|
|
def show_message(self, text, msg_type='info'):
|
|
"""msg_type: 'info', 'error', 'confirm'. Returns True/False for confirm."""
|
|
raise NotImplementedError
|
|
|
|
def show_input(self, prompt, max_len=40):
|
|
"""Show input prompt and return typed string."""
|
|
raise NotImplementedError
|
|
```
|
|
|
|
**Step 2: Implement curses_renderer.py**
|
|
|
|
This is the largest single file. It implements the full VT220 aesthetic with green-on-black, box drawing characters, and Pick-style screen layout.
|
|
|
|
```python
|
|
# console/renderers/curses_renderer.py
|
|
import curses
|
|
from console.renderers.base import BaseRenderer
|
|
from console.config import COLORS_VT220
|
|
from console.utils.formatting import pad_right, truncate
|
|
|
|
|
|
class CursesRenderer(BaseRenderer):
|
|
|
|
def __init__(self):
|
|
self.stdscr = None
|
|
self.colors = {}
|
|
|
|
def init_screen(self):
|
|
self.stdscr = curses.initscr()
|
|
curses.noecho()
|
|
curses.cbreak()
|
|
curses.curs_set(0)
|
|
self.stdscr.keypad(True)
|
|
|
|
if curses.has_colors():
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
self._init_colors()
|
|
|
|
def _init_colors(self):
|
|
color_map = {'black': 0, 'red': 1, 'green': 2, 'yellow': 3,
|
|
'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7}
|
|
for i, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), 1):
|
|
if i < curses.COLOR_PAIRS:
|
|
curses.init_pair(i, color_map.get(fg, 7), color_map.get(bg, 0))
|
|
self.colors[name] = curses.color_pair(i)
|
|
# Ensure defaults
|
|
self.colors.setdefault('normal', curses.color_pair(0))
|
|
|
|
def _style(self, name):
|
|
return self.colors.get(name, self.colors.get('normal', 0))
|
|
|
|
def cleanup(self):
|
|
if self.stdscr:
|
|
curses.nocbreak()
|
|
self.stdscr.keypad(False)
|
|
curses.echo()
|
|
curses.endwin()
|
|
|
|
def get_size(self):
|
|
h, w = self.stdscr.getmaxyx()
|
|
return h, w
|
|
|
|
def clear(self):
|
|
self.stdscr.clear()
|
|
|
|
def refresh(self):
|
|
self.stdscr.refresh()
|
|
|
|
def get_key(self):
|
|
return self.stdscr.getch()
|
|
|
|
def draw_header(self, title, subtitle=''):
|
|
h, w = self.get_size()
|
|
header_text = f' {title}'
|
|
if subtitle:
|
|
space = w - len(title) - len(subtitle) - 4
|
|
header_text = f' {title}{" " * max(space, 2)}{subtitle} '
|
|
header_text = header_text[:w].ljust(w)
|
|
try:
|
|
self.stdscr.addstr(0, 0, header_text, self._style('header') | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
# Separator line
|
|
try:
|
|
self.stdscr.addstr(1, 0, '─' * w, self._style('border'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_footer(self, key_labels):
|
|
h, w = self.get_size()
|
|
# Separator
|
|
try:
|
|
self.stdscr.addstr(h - 2, 0, '─' * w, self._style('border'))
|
|
except curses.error:
|
|
pass
|
|
# Key labels
|
|
footer = ' ' + ' '.join(f'{k}={v}' for k, v in key_labels) + ' '
|
|
footer = footer[:w].ljust(w)
|
|
try:
|
|
self.stdscr.addstr(h - 1, 0, footer[:w-1], self._style('footer'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_menu(self, items, selected_index=0, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
if title:
|
|
try:
|
|
self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
start_row += 2
|
|
|
|
for i, (num, label) in enumerate(items):
|
|
row = start_row + i
|
|
if row >= h - 3:
|
|
break
|
|
style = self._style('highlight') if i == selected_index else self._style('normal')
|
|
prefix = '>' if i == selected_index else ' '
|
|
text = f' {prefix} {num}. {label}'
|
|
try:
|
|
self.stdscr.addstr(row, 0, pad_right(text, w), style)
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
|
|
# Header row
|
|
header_line = ' # │ '
|
|
for hdr, width in zip(headers, widths):
|
|
header_line += pad_right(hdr, width) + ' │ '
|
|
try:
|
|
self.stdscr.addstr(start_row, 0, header_line[:w], self._style('title') | curses.A_BOLD)
|
|
self.stdscr.addstr(start_row + 1, 0, '─' * w, self._style('border'))
|
|
except curses.error:
|
|
pass
|
|
|
|
# Data rows
|
|
for i, row in enumerate(rows):
|
|
y = start_row + 2 + i
|
|
if y >= h - 4:
|
|
break
|
|
style = self._style('highlight') if i == selected_row else self._style('normal')
|
|
line = f' {i + 1:<3}│ '
|
|
for val, width in zip(row, widths):
|
|
line += pad_right(truncate(str(val) if val is not None else '', width), width) + ' │ '
|
|
try:
|
|
self.stdscr.addstr(y, 0, line[:w], style)
|
|
except curses.error:
|
|
pass
|
|
|
|
# Page info
|
|
if page_info:
|
|
info_row = h - 3
|
|
info_text = f' Página {page_info.get("page", 1)}/{page_info.get("total_pages", 1)}' \
|
|
f' {page_info.get("showing", "")} de {page_info.get("total", "")} registros'
|
|
try:
|
|
self.stdscr.addstr(info_row, 0, info_text, self._style('info'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_detail(self, fields, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
if title:
|
|
try:
|
|
self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
start_row += 2
|
|
|
|
max_label = max(len(f[0]) for f in fields) if fields else 10
|
|
for i, (label, value) in enumerate(fields):
|
|
row = start_row + i
|
|
if row >= h - 3:
|
|
break
|
|
dots = '.' * (max_label - len(label) + 2)
|
|
line = f' {label}{dots}: {value}'
|
|
try:
|
|
self.stdscr.addstr(row, 2, label, self._style('field_label'))
|
|
self.stdscr.addstr(row, 2 + len(label), dots + ': ', self._style('border'))
|
|
self.stdscr.addstr(row, 2 + max_label + 4, str(value)[:w - max_label - 8],
|
|
self._style('field_value'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_form(self, fields, focused_index=0, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
if title:
|
|
try:
|
|
self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
start_row += 2
|
|
|
|
max_label = max(len(f['label']) for f in fields) if fields else 10
|
|
for i, field in enumerate(fields):
|
|
row = start_row + i
|
|
if row >= h - 3:
|
|
break
|
|
label = field['label']
|
|
value = field.get('value', '')
|
|
field_width = field.get('width', 30)
|
|
dots = '.' * (max_label - len(label) + 2)
|
|
is_focused = (i == focused_index)
|
|
|
|
try:
|
|
self.stdscr.addstr(row, 2, f'{i + 1}.', self._style('info'))
|
|
self.stdscr.addstr(row, 5, label, self._style('field_label'))
|
|
self.stdscr.addstr(row, 5 + len(label), dots + ': ', self._style('border'))
|
|
|
|
val_col = 5 + max_label + 4
|
|
display_val = f'[{pad_right(str(value), field_width)}]'
|
|
style = self._style('field_active') if is_focused else self._style('field_value')
|
|
self.stdscr.addstr(row, val_col, display_val[:w - val_col - 2], style)
|
|
|
|
hint = field.get('hint', '')
|
|
if hint:
|
|
hint_col = val_col + field_width + 3
|
|
if hint_col < w - 2:
|
|
self.stdscr.addstr(row, hint_col, hint[:w - hint_col - 1], self._style('info'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_filter_list(self, items, filter_text, selected_index, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
if title:
|
|
try:
|
|
self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
start_row += 1
|
|
self.stdscr.addstr(start_row, 0, '─' * w, self._style('border'))
|
|
start_row += 1
|
|
|
|
# Filter input
|
|
try:
|
|
self.stdscr.addstr(start_row, 2, 'Filtro: ', self._style('field_label'))
|
|
self.stdscr.addstr(start_row, 10, filter_text + '_', self._style('field_active'))
|
|
except curses.error:
|
|
pass
|
|
start_row += 1
|
|
try:
|
|
self.stdscr.addstr(start_row, 0, '─' * w, self._style('border'))
|
|
except curses.error:
|
|
pass
|
|
start_row += 1
|
|
|
|
# Items
|
|
visible_items = h - start_row - 4
|
|
scroll_offset = max(0, selected_index - visible_items + 1)
|
|
for i, item in enumerate(items[scroll_offset:scroll_offset + visible_items]):
|
|
row = start_row + i
|
|
actual_idx = scroll_offset + i
|
|
is_selected = actual_idx == selected_index
|
|
prefix = '> ' if is_selected else ' '
|
|
num = f'{actual_idx + 1}.'
|
|
style = self._style('highlight') if is_selected else self._style('normal')
|
|
text = f' {prefix}{num:>4} {item}'
|
|
try:
|
|
self.stdscr.addstr(row, 0, pad_right(text, w), style)
|
|
except curses.error:
|
|
pass
|
|
|
|
# Count
|
|
count_text = f' {len(items)} coincidencias'
|
|
try:
|
|
self.stdscr.addstr(h - 3, w - len(count_text) - 2, count_text, self._style('info'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_comparison(self, columns, title=''):
|
|
h, w = self.get_size()
|
|
start_row = 3
|
|
if title:
|
|
try:
|
|
self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
|
|
except curses.error:
|
|
pass
|
|
start_row += 2
|
|
|
|
n_cols = len(columns)
|
|
if n_cols == 0:
|
|
return
|
|
label_w = 16
|
|
col_w = max(12, (w - label_w - 4) // n_cols - 3)
|
|
|
|
# Column headers
|
|
header_line = pad_right('', label_w) + ' │ '
|
|
for col in columns:
|
|
header_line += pad_right(truncate(col['header'], col_w), col_w) + ' │ '
|
|
try:
|
|
self.stdscr.addstr(start_row, 2, header_line[:w - 4], self._style('title') | curses.A_BOLD)
|
|
self.stdscr.addstr(start_row + 1, 2, '─' * (w - 4), self._style('border'))
|
|
except curses.error:
|
|
pass
|
|
start_row += 2
|
|
|
|
# Find max rows
|
|
max_rows = max(len(col.get('rows', [])) for col in columns) if columns else 0
|
|
for r in range(max_rows):
|
|
row = start_row + r
|
|
if row >= h - 4:
|
|
break
|
|
# Row label from first column
|
|
row_label = columns[0]['rows'][r][0] if r < len(columns[0].get('rows', [])) else ''
|
|
line = pad_right(row_label, label_w) + ' │ '
|
|
for col in columns:
|
|
val = col['rows'][r][1] if r < len(col.get('rows', [])) else ''
|
|
line += pad_right(truncate(str(val), col_w), col_w) + ' │ '
|
|
try:
|
|
self.stdscr.addstr(row, 2, line[:w - 4], self._style('normal'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_text(self, row, col, text, style='normal'):
|
|
h, w = self.get_size()
|
|
if row < h and col < w:
|
|
try:
|
|
self.stdscr.addstr(row, col, text[:w - col], self._style(style))
|
|
except curses.error:
|
|
pass
|
|
|
|
def draw_box(self, top, left, height, width, title=''):
|
|
try:
|
|
# Top border
|
|
self.stdscr.addstr(top, left, '┌' + '─' * (width - 2) + '┐', self._style('border'))
|
|
if title:
|
|
self.stdscr.addstr(top, left + 2, f' {title} ', self._style('title') | curses.A_BOLD)
|
|
# Sides
|
|
for i in range(1, height - 1):
|
|
self.stdscr.addstr(top + i, left, '│', self._style('border'))
|
|
self.stdscr.addstr(top + i, left + width - 1, '│', self._style('border'))
|
|
# Bottom border
|
|
self.stdscr.addstr(top + height - 1, left, '└' + '─' * (width - 2) + '┘', self._style('border'))
|
|
except curses.error:
|
|
pass
|
|
|
|
def show_message(self, text, msg_type='info'):
|
|
h, w = self.get_size()
|
|
box_w = min(len(text) + 6, w - 4)
|
|
box_h = 5
|
|
top = h // 2 - 2
|
|
left = (w - box_w) // 2
|
|
|
|
style = self._style('error') if msg_type == 'error' else self._style('info')
|
|
self.draw_box(top, left, box_h, box_w)
|
|
|
|
try:
|
|
self.stdscr.addstr(top + 2, left + 3, truncate(text, box_w - 6), style)
|
|
if msg_type == 'confirm':
|
|
self.stdscr.addstr(top + 3, left + 3, '(S/N)', self._style('field_label'))
|
|
else:
|
|
self.stdscr.addstr(top + 3, left + 3, 'Presione cualquier tecla...', self._style('field_label'))
|
|
except curses.error:
|
|
pass
|
|
self.refresh()
|
|
|
|
if msg_type == 'confirm':
|
|
while True:
|
|
key = self.get_key()
|
|
if key in (ord('s'), ord('S'), ord('y'), ord('Y')):
|
|
return True
|
|
if key in (ord('n'), ord('N'), 27):
|
|
return False
|
|
else:
|
|
self.get_key()
|
|
return True
|
|
|
|
def show_input(self, prompt, max_len=40):
|
|
h, w = self.get_size()
|
|
box_w = max(len(prompt) + max_len + 8, 40)
|
|
box_h = 5
|
|
top = h // 2 - 2
|
|
left = (w - box_w) // 2
|
|
|
|
self.draw_box(top, left, box_h, box_w, prompt)
|
|
|
|
value = ''
|
|
curses.curs_set(1)
|
|
while True:
|
|
try:
|
|
display = f'[{pad_right(value, max_len)}]'
|
|
self.stdscr.addstr(top + 2, left + 3, display, self._style('field_active'))
|
|
self.stdscr.move(top + 2, left + 4 + len(value))
|
|
except curses.error:
|
|
pass
|
|
self.refresh()
|
|
|
|
key = self.get_key()
|
|
if key == 10: # ENTER
|
|
break
|
|
elif key == 27: # ESC
|
|
curses.curs_set(0)
|
|
return None
|
|
elif key in (127, curses.KEY_BACKSPACE, 8):
|
|
value = value[:-1]
|
|
elif 32 <= key <= 126 and len(value) < max_len:
|
|
value += chr(key)
|
|
|
|
curses.curs_set(0)
|
|
return value
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add console/renderers/
|
|
git commit -m "feat(console): add curses VT220 renderer with full widget set"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: App Controller and Main Menu Screen
|
|
|
|
**Files:**
|
|
- Create: `console/core/app.py`
|
|
- Create: `console/screens/menu_principal.py`
|
|
- Create: `console/screens/estadisticas.py`
|
|
|
|
**Step 1: Implement app.py**
|
|
|
|
```python
|
|
# console/core/app.py
|
|
from console.core.navigation import Navigation
|
|
from console.core.keybindings import Key
|
|
|
|
|
|
class App:
|
|
"""Main application controller. Manages screen lifecycle and navigation."""
|
|
|
|
def __init__(self, renderer, db):
|
|
self.renderer = renderer
|
|
self.db = db
|
|
self.nav = Navigation()
|
|
self.screens = {}
|
|
self.running = False
|
|
self._register_screens()
|
|
|
|
def _register_screens(self):
|
|
from console.screens.menu_principal import MenuPrincipal
|
|
from console.screens.estadisticas import EstadisticasScreen
|
|
from console.screens.vehiculo_nav import VehiculoNavScreen
|
|
from console.screens.buscar_parte import BuscarParteScreen
|
|
from console.screens.buscar_texto import BuscarTextoScreen
|
|
from console.screens.vin_decoder import VinDecoderScreen
|
|
from console.screens.catalogo import CatalogoScreen
|
|
from console.screens.parte_detalle import ParteDetalleScreen
|
|
from console.screens.comparador import ComparadorScreen
|
|
from console.screens.admin_partes import AdminPartesScreen
|
|
from console.screens.admin_fabricantes import AdminFabricantesScreen
|
|
from console.screens.admin_crossref import AdminCrossrefScreen
|
|
from console.screens.admin_import import AdminImportScreen
|
|
|
|
self.screens = {
|
|
'menu': MenuPrincipal(),
|
|
'estadisticas': EstadisticasScreen(),
|
|
'vehiculo_nav': VehiculoNavScreen(),
|
|
'buscar_parte': BuscarParteScreen(),
|
|
'buscar_texto': BuscarTextoScreen(),
|
|
'vin_decoder': VinDecoderScreen(),
|
|
'catalogo': CatalogoScreen(),
|
|
'parte_detalle': ParteDetalleScreen(),
|
|
'comparador': ComparadorScreen(),
|
|
'admin_partes': AdminPartesScreen(),
|
|
'admin_fabricantes': AdminFabricantesScreen(),
|
|
'admin_crossref': AdminCrossrefScreen(),
|
|
'admin_import': AdminImportScreen(),
|
|
}
|
|
|
|
def run(self):
|
|
self.renderer.init_screen()
|
|
self.running = True
|
|
self.nav.push('menu', {}, label='Menú')
|
|
|
|
try:
|
|
while self.running:
|
|
current = self.nav.current()
|
|
if current is None:
|
|
break
|
|
|
|
screen_name, context = current
|
|
screen = self.screens.get(screen_name)
|
|
if screen is None:
|
|
self.renderer.show_message(f'Pantalla "{screen_name}" no encontrada', 'error')
|
|
self.nav.pop()
|
|
continue
|
|
|
|
# Render
|
|
self.renderer.clear()
|
|
screen.render(context, self.db, self.renderer)
|
|
self.renderer.refresh()
|
|
|
|
# Get input
|
|
key = self.renderer.get_key()
|
|
|
|
# Global keys
|
|
if key == Key.F10:
|
|
self.nav.clear()
|
|
self.nav.push('menu', {}, label='Menú')
|
|
continue
|
|
|
|
# Screen-specific handling
|
|
result = screen.on_key(key, context, self.db, self.renderer, self.nav)
|
|
|
|
if result == 'quit':
|
|
self.running = False
|
|
elif result == 'back':
|
|
self.nav.pop()
|
|
elif result and isinstance(result, tuple):
|
|
# (screen_name, context, label)
|
|
name, ctx, label = result
|
|
self.nav.push(name, ctx, label=label)
|
|
elif result and isinstance(result, str):
|
|
self.nav.push(result, {}, label=result)
|
|
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
self.renderer.cleanup()
|
|
```
|
|
|
|
**Step 2: Implement menu_principal.py**
|
|
|
|
```python
|
|
# console/screens/menu_principal.py
|
|
from console.core.screens import Screen
|
|
from console.core.keybindings import Key
|
|
from console.config import APP_NAME, APP_SUBTITLE, VERSION
|
|
|
|
|
|
class MenuPrincipal(Screen):
|
|
|
|
def __init__(self):
|
|
super().__init__('menu', 'Menú Principal')
|
|
self.selected = 0
|
|
self.items = [
|
|
('1', 'Consulta por Vehículo'),
|
|
('2', 'Búsqueda por Número de Parte'),
|
|
('3', 'Búsqueda por Descripción'),
|
|
('4', 'Decodificador VIN'),
|
|
('5', 'Catálogo de Categorías'),
|
|
('─', '─────────────────────────'),
|
|
('6', 'Administración de Partes'),
|
|
('7', 'Administración de Fabricantes'),
|
|
('8', 'Cross-References'),
|
|
('9', 'Importar / Exportar Datos'),
|
|
('─', '─────────────────────────'),
|
|
('0', 'Estadísticas del Sistema'),
|
|
]
|
|
self.selectable = [i for i, (num, _) in enumerate(self.items) if num != '─']
|
|
self.actions = {
|
|
'1': ('vehiculo_nav', {}, 'Vehículo'),
|
|
'2': ('buscar_parte', {}, 'Buscar Parte'),
|
|
'3': ('buscar_texto', {}, 'Buscar Texto'),
|
|
'4': ('vin_decoder', {}, 'VIN Decoder'),
|
|
'5': ('catalogo', {}, 'Catálogo'),
|
|
'6': ('admin_partes', {}, 'Admin Partes'),
|
|
'7': ('admin_fabricantes', {}, 'Admin Fabricantes'),
|
|
'8': ('admin_crossref', {}, 'Admin Cross-Ref'),
|
|
'9': ('admin_import', {}, 'Importar/Exportar'),
|
|
'0': ('estadisticas', {}, 'Estadísticas'),
|
|
}
|
|
|
|
def render(self, context, db, renderer):
|
|
renderer.draw_header(f'{APP_NAME} v{VERSION}', APP_SUBTITLE)
|
|
renderer.draw_menu(self.items, self.selected)
|
|
renderer.draw_footer([
|
|
('F1', 'Ayuda'), ('F3', 'Buscar'), ('F10', 'Menú'), ('ESC', 'Salir')
|
|
])
|
|
|
|
def on_key(self, key, context, db, renderer, nav):
|
|
if key == Key.ESCAPE:
|
|
if renderer.show_message('¿Desea salir del sistema?', 'confirm'):
|
|
return 'quit'
|
|
return None
|
|
|
|
# Number keys
|
|
if 48 <= key <= 57: # 0-9
|
|
num = chr(key)
|
|
if num in self.actions:
|
|
return self.actions[num]
|
|
|
|
# Arrow navigation
|
|
if key == Key.DOWN:
|
|
idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0
|
|
idx = min(idx + 1, len(self.selectable) - 1)
|
|
self.selected = self.selectable[idx]
|
|
elif key == Key.UP:
|
|
idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0
|
|
idx = max(idx - 1, 0)
|
|
self.selected = self.selectable[idx]
|
|
elif key == Key.ENTER:
|
|
num = self.items[self.selected][0]
|
|
if num in self.actions:
|
|
return self.actions[num]
|
|
|
|
return None
|
|
```
|
|
|
|
**Step 3: Implement estadisticas.py**
|
|
|
|
```python
|
|
# console/screens/estadisticas.py
|
|
from console.core.screens import Screen
|
|
from console.core.keybindings import Key
|
|
from console.utils.formatting import format_number
|
|
|
|
|
|
class EstadisticasScreen(Screen):
|
|
|
|
def __init__(self):
|
|
super().__init__('estadisticas', 'Estadísticas del Sistema')
|
|
|
|
def render(self, context, db, renderer):
|
|
import datetime
|
|
now = datetime.datetime.now().strftime('%d/%b/%Y %H:%M')
|
|
renderer.draw_header('ESTADÍSTICAS DEL SISTEMA', now)
|
|
|
|
stats = db.get_stats()
|
|
|
|
fields = [
|
|
('', '── BASE DE DATOS ─────────────────────────────────'),
|
|
('Marcas', format_number(stats.get('brands', 0))),
|
|
('Modelos', format_number(stats.get('models', 0))),
|
|
('Motores', format_number(stats.get('engines', 0))),
|
|
('Configuraciones', format_number(stats.get('model_year_engine', 0))),
|
|
('Fabricantes', format_number(stats.get('manufacturers', 0))),
|
|
('Categorías', format_number(stats.get('part_categories', 0))),
|
|
('Grupos', format_number(stats.get('part_groups', 0))),
|
|
('Partes OEM', format_number(stats.get('parts', 0))),
|
|
('Aftermarket', format_number(stats.get('aftermarket_parts', 0))),
|
|
('Cross-Refs', format_number(stats.get('part_cross_references', 0))),
|
|
('Diagramas', format_number(stats.get('diagrams', 0))),
|
|
('', '── COBERTURA ─────────────────────────────────────'),
|
|
('Fitments', format_number(stats.get('vehicle_parts', 0))),
|
|
('VINs en caché', format_number(stats.get('vin_cache', 0))),
|
|
]
|
|
|
|
# Add top brands
|
|
top_brands = stats.get('top_brands', [])
|
|
if top_brands:
|
|
brands_str = ' '.join(f"{b['name']}({format_number(b['cnt'])})" for b in top_brands)
|
|
fields.append(('Top marcas', brands_str))
|
|
|
|
renderer.draw_detail(fields, 'ESTADÍSTICAS DEL SISTEMA')
|
|
renderer.draw_footer([('F5', 'Actualizar'), ('ESC', 'Menú')])
|
|
|
|
def on_key(self, key, context, db, renderer, nav):
|
|
if key == Key.ESCAPE:
|
|
return 'back'
|
|
if key == Key.F5:
|
|
return None # Re-render
|
|
return None
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add console/core/app.py console/screens/menu_principal.py console/screens/estadisticas.py
|
|
git commit -m "feat(console): add app controller, main menu and statistics screen"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Vehicle Navigation Screen (Drill-Down)
|
|
|
|
**Files:**
|
|
- Create: `console/screens/vehiculo_nav.py`
|
|
|
|
**Step 1: Implement drill-down navigation**
|
|
|
|
The vehicle navigation screen handles the full drill-down flow: Brand → Model → Year → Engine → Categories → Groups → Parts, each as a filterable list. It uses the renderer's `draw_filter_list` method and maintains state for each drill-down level within its context dict.
|
|
|
|
**Key behaviors:**
|
|
- Typing characters filters the list in real time
|
|
- Arrow keys move selection cursor
|
|
- ENTER advances to next drill-down level
|
|
- ESC goes back one level (or exits to menu if at brand level)
|
|
- After selecting engine, transitions to catalogo screen with the vehicle's mye_id
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add console/screens/vehiculo_nav.py
|
|
git commit -m "feat(console): add vehicle drill-down navigation screen"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Catalog, Search, and VIN Screens
|
|
|
|
**Files:**
|
|
- Create: `console/screens/catalogo.py`
|
|
- Create: `console/screens/buscar_parte.py`
|
|
- Create: `console/screens/buscar_texto.py`
|
|
- Create: `console/screens/vin_decoder.py`
|
|
|
|
**Step 1: Implement catalogo.py**
|
|
|
|
Categories → Groups → Parts drill-down, similar structure to vehiculo_nav but for the parts hierarchy. Uses `draw_filter_list` for categories/groups and `draw_table` for parts list.
|
|
|
|
**Step 2: Implement buscar_parte.py**
|
|
|
|
Single input field (via `show_input`), calls `db.search_part_number()`, displays results in `draw_table`. Selecting a result navigates to `parte_detalle`.
|
|
|
|
**Step 3: Implement buscar_texto.py**
|
|
|
|
Same pattern as buscar_parte but uses `db.search_parts()` (FTS5) with a text query. Paginated results via PgUp/PgDn.
|
|
|
|
**Step 4: Implement vin_decoder.py**
|
|
|
|
Input 17-char VIN, check `db.get_vin_cache()` first, else call `decode_vin_nhtsa()` and cache result. Display decoded info via `draw_detail`. Option to navigate to vehicle parts.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add console/screens/catalogo.py console/screens/buscar_parte.py console/screens/buscar_texto.py console/screens/vin_decoder.py
|
|
git commit -m "feat(console): add catalog, search, and VIN decoder screens"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Part Detail and Comparator Screens
|
|
|
|
**Files:**
|
|
- Create: `console/screens/parte_detalle.py`
|
|
- Create: `console/screens/comparador.py`
|
|
|
|
**Step 1: Implement parte_detalle.py**
|
|
|
|
Top section: `draw_detail` with part fields (OEM number, name, group, category, description, material, weight). Bottom section: `draw_table` with aftermarket alternatives. F4 navigates to cross-references view. F6 shows vehicles that use this part. Number key selects an alternative to compare.
|
|
|
|
**Step 2: Implement comparador.py**
|
|
|
|
Uses `draw_comparison` to show OEM part and selected aftermarket alternatives side by side. Shows quality bars, prices, savings percentages, warranty info.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add console/screens/parte_detalle.py console/screens/comparador.py
|
|
git commit -m "feat(console): add part detail and comparator screens"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Admin CRUD Screens
|
|
|
|
**Files:**
|
|
- Create: `console/screens/admin_partes.py`
|
|
- Create: `console/screens/admin_fabricantes.py`
|
|
- Create: `console/screens/admin_crossref.py`
|
|
- Create: `console/screens/admin_import.py`
|
|
|
|
**Step 1: Implement admin_partes.py**
|
|
|
|
List view with `draw_table` (paginated). F2 or ENTER to edit selected part via `draw_form`. F3 to add new part. F9 saves, ESC cancels. Delete with confirmation dialog.
|
|
|
|
**Step 2: Implement admin_fabricantes.py**
|
|
|
|
Same CRUD pattern as admin_partes but for manufacturers table.
|
|
|
|
**Step 3: Implement admin_crossref.py**
|
|
|
|
Same CRUD pattern for cross-references. F1 on part_id field opens lookup list of parts.
|
|
|
|
**Step 4: Implement admin_import.py**
|
|
|
|
Menu to select data type (categories, groups, parts, manufacturers, aftermarket, crossref, fitment). File path input via `show_input`. Reads CSV and calls appropriate db methods. Shows progress/results.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add console/screens/admin_*.py
|
|
git commit -m "feat(console): add admin CRUD screens for parts, manufacturers, crossref, import"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Textual/Rich Modern Renderer
|
|
|
|
**Files:**
|
|
- Create: `console/renderers/textual_renderer.py`
|
|
|
|
**Step 1: Install textual dependency**
|
|
|
|
```bash
|
|
pip install textual rich
|
|
```
|
|
|
|
**Step 2: Implement textual_renderer.py**
|
|
|
|
Implements the same `BaseRenderer` interface but using textual/rich for a modern TUI experience. Uses Rich tables, panels, and styled text instead of curses box drawing. The same screen logic drives both renderers.
|
|
|
|
Note: textual uses an async event loop, so this renderer wraps textual's App in a synchronous interface matching BaseRenderer. An alternative is a thin adapter that translates `draw_*` calls into Rich console output for simpler integration.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add console/renderers/textual_renderer.py
|
|
git commit -m "feat(console): add textual/rich modern renderer"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Integration Testing and Polish
|
|
|
|
**Files:**
|
|
- Create: `console/tests/test_integration.py`
|
|
- Modify: `console/main.py`
|
|
|
|
**Step 1: Write integration tests**
|
|
|
|
Test that the app can be instantiated, screens registered, and basic navigation works (using a mock renderer that records draw calls instead of painting to screen).
|
|
|
|
**Step 2: Add error handling to main.py**
|
|
|
|
Wrap `app.run()` in try/except to always clean up the terminal on crash. Add `--db` flag to override database path.
|
|
|
|
**Step 3: Test full app manually**
|
|
|
|
```bash
|
|
cd /home/Autopartes && python -m console.main --mode vt220
|
|
cd /home/Autopartes && python -m console.main --mode modern
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add console/
|
|
git commit -m "feat(console): add integration tests and polish error handling"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Documentation and Final Commit
|
|
|
|
**Files:**
|
|
- Create: `console/README.md`
|
|
- Modify: `README.md` (root)
|
|
|
|
**Step 1: Write console README**
|
|
|
|
Usage instructions, keybindings reference, screenshots placeholder, mode descriptions.
|
|
|
|
**Step 2: Update root README**
|
|
|
|
Add section about the console system with quickstart command.
|
|
|
|
**Step 3: Final commit**
|
|
|
|
```bash
|
|
git add console/README.md README.md
|
|
git commit -m "docs: add console system documentation"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Task | Description | Est. Steps |
|
|
|------|-------------|------------|
|
|
| 1 | Project scaffold + config | 4 |
|
|
| 2 | Database abstraction layer | 5 |
|
|
| 3 | Core framework (keys, nav, screens) | 7 |
|
|
| 4 | Utilities (formatting, VIN API) | 6 |
|
|
| 5 | Curses VT220 renderer | 3 |
|
|
| 6 | App controller + main menu + stats | 4 |
|
|
| 7 | Vehicle drill-down navigation | 2 |
|
|
| 8 | Catalog, search, VIN screens | 5 |
|
|
| 9 | Part detail + comparator | 3 |
|
|
| 10 | Admin CRUD screens | 5 |
|
|
| 11 | Textual modern renderer | 3 |
|
|
| 12 | Integration tests + polish | 4 |
|
|
| 13 | Documentation | 3 |
|
|
| **Total** | | **54 steps** |
|
|
|
|
**Dependencies:** Tasks 1-5 must be completed first (foundation). Tasks 6-10 can be done in any order after foundation. Task 11 is independent of 6-10. Tasks 12-13 are final.
|