diff --git a/console/main.py b/console/main.py index 72efeb0..38bf191 100644 --- a/console/main.py +++ b/console/main.py @@ -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__": diff --git a/console/tests/test_integration.py b/console/tests/test_integration.py new file mode 100644 index 0000000..51206c0 --- /dev/null +++ b/console/tests/test_integration.py @@ -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