Fix #13: Expose Pre-edit Backup restoration

Preserve full diff opcodes before grouping, route modal navigation to a two-axis viewport-sized scroller, and calculate side-by-side layout using terminal cell widths so tabbed Hosts File lines keep a stable divider.
This commit is contained in:
Philip Henning 2026-09-04 20:19:17 +02:00
parent 6a3e1e0d5a
commit e268794564
13 changed files with 1103 additions and 19 deletions

View file

@ -1,19 +1,22 @@
"""Tests for application-level authorization flow."""
from contextlib import contextmanager
import asyncio
from io import StringIO
import os
import signal
from unittest.mock import Mock, patch
import pytest
from rich.cells import cell_len
from textual.app import SuspendNotSupported
from textual.widgets import Input, RadioButton, Static
from textual.widgets import Button, Input, RadioButton, Static
from src.hosts.core.filters import FilterOptions
from src.hosts.core.models import HostEntry, HostsFile
from src.hosts.tui.app import HostsManagerApp
from src.hosts.tui.custom_footer import CustomFooter
from src.hosts.tui.backup_restore_modal import BackupRestoreModal
from src.hosts.tui.filter_modal import FilterModal
from src.hosts.tui.help_modal import HelpModal
from src.hosts.tui.privilege_prompt import (
@ -246,6 +249,147 @@ async def test_small_viewport_blocks_hidden_mutation_shortcuts():
app.manager.enter_edit_mode.assert_not_called()
@pytest.mark.asyncio
async def test_restore_opens_a_side_by_side_preview_with_exact_paths(tmp_path):
"""The recovery workflow makes its before-and-after files reviewable."""
app = app_with_filterable_entries()
backup_path = tmp_path / "hosts.backup"
hosts_file_path = tmp_path / "hosts"
backup_path.write_text("127.0.0.1 restored.test\n")
hosts_file_path.write_text("127.0.0.1 current.test\n")
app.edit_mode = True
app.manager = Mock()
app.manager.backup_path = backup_path
app.manager.parser.file_path = hosts_file_path
async with app.run_test(size=(120, 40)) as pilot:
app.action_restore_backup()
await pilot.pause()
await pilot.pause()
assert isinstance(app.screen, BackupRestoreModal)
assert str(backup_path) in str(
app.screen.query_one("#backup-restored-path", Static).render()
)
assert str(hosts_file_path) in str(
app.screen.query_one("#backup-current-path", Static).render()
)
diff = str(app.screen.query_one("#backup-diff", Static).render())
assert "current.test" in diff
assert "restored.test" in diff
@pytest.mark.asyncio
async def test_restore_side_by_side_divider_uses_terminal_cell_widths(tmp_path):
"""Tabs in Hosts File lines cannot move the right-hand file between rows."""
app = app_with_filterable_entries()
backup_path = tmp_path / "hosts.backup"
hosts_file_path = tmp_path / "hosts"
hosts_file_path.write_text(
"127.0.0.1\tlocalhost\n"
"255.255.255.255\tbroadcasthost\n"
"::1\tlocalhost\n"
"192.0.2.10\tcurrent.test\n"
)
backup_path.write_text(
"127.0.0.1\tlocalhost\n"
"255.255.255.255\tbroadcasthost\n"
"::1\tlocalhost\n"
"192.0.2.10\trestored.test\n"
)
app.edit_mode = True
app.manager = Mock()
app.manager.backup_path = backup_path
app.manager.parser.file_path = hosts_file_path
async with app.run_test(size=(160, 40)) as pilot:
app.action_restore_backup()
await pilot.pause()
await pilot.pause()
modal = app.screen
assert isinstance(modal, BackupRestoreModal)
lines = [
line for line in modal._render_diff().plain.splitlines() if "" in line
]
divider_columns = {
cell_len(line[: line.index("")].expandtabs(8)) for line in lines
}
assert "\t" not in "\n".join(lines)
assert len(divider_columns) == 1
@pytest.mark.asyncio
async def test_restore_preview_disables_a_no_change_operation(tmp_path):
"""A no-op restore cannot perform an unnecessary privileged write."""
app = app_with_filterable_entries()
backup_path = tmp_path / "hosts.backup"
hosts_file_path = tmp_path / "hosts"
contents = "127.0.0.1 localhost\n"
backup_path.write_text(contents)
hosts_file_path.write_text(contents)
app.edit_mode = True
app.manager = Mock()
app.manager.backup_path = backup_path
app.manager.parser.file_path = hosts_file_path
async with app.run_test(size=(120, 40)) as pilot:
app.action_restore_backup()
await pilot.pause()
await pilot.pause()
modal = app.screen
assert isinstance(modal, BackupRestoreModal)
assert modal.query_one("#restore-button", Button).disabled
assert "would make no changes" in str(
modal.query_one("#backup-restore-status", Static).render()
)
@pytest.mark.asyncio
async def test_restore_preview_fills_width_and_routes_navigation_from_cancel(tmp_path):
"""The safe initial focus does not prevent full mode or two-axis scrolling."""
app = app_with_filterable_entries()
backup_path = tmp_path / "hosts.backup"
hosts_file_path = tmp_path / "hosts"
current_lines = [
f"192.0.2.{index} host-{index}.test " + "x" * 120 for index in range(1, 81)
]
restored_lines = current_lines.copy()
restored_lines[39] = "192.0.2.40 restored.test " + "y" * 120
hosts_file_path.write_text("\n".join(current_lines) + "\n")
backup_path.write_text("\n".join(restored_lines) + "\n")
app.edit_mode = True
app.manager = Mock()
app.manager.backup_path = backup_path
app.manager.parser.file_path = hosts_file_path
async with app.run_test(size=(160, 40)) as pilot:
app.action_restore_backup()
await pilot.pause()
await pilot.pause()
modal = app.screen
assert isinstance(modal, BackupRestoreModal)
container = modal.query_one("#backup-restore-container")
assert container.outer_size.width == 156
changed_diff = str(modal.query_one("#backup-diff", Static).render())
await pilot.press("f")
await pilot.pause()
full_diff = str(modal.query_one("#backup-diff", Static).render())
assert full_diff != changed_diff
assert "host-1.test" in full_diff
scroller = modal.query_one("#backup-diff-scroll")
assert app.focused is modal.query_one("#cancel-button")
await pilot.press("down", "right")
await pilot.pause()
assert scroller.scroll_y > 0
assert scroller.scroll_x > 0
class TestPrivilegedModeAuthorization:
"""Test the user-visible privileged-mode authorization flow."""
@ -474,3 +618,80 @@ class TestPrivilegedModeAuthorization:
assert not app.manager.permission_manager._sudo_validated
mock_run.assert_not_called()
app.update_status.assert_called_once_with("Edit mode disabled")
class TestPreEditBackupRestoration:
"""Test the user-reachable Pre-edit Backup recovery flow."""
def test_restore_action_confirms_paths_then_reloads_after_success(self, tmp_path):
app = HostsManagerApp()
app.edit_mode = True
app.manager = Mock()
backup_path = tmp_path / "hosts.backup.123"
hosts_file_path = tmp_path / "hosts"
backup_path.write_text("127.0.0.1 restored.test\n")
hosts_file_path.write_text("127.0.0.1 current.test\n")
app.manager.backup_path = backup_path
app.manager.parser.file_path = hosts_file_path
app.manager.restore_backup.return_value = (True, "Backup restored successfully")
app.push_screen = Mock()
app.load_hosts_file = Mock()
app.update_status = Mock()
app.run_worker = Mock(side_effect=lambda coroutine, **_: asyncio.run(coroutine))
app.action_restore_backup()
modal, callback = app.push_screen.call_args.args
assert modal.preview.backup_path == backup_path
assert modal.preview.hosts_file_path == hosts_file_path
callback(True)
app.manager.restore_backup.assert_called_once_with(
modal.preview.backup_digest, modal.preview.hosts_file_digest
)
app.load_hosts_file.assert_called_once_with()
assert app.update_status.call_args.args == (
f"Pre-edit Backup restored from {backup_path} to {hosts_file_path}",
)
def test_failed_restore_keeps_interface_state_and_reports_paths(self, tmp_path):
app = HostsManagerApp()
app.edit_mode = True
app.manager = Mock()
backup_path = tmp_path / "hosts.backup.123"
hosts_file_path = tmp_path / "hosts"
backup_path.write_text("127.0.0.1 restored.test\n")
hosts_file_path.write_text("127.0.0.1 current.test\n")
app.manager.backup_path = backup_path
app.manager.parser.file_path = hosts_file_path
app.manager.restore_backup.return_value = (False, "Permission denied")
app.push_screen = Mock()
app.load_hosts_file = Mock()
app.update_status = Mock()
app.run_worker = Mock(side_effect=lambda coroutine, **_: asyncio.run(coroutine))
app.action_restore_backup()
_, callback = app.push_screen.call_args.args
callback(True)
app.load_hosts_file.assert_not_called()
assert app.update_status.call_args.args == (
f"Failed to restore Pre-edit Backup from {backup_path} to {hosts_file_path}: Permission denied. "
"The Hosts File and interface are unchanged. Check write access and try again.",
)
def test_restore_is_unavailable_while_the_entry_editor_has_unsaved_state(self):
app = HostsManagerApp()
app.entry_edit_mode = True
app.manager = Mock()
app.push_screen = Mock()
app.update_status = Mock()
app.action_restore_backup()
app.push_screen.assert_not_called()
app.manager.restore_backup.assert_not_called()
app.update_status.assert_called_once_with(
"Finish or cancel the Entry Editor before restoring the Pre-edit Backup."
)