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:
parent
6a3e1e0d5a
commit
e268794564
13 changed files with 1103 additions and 19 deletions
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue