hosts/tests/test_import_export_workflow.py

277 lines
10 KiB
Python

"""User-visible import and export workflows."""
from unittest.mock import Mock
import pytest
from rich.style import Style
from textual.containers import Vertical
from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static
from hosts.core.import_export import (
ExportFormat,
ExportResult,
ImportFormat,
ImportResult,
)
from hosts.core.models import HostEntry, HostsFile
from hosts.tui.app import HostsManagerApp
from hosts.tui.import_export_modal import ExportModal, FileBrowserModal, ImportModal
@pytest.mark.asyncio
async def test_export_shortcut_opens_a_format_chooser_and_reports_validation():
"""Export presents every core format and keeps invalid input visible."""
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])]
)
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+x")
await pilot.pause()
assert isinstance(app.screen, ExportModal)
assert [button.label.plain for button in app.screen.query(RadioButton)] == [
"Hosts file (.hosts)",
"JSON (.json)",
"CSV (.csv)",
]
app.screen.query_one("#path-input", Input).value = ""
await pilot.click("#continue-button")
assert "Choose a file path" in str(
app.screen.query_one("#workflow-error", Static).render()
)
@pytest.mark.asyncio
async def test_export_modal_is_centered_in_the_terminal_viewport():
"""The export form is centered rather than pinned to a terminal corner."""
app = HostsManagerApp()
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+x")
await pilot.pause()
container = app.screen.query_one("#format-container")
assert container.region.x == (app.size.width - container.region.width) // 2
assert container.region.y == (app.size.height - container.region.height) // 2
@pytest.mark.asyncio
async def test_format_modal_uses_compact_consistent_form_sections():
"""Location and format fields use matching borders without excess rows."""
app = HostsManagerApp()
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+x")
await pilot.pause()
location = app.screen.query_one(".path-section", Vertical)
formats = app.screen.query_one(".format-section", Vertical)
path_input = app.screen.query_one("#path-input", Input)
browse = app.screen.query_one("#browse-button", Button)
format_select = app.screen.query_one("#format-select", RadioSet)
container = app.screen.query_one("#format-container")
assert location.border_title == "Export file path"
assert path_input.region.height == 1
assert browse.region.height == 1
assert format_select.region.height == 3
assert formats.region.height == 5
assert container.region.height <= 22
@pytest.mark.asyncio
async def test_export_browser_lets_the_user_choose_a_destination_directory():
"""Browse opens a centered directory tree rather than requiring a typed path."""
app = HostsManagerApp()
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+x")
await pilot.pause()
browse = app.screen.query_one("#browse-button", Button)
path_input = app.screen.query_one("#path-input", Input)
container = app.screen.query_one("#format-container")
assert browse.region.width > 0
assert path_input.region.height == 1
assert browse.region.height == 1
assert browse.region.x >= path_input.region.right
assert browse.region.y == path_input.region.y
assert browse.region.right <= container.region.right
assert browse.region.bottom <= container.region.bottom
await pilot.press("tab")
await pilot.press("enter")
await pilot.pause()
await pilot.pause()
assert isinstance(app.screen, FileBrowserModal)
assert app.screen.query_one("#file-browser-tree") is not None
container = app.screen.query_one("#file-browser-container")
assert container.region.x == (app.size.width - container.region.width) // 2
await pilot.press("tab")
await pilot.press("tab")
await pilot.press("enter")
await pilot.pause()
await pilot.pause()
assert isinstance(app.screen, ExportModal)
assert app.screen.query_one("#path-input", Input).value.endswith(
"hosts-export.hosts"
)
@pytest.mark.asyncio
async def test_file_browser_presents_parent_directory_as_dot_dot(tmp_path):
"""The chooser exposes its parent as the first conventional tree entry."""
child = tmp_path / "child"
child.mkdir()
app = HostsManagerApp()
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
app.push_screen(FileBrowserModal(child, choose_directory=True))
await pilot.pause()
browser = app.screen
assert isinstance(browser, FileBrowserModal)
tree = browser.query_one("#file-browser-tree", DirectoryTree)
await pilot.pause()
assert str(tree.root.children[0].label) == ".."
assert tree.render_label(
tree.root.children[0], Style(), Style()
).plain.startswith("")
assert len(browser.query("#up-button")) == 0
tree.focus()
tree.select_node(tree.root.children[0])
await pilot.pause()
assert tree.path == tmp_path
@pytest.mark.asyncio
async def test_export_requires_overwrite_confirmation_before_calling_service(tmp_path):
"""An existing output file is not replaced until the user confirms it."""
target = tmp_path / "hosts.json"
target.write_text("existing", encoding="utf-8")
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])]
)
app.import_export_service = Mock()
app.import_export_service.get_supported_export_formats.return_value = list(
ExportFormat
)
app.import_export_service.validate_export_path.return_value = [
f"File {target} already exists and will be overwritten"
]
app.import_export_service.export_json_format.return_value = ExportResult(
success=True,
file_path=target,
entries_exported=1,
errors=[],
format=ExportFormat.JSON,
)
async with app.run_test(size=(120, 40)) as pilot:
app.action_export_entries()
await pilot.pause()
app.screen.query_one("#path-input", Input).value = str(target)
app.screen.query_one("#format-json", RadioButton).value = True
await pilot.click("#continue-button")
assert "already exists" in str(
app.screen.query_one("#workflow-warning", Static).render()
)
app.import_export_service.export_json_format.assert_not_called()
await pilot.click("#confirm-button")
await pilot.pause()
await pilot.pause()
app.import_export_service.export_json_format.assert_called_once_with(
app.hosts_file, target
)
@pytest.mark.asyncio
async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_errors():
"""Import cannot mutate in Read-only Mode and reports parser failures."""
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])]
)
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+o")
await pilot.pause()
assert not isinstance(app.screen, ImportModal)
assert "Read-only Mode" in str(app.query_one("#message-rail", Static).render())
app.edit_mode = True
app.manager.edit_mode = True
app.import_export_service = Mock()
app.import_export_service.get_supported_import_formats.return_value = list(
ImportFormat
)
app.import_export_service.import_csv_format.return_value = Mock(
success=False, errors=["CSV column 'ip_address' is missing"]
)
app.action_import_entries()
await pilot.pause()
assert isinstance(app.screen, ImportModal)
app.screen.query_one("#path-input", Input).value = "/tmp/broken.csv"
csv_button = app.screen.query_one("#format-csv", RadioButton)
csv_button.value = True
await pilot.click("#import-button")
await pilot.pause()
assert "CSV column 'ip_address' is missing" in str(
app.screen.query_one("#workflow-error", Static).render()
)
@pytest.mark.asyncio
async def test_successful_import_replaces_entries_and_saves_through_the_manager():
"""A valid import reaches the persistence seam without a real Hosts File."""
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])]
)
app.edit_mode = True
app.manager.edit_mode = True
app.save_mutation = Mock(return_value=True)
app.import_export_service = Mock()
app.import_export_service.get_supported_import_formats.return_value = list(
ImportFormat
)
imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"])
app.import_export_service.import_json_format.return_value = ImportResult(
success=True,
entries=[imported],
errors=[],
warnings=[],
total_processed=1,
successfully_imported=1,
)
async with app.run_test(size=(120, 40)) as pilot:
app.action_import_entries()
await pilot.pause()
app.screen.query_one("#path-input", Input).value = "/tmp/entries.json"
app.screen.query_one("#format-json", RadioButton).value = True
await pilot.click("#import-button")
await pilot.pause()
await pilot.pause()
assert [entry.hostnames for entry in app.hosts_file.entries] == [
["imported.test"]
]
app.save_mutation.assert_called_once()
assert "Imported and saved 1 Host Entries" in str(
app.query_one("#message-rail", Static).render()
)