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:
@@ -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__":
|
||||
|
||||
277
console/tests/test_integration.py
Normal file
277
console/tests/test_integration.py
Normal 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
|
||||
Reference in New Issue
Block a user