feat(console): add integration tests and polish error handling

Add MockRenderer-based integration tests that verify the full screen-to-
renderer pipeline without a real terminal. Update main.py with proper
--db flag handling, database existence check, startup banner, and
graceful error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:59:08 +00:00
parent 7bf50a2c67
commit 64503ca363
2 changed files with 336 additions and 5 deletions

View File

@@ -8,6 +8,7 @@ Usage:
"""
import argparse
import os
import sys
from console.config import VERSION, APP_NAME, APP_SUBTITLE, DB_PATH, DEFAULT_MODE
@@ -38,6 +39,19 @@ def parse_args(argv=None):
return parser.parse_args(argv)
def _print_banner(mode, db_path):
"""Print a startup banner before entering terminal mode."""
border = "=" * 58
print(border)
print(f" {APP_NAME} v{VERSION}")
print(f" {APP_SUBTITLE}")
print(border)
print(f" Mode : {mode}")
print(f" DB : {db_path}")
print(border)
print()
def main(argv=None):
"""Main entry point: parse args, set up renderer, DB, and launch the app."""
args = parse_args(argv)
@@ -45,20 +59,60 @@ def main(argv=None):
db_path = args.db
mode = args.mode
# Verify the database file exists before proceeding
if not os.path.isfile(db_path):
print(
f"Error: Database not found at '{db_path}'.\n"
f"\n"
f"Make sure the vehicle database exists. You can specify a\n"
f"custom path with the --db flag:\n"
f"\n"
f" python -m console --db /path/to/vehicle_database.db\n",
file=sys.stderr,
)
sys.exit(1)
# Lazy imports so the module can be loaded without curses available
# (e.g. during tests or when just checking --version).
from console.core.database import Database
from console.renderers import create_renderer
from console.db import Database
from console.renderers.curses_renderer import CursesRenderer
from console.core.app import App
# Print startup banner
_print_banner(mode, db_path)
db = Database(db_path)
renderer = create_renderer(mode)
# Select renderer based on mode
if mode == "modern":
try:
from console.renderers.textual_renderer import TextualRenderer
renderer = TextualRenderer()
except ImportError:
print(
"Warning: 'modern' mode requires the 'rich' package.\n"
"Falling back to vt220 mode.\n"
"Install with: pip install rich\n",
file=sys.stderr,
)
renderer = CursesRenderer()
else:
renderer = CursesRenderer()
app = App(renderer=renderer, db=db)
try:
app.run()
finally:
db.close()
except KeyboardInterrupt:
pass
except Exception as e:
# Ensure terminal is restored before printing the traceback
try:
renderer.cleanup()
except Exception:
pass
print(f"\nError: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":

View File

@@ -0,0 +1,277 @@
"""
Integration tests for the AUTOPARTES console application.
Uses a MockRenderer that records draw calls instead of painting to a real
terminal, allowing end-to-end testing of the screen -> renderer pipeline
without curses.
"""
import pytest
from console.renderers.base import BaseRenderer
from console.core.navigation import Navigation
from console.db import Database
# =========================================================================
# MockRenderer
# =========================================================================
class MockRenderer(BaseRenderer):
"""A renderer that records all draw calls for later assertion.
Pre-load ``self.keys`` with a sequence of key codes; ``get_key()``
pops from the front and returns ESC (27) when the list is exhausted.
"""
def __init__(self):
self.calls = [] # list of (method_name, args, kwargs)
self.keys = [] # pre-loaded key presses
self._size = (24, 80) # rows, cols
# -- Lifecycle ----------------------------------------------------------
def init_screen(self):
self.calls.append(('init_screen', (), {}))
def cleanup(self):
self.calls.append(('cleanup', (), {}))
# -- Screen queries -----------------------------------------------------
def get_size(self):
return self._size
# -- Primitive operations -----------------------------------------------
def clear(self):
self.calls.append(('clear', (), {}))
def refresh(self):
self.calls.append(('refresh', (), {}))
def get_key(self):
if self.keys:
return self.keys.pop(0)
return 27 # ESC to exit
# -- High-level widgets -------------------------------------------------
def draw_header(self, title, subtitle=''):
self.calls.append(('draw_header', (title, subtitle), {}))
def draw_footer(self, key_labels):
self.calls.append(('draw_footer', (key_labels,), {}))
def draw_menu(self, items, selected_index=0, title=''):
self.calls.append(('draw_menu', (items, selected_index), {}))
def draw_table(self, headers, rows, widths, page_info=None,
selected_row=-1):
self.calls.append(('draw_table', (headers, rows, widths), {}))
def draw_detail(self, fields, title=''):
self.calls.append(('draw_detail', (fields,), {}))
def draw_form(self, fields, focused_index=0, title=''):
self.calls.append(('draw_form', (fields, focused_index), {}))
def draw_filter_list(self, items, filter_text, selected_index, title=''):
self.calls.append(('draw_filter_list', (items, filter_text, selected_index), {}))
def draw_comparison(self, columns, title=''):
self.calls.append(('draw_comparison', (columns,), {}))
# -- Low-level drawing --------------------------------------------------
def draw_text(self, row, col, text, style='normal'):
self.calls.append(('draw_text', (row, col, text, style), {}))
def draw_box(self, top, left, height, width, title=''):
self.calls.append(('draw_box', (top, left, height, width), {}))
# -- Dialogs ------------------------------------------------------------
def show_message(self, text, msg_type='info'):
self.calls.append(('show_message', (text, msg_type), {}))
if msg_type == 'confirm':
return True # auto-confirm
return True
def show_input(self, prompt, max_len=40):
self.calls.append(('show_input', (prompt, max_len), {}))
return None # cancel by default
# -- Helpers for assertions ---------------------------------------------
def method_names(self):
"""Return a list of just the method names from recorded calls."""
return [name for name, _args, _kwargs in self.calls]
def calls_for(self, method_name):
"""Return only the calls matching *method_name*."""
return [(args, kwargs) for name, args, kwargs in self.calls
if name == method_name]
# =========================================================================
# Fixtures
# =========================================================================
@pytest.fixture
def mock_renderer():
"""Provide a fresh MockRenderer for each test."""
return MockRenderer()
@pytest.fixture(scope="module")
def db():
"""Provide a shared Database instance for integration tests."""
return Database()
# =========================================================================
# Test 1: App creates with screens
# =========================================================================
class TestAppCreatesWithScreens:
def test_app_creates_with_screens(self, mock_renderer, db):
"""App should register at least 'menu' and 'estadisticas' screens."""
from console.core.app import App
app = App(renderer=mock_renderer, db=db)
assert 'menu' in app.screens
assert 'estadisticas' in app.screens
assert len(app.screens) >= 2
# =========================================================================
# Test 2: App runs and quits
# =========================================================================
class TestAppRunsAndQuits:
def test_app_runs_and_quits(self, mock_renderer, db):
"""Pre-load ESC + confirm-yes (ord('s')). App should exit cleanly."""
from console.core.app import App
# ESC triggers quit dialog, show_message auto-confirms True
mock_renderer.keys = [27] # ESC
app = App(renderer=mock_renderer, db=db)
app.run() # should not raise
# Verify init_screen and cleanup were both called
names = mock_renderer.method_names()
assert 'init_screen' in names
assert 'cleanup' in names
# =========================================================================
# Test 3: Menu renders
# =========================================================================
class TestMenuRenders:
def test_menu_renders(self, mock_renderer, db):
"""MenuPrincipalScreen.render() should call draw_header and draw_menu."""
from console.screens.menu_principal import MenuPrincipalScreen
screen = MenuPrincipalScreen()
screen.render(context={}, db=db, renderer=mock_renderer)
names = mock_renderer.method_names()
assert 'draw_header' in names
assert 'draw_menu' in names
# =========================================================================
# Test 4: Estadisticas renders
# =========================================================================
class TestEstadisticasRenders:
def test_estadisticas_renders(self, mock_renderer, db):
"""EstadisticasScreen.render() should call draw_header and draw_text."""
from console.screens.estadisticas import EstadisticasScreen
screen = EstadisticasScreen()
screen.render(context={}, db=db, renderer=mock_renderer)
names = mock_renderer.method_names()
assert 'draw_header' in names
# EstadisticasScreen uses draw_text for its detail fields
assert 'draw_text' in names
# =========================================================================
# Test 5: Navigation integration
# =========================================================================
class TestNavigationIntegration:
def test_navigation_push_pop_breadcrumb(self):
"""Push menu, push estadisticas, verify breadcrumb, pop, verify current."""
nav = Navigation()
nav.push('menu', {}, label='Menu')
nav.push('estadisticas', {}, label='Estadisticas')
# Breadcrumb should show both
assert nav.breadcrumb() == ['Menu', 'Estadisticas']
assert nav.depth() == 2
# Current should be estadisticas
current = nav.current()
assert current is not None
assert current[0] == 'estadisticas'
# Pop estadisticas
popped = nav.pop()
assert popped[0] == 'estadisticas'
# Now current should be menu
current = nav.current()
assert current is not None
assert current[0] == 'menu'
assert nav.breadcrumb() == ['Menu']
# =========================================================================
# Test 6: All screens instantiate
# =========================================================================
class TestAllScreensInstantiate:
"""Import and instantiate all 13 screen classes, verifying each has
name and title attributes."""
# (module_path, class_name)
_SCREEN_CLASSES = [
("console.screens.menu_principal", "MenuPrincipalScreen"),
("console.screens.estadisticas", "EstadisticasScreen"),
("console.screens.vehiculo_nav", "VehiculoNavScreen"),
("console.screens.buscar_parte", "BuscarParteScreen"),
("console.screens.buscar_texto", "BuscarTextoScreen"),
("console.screens.vin_decoder", "VinDecoderScreen"),
("console.screens.catalogo", "CatalogoScreen"),
("console.screens.parte_detalle", "ParteDetalleScreen"),
("console.screens.comparador", "ComparadorScreen"),
("console.screens.admin_partes", "AdminPartesScreen"),
("console.screens.admin_fabricantes", "AdminFabricantesScreen"),
("console.screens.admin_crossref", "AdminCrossrefScreen"),
("console.screens.admin_import", "AdminImportScreen"),
]
def test_all_13_screens_exist(self):
"""All 13 screen modules should be importable."""
assert len(self._SCREEN_CLASSES) == 13
@pytest.mark.parametrize("module_path,class_name", _SCREEN_CLASSES)
def test_screen_instantiates(self, module_path, class_name):
"""Each screen class should instantiate and have name + title."""
import importlib
mod = importlib.import_module(module_path)
cls = getattr(mod, class_name)
instance = cls()
assert hasattr(instance, 'name')
assert hasattr(instance, 'title')
assert isinstance(instance.name, str)
assert isinstance(instance.title, str)
assert len(instance.name) > 0
assert len(instance.title) > 0