240 lines
8.8 KiB
Python
240 lines
8.8 KiB
Python
"""User-visible import and export workflows."""
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from textual.widgets import Button, DirectoryTree, Input, RadioButton, 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_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 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("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_can_move_to_its_parent_directory(tmp_path):
|
|
"""The chooser exposes an explicit route back up the directory tree."""
|
|
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)
|
|
|
|
browser.action_go_up()
|
|
await pilot.pause()
|
|
|
|
assert browser.query_one("#file-browser-tree", DirectoryTree).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()
|
|
)
|