224 lines
7.1 KiB
Python
224 lines
7.1 KiB
Python
# [DEF:backend.tests.scripts.test_clean_release_tui:Module]
|
|
# @TIER: STANDARD
|
|
# @SEMANTICS: tests, tui, clean-release, curses
|
|
# @PURPOSE: Unit tests for the interactive curses TUI of the clean release process.
|
|
# @LAYER: Scripts
|
|
# @RELATION: TESTS -> backend.src.scripts.clean_release_tui
|
|
# @INVARIANT: TUI initializes, handles hotkeys (F5, F10) and safely falls back without TTY.
|
|
|
|
import os
|
|
import sys
|
|
import curses
|
|
import json
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.scripts.clean_release_tui import CleanReleaseTUI, main, tui_main
|
|
from src.models.clean_release import CheckFinalStatus
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_stdscr() -> MagicMock:
|
|
stdscr = MagicMock()
|
|
stdscr.getmaxyx.return_value = (40, 100)
|
|
stdscr.getch.return_value = -1
|
|
return stdscr
|
|
|
|
|
|
def test_headless_fallback(capsys):
|
|
"""
|
|
@TEST_EDGE: stdout_unavailable
|
|
Tests that non-TTY startup is explicitly refused and wrapper is not invoked.
|
|
"""
|
|
with mock.patch("src.scripts.clean_release_tui.curses.wrapper") as curses_wrapper_mock:
|
|
with mock.patch("sys.stdout.isatty", return_value=False):
|
|
exit_code = main()
|
|
|
|
curses_wrapper_mock.assert_not_called()
|
|
|
|
assert exit_code == 2
|
|
captured = capsys.readouterr()
|
|
assert "TTY is required for TUI mode" in captured.err
|
|
assert "Use CLI/API workflow instead" in captured.err
|
|
|
|
|
|
@patch("src.scripts.clean_release_tui.curses")
|
|
def test_tui_initial_render(mock_curses_module, mock_stdscr: MagicMock):
|
|
"""
|
|
Simulates the initial rendering cycle of the TUI application to ensure
|
|
titles, headers, footers and the READY state are drawn appropriately.
|
|
"""
|
|
# Ensure constants match
|
|
mock_curses_module.KEY_F10 = curses.KEY_F10
|
|
mock_curses_module.KEY_F5 = curses.KEY_F5
|
|
mock_curses_module.color_pair.side_effect = lambda x: x
|
|
mock_curses_module.A_BOLD = 0
|
|
|
|
app = CleanReleaseTUI(mock_stdscr)
|
|
assert app.status == "READY"
|
|
|
|
# We only want to run one loop iteration, so we mock getch to return F10
|
|
mock_stdscr.getch.return_value = curses.KEY_F10
|
|
|
|
app.loop()
|
|
|
|
# Assert header was drawn
|
|
addstr_calls = mock_stdscr.addstr.call_args_list
|
|
assert any("Enterprise Clean Release Validator" in str(call) for call in addstr_calls)
|
|
assert any("Candidate: [2026.03.03-rc1]" in str(call) for call in addstr_calls)
|
|
|
|
# Assert checks list is shown
|
|
assert any("Data Purity" in str(call) for call in addstr_calls)
|
|
assert any("Internal Sources Only" in str(call) for call in addstr_calls)
|
|
|
|
# Assert footer is shown
|
|
assert any("F5 Run" in str(call) for call in addstr_calls)
|
|
|
|
|
|
@patch("src.scripts.clean_release_tui.curses")
|
|
def test_tui_run_checks_f5(mock_curses_module, mock_stdscr: MagicMock):
|
|
"""
|
|
Simulates pressing F5 to transition into the RUNNING checks flow.
|
|
"""
|
|
# Ensure constants match
|
|
mock_curses_module.KEY_F10 = curses.KEY_F10
|
|
mock_curses_module.KEY_F5 = curses.KEY_F5
|
|
mock_curses_module.color_pair.side_effect = lambda x: x
|
|
mock_curses_module.A_BOLD = 0
|
|
|
|
app = CleanReleaseTUI(mock_stdscr)
|
|
|
|
# getch sequence:
|
|
# 1. First loop: F5 (triggers run_checks)
|
|
# 2. Next call after run_checks: F10 to exit
|
|
mock_stdscr.f5_pressed = False
|
|
def side_effect():
|
|
if not mock_stdscr.f5_pressed:
|
|
mock_stdscr.f5_pressed = True
|
|
return curses.KEY_F5
|
|
return curses.KEY_F10
|
|
|
|
mock_stdscr.getch.side_effect = side_effect
|
|
|
|
with mock.patch("time.sleep", return_value=None):
|
|
app.loop()
|
|
|
|
# After F5 is pressed, status should be BLOCKED due to deliberate 'test-data' violation
|
|
assert app.status == CheckFinalStatus.BLOCKED
|
|
assert app.report_id is not None
|
|
assert "CCR-" in app.report_id
|
|
assert len(app.violations_list) > 0
|
|
|
|
|
|
@patch("src.scripts.clean_release_tui.curses")
|
|
def test_tui_exit_f10(mock_curses_module, mock_stdscr: MagicMock):
|
|
"""
|
|
Simulates pressing F10 to exit the application immediately without running checks.
|
|
"""
|
|
# Ensure constants match
|
|
mock_curses_module.KEY_F10 = curses.KEY_F10
|
|
|
|
app = CleanReleaseTUI(mock_stdscr)
|
|
mock_stdscr.getch.return_value = curses.KEY_F10
|
|
|
|
# loop() should return cleanly
|
|
app.loop()
|
|
|
|
assert app.status == "READY"
|
|
|
|
|
|
@patch("src.scripts.clean_release_tui.curses")
|
|
def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock):
|
|
"""
|
|
Simulates pressing F7 to clear history.
|
|
"""
|
|
mock_curses_module.KEY_F10 = curses.KEY_F10
|
|
mock_curses_module.KEY_F7 = curses.KEY_F7
|
|
mock_curses_module.color_pair.side_effect = lambda x: x
|
|
mock_curses_module.A_BOLD = 0
|
|
|
|
app = CleanReleaseTUI(mock_stdscr)
|
|
app.status = CheckFinalStatus.BLOCKED
|
|
app.report_id = "SOME-REPORT"
|
|
|
|
# F7 then F10
|
|
mock_stdscr.getch.side_effect = [curses.KEY_F7, curses.KEY_F10]
|
|
|
|
app.loop()
|
|
|
|
assert app.status == "READY"
|
|
assert app.report_id is None
|
|
assert len(app.checks_progress) == 0
|
|
|
|
|
|
@patch("src.scripts.clean_release_tui.curses")
|
|
def test_tui_real_mode_bootstrap_imports_artifacts_catalog(
|
|
mock_curses_module,
|
|
mock_stdscr: MagicMock,
|
|
tmp_path,
|
|
):
|
|
"""
|
|
@TEST_CONTRACT: bootstrap.json + artifacts.json -> candidate PREPARED with imported artifacts
|
|
"""
|
|
mock_curses_module.KEY_F10 = curses.KEY_F10
|
|
mock_curses_module.color_pair.side_effect = lambda x: x
|
|
mock_curses_module.A_BOLD = 0
|
|
|
|
bootstrap_path = tmp_path / "bootstrap.json"
|
|
artifacts_path = tmp_path / "artifacts.json"
|
|
bootstrap_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"candidate_id": "real-candidate-1",
|
|
"version": "1.0.0",
|
|
"source_snapshot_ref": "git:release/1",
|
|
"created_by": "operator",
|
|
"allowed_hosts": ["repo.intra.company.local"],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
artifacts_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"artifacts": [
|
|
{
|
|
"id": "artifact-1",
|
|
"path": "backend/dist/package.tar.gz",
|
|
"sha256": "deadbeef",
|
|
"size": 1024,
|
|
"category": "core",
|
|
"source_uri": "https://repo.intra.company.local/releases/package.tar.gz",
|
|
"source_host": "repo.intra.company.local",
|
|
}
|
|
]
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLEAN_TUI_MODE": "real",
|
|
"CLEAN_TUI_BOOTSTRAP_JSON": str(bootstrap_path),
|
|
"CLEAN_TUI_ARTIFACTS_JSON": str(artifacts_path),
|
|
},
|
|
clear=False,
|
|
):
|
|
app = CleanReleaseTUI(mock_stdscr)
|
|
|
|
candidate = app.repo.get_candidate("real-candidate-1")
|
|
artifacts = app.repo.get_artifacts_by_candidate("real-candidate-1")
|
|
|
|
assert candidate is not None
|
|
assert candidate.status == "PREPARED"
|
|
assert len(artifacts) == 1
|
|
assert artifacts[0].path == "backend/dist/package.tar.gz"
|
|
assert artifacts[0].detected_category == "core"
|
|
|
|
|
|
# [/DEF:backend.tests.scripts.test_clean_release_tui:Module]
|