|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import re |
| 4 | +from dataclasses import dataclass |
| 5 | +from typing import Any, cast |
| 6 | + |
| 7 | +from fastapi.testclient import TestClient |
| 8 | + |
| 9 | +from adapter_api import create_app |
| 10 | +from core_config import Config |
| 11 | +from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases |
| 12 | + |
| 13 | + |
| 14 | +@dataclass |
| 15 | +class _AsyncValue: |
| 16 | + value: Any |
| 17 | + |
| 18 | + async def execute(self, *_args, **_kwargs) -> Any: |
| 19 | + return self.value |
| 20 | + |
| 21 | + |
| 22 | +def _build_runtime(config: Config) -> AppRuntime: |
| 23 | + runtime_status = { |
| 24 | + "ollama_version": "0.6.0", |
| 25 | + "local_models": {"qwen3.5:2b-q4_K_M": {}}, |
| 26 | + "reachable": True, |
| 27 | + "database_path": config.database_path, |
| 28 | + "storage_dir": config.storage_dir, |
| 29 | + "models": list(config.model_priority), |
| 30 | + } |
| 31 | + return AppRuntime( |
| 32 | + config=config, |
| 33 | + use_cases=AppUseCases( |
| 34 | + get_runtime_status=_AsyncValue(runtime_status), |
| 35 | + bootstrap_runtime=_AsyncValue({"models": ["qwen3.5:2b-q4_K_M"]}), |
| 36 | + ingest_feed=cast(Any, _AsyncValue(None)), |
| 37 | + process_source=cast(Any, _AsyncValue(None)), |
| 38 | + render_rss_feed=_AsyncValue("<rss></rss>"), |
| 39 | + ), |
| 40 | + close=lambda: None, |
| 41 | + ) |
| 42 | + |
| 43 | + |
| 44 | +client = TestClient(create_app(_build_runtime(Config()))) |
| 45 | + |
| 46 | + |
| 47 | +def _is_javascript_content_type(content_type: str) -> bool: |
| 48 | + return content_type.startswith(("text/javascript", "application/javascript")) |
| 49 | + |
| 50 | + |
| 51 | +def class_tokens_for_id(html: str, element_id: str) -> set[str]: |
| 52 | + tag_match = re.search(rf'<[^>]*\bid="{re.escape(element_id)}"[^>]*>', html) |
| 53 | + assert tag_match is not None |
| 54 | + |
| 55 | + class_match = re.search(r'\bclass="([^"]+)"', tag_match.group(0)) |
| 56 | + assert class_match is not None |
| 57 | + |
| 58 | + return set(class_match.group(1).split()) |
| 59 | + |
| 60 | + |
| 61 | +def test_setup_css_supports_shell_and_disclosure_layout() -> None: |
| 62 | + css = client.get("/static/setup.css") |
| 63 | + |
| 64 | + assert css.status_code == 200 |
| 65 | + assert "--paper-base" in css.text |
| 66 | + assert ".shell" in css.text |
| 67 | + assert ".progress-rail" in css.text |
| 68 | + assert ".detail-drawer" in css.text |
| 69 | + assert ".summary-card" in css.text |
| 70 | + assert "@media (max-width: 860px)" in css.text |
| 71 | + assert '#setup-progress[data-mobile-open="false"]' in css.text |
| 72 | + assert '#mobile-step-toggle[aria-expanded="true"]' in css.text |
| 73 | + assert "#6366f1" not in css.text |
| 74 | + |
| 75 | + |
| 76 | +def test_setup_home_renders_shell_and_collapsed_detail_drawers() -> None: |
| 77 | + home = client.get("/") |
| 78 | + workbench_classes = class_tokens_for_id(home.text, "setup-workbench") |
| 79 | + progress_classes = class_tokens_for_id(home.text, "setup-progress") |
| 80 | + prerequisites_panel_classes = class_tokens_for_id( |
| 81 | + home.text, "step-panel-prerequisites" |
| 82 | + ) |
| 83 | + configuration_panel_classes = class_tokens_for_id( |
| 84 | + home.text, "step-panel-configuration" |
| 85 | + ) |
| 86 | + runtime_panel_classes = class_tokens_for_id(home.text, "step-panel-runtime") |
| 87 | + process_panel_classes = class_tokens_for_id(home.text, "step-panel-process") |
| 88 | + |
| 89 | + assert home.status_code == 200 |
| 90 | + assert 'id="setup-workbench"' in home.text |
| 91 | + assert 'id="setup-progress"' in home.text |
| 92 | + assert 'id="readiness-summary"' in home.text |
| 93 | + assert 'id="blocker-summary"' in home.text |
| 94 | + assert 'id="common-fixes"' in home.text |
| 95 | + assert 'data-step-id="prerequisites"' in home.text |
| 96 | + assert 'data-step-id="configuration"' in home.text |
| 97 | + assert 'data-step-id="runtime"' in home.text |
| 98 | + assert 'data-step-id="process"' in home.text |
| 99 | + assert "shell" in workbench_classes |
| 100 | + assert "progress-rail" in progress_classes |
| 101 | + assert {"step-panel", "card"} <= prerequisites_panel_classes |
| 102 | + assert {"step-panel", "card", "wide"} <= configuration_panel_classes |
| 103 | + assert {"step-panel", "card"} <= runtime_panel_classes |
| 104 | + assert {"step-panel", "card"} <= process_panel_classes |
| 105 | + assert 'id="mobile-step-toggle"' in home.text |
| 106 | + assert 'aria-controls="setup-progress"' in home.text |
| 107 | + assert 'aria-expanded="false"' in home.text |
| 108 | + assert 'id="run-diagnostics"' in home.text |
| 109 | + assert 'id="copy-env"' in home.text |
| 110 | + assert 'id="runtime-check"' in home.text |
| 111 | + assert 'id="bootstrap-models"' in home.text |
| 112 | + assert 'id="process-run"' in home.text |
| 113 | + assert 'id="runtime-summary"' in home.text |
| 114 | + assert 'id="process-summary"' in home.text |
| 115 | + assert 'id="advanced-config"' in home.text |
| 116 | + assert 'id="runtime-details"' in home.text |
| 117 | + assert 'id="process-details"' in home.text |
| 118 | + assert re.search( |
| 119 | + r'<details[^>]*id="advanced-config"(?![^>]*\bopen\b)[^>]*>', home.text |
| 120 | + ) |
| 121 | + assert re.search( |
| 122 | + r'<details[^>]*id="runtime-details"(?![^>]*\bopen\b)[^>]*>', home.text |
| 123 | + ) |
| 124 | + assert re.search( |
| 125 | + r'<details[^>]*id="process-details"(?![^>]*\bopen\b)[^>]*>', home.text |
| 126 | + ) |
| 127 | + |
| 128 | + |
| 129 | +def test_setup_assets_expose_module_and_top_level_contracts() -> None: |
| 130 | + home = client.get("/") |
| 131 | + entry_js = client.get("/static/setup.js") |
| 132 | + api_js = client.get("/static/setup_api.js") |
| 133 | + state_js = client.get("/static/setup_state.js") |
| 134 | + view_models_js = client.get("/static/setup_view_models.js") |
| 135 | + views_js = client.get("/static/setup_views.js") |
| 136 | + |
| 137 | + assert 'src="static/setup.js" type="module"' in home.text |
| 138 | + |
| 139 | + for response in (entry_js, api_js, state_js, view_models_js, views_js): |
| 140 | + assert response.status_code == 200 |
| 141 | + assert _is_javascript_content_type(response.headers["content-type"]) |
| 142 | + |
| 143 | + assert 'import { createSetupApi } from "./setup_api.js";' in entry_js.text |
| 144 | + assert 'import { createSetupState } from "./setup_state.js";' in entry_js.text |
| 145 | + assert ( |
| 146 | + 'import { buildShellSummaryView } from "./setup_view_models.js";' |
| 147 | + in entry_js.text |
| 148 | + ) |
| 149 | + assert ( |
| 150 | + 'import { buildProcessSummaryView } from "./setup_view_models.js";' |
| 151 | + in entry_js.text |
| 152 | + ) |
| 153 | + assert ( |
| 154 | + 'import { buildProcessFailureView } from "./setup_view_models.js";' |
| 155 | + in entry_js.text |
| 156 | + ) |
| 157 | + assert 'import { createSetupViews } from "./setup_views.js";' in entry_js.text |
| 158 | + assert "function boot()" in entry_js.text |
| 159 | + assert "boot();" in entry_js.text |
| 160 | + assert "handleDiagnosticsRun" in entry_js.text |
| 161 | + assert "handleRuntimeCheck" in entry_js.text |
| 162 | + assert "handleBootstrapModels" in entry_js.text |
| 163 | + assert "handleProcessRun" in entry_js.text |
| 164 | + assert "runDiagnostics();" not in entry_js.text |
| 165 | + |
| 166 | + assert "export function createSetupApi" in api_js.text |
| 167 | + assert "runDiagnostics" in api_js.text |
| 168 | + assert "checkRuntime" in api_js.text |
| 169 | + assert "bootstrapModels" in api_js.text |
| 170 | + assert "runProcess" in api_js.text |
| 171 | + |
| 172 | + assert "export function createSetupState" in state_js.text |
| 173 | + assert "beginDiagnosticsCheck" in state_js.text |
| 174 | + assert "markConfigurationComplete" in state_js.text |
| 175 | + assert "beginRuntimeCheck" in state_js.text |
| 176 | + assert "completeRuntimeCheck" in state_js.text |
| 177 | + assert "beginProcess" in state_js.text |
| 178 | + assert "markProcessSuccess" in state_js.text |
| 179 | + assert "markProcessFailure" in state_js.text |
| 180 | + assert ( |
| 181 | + 'const STEP_ORDER = ["prerequisites", "configuration", "runtime", "process"]' |
| 182 | + in state_js.text |
| 183 | + ) |
| 184 | + |
| 185 | + assert "export function buildShellSummaryView" in view_models_js.text |
| 186 | + assert "export function buildStaleSummaryLabel" in view_models_js.text |
| 187 | + assert "export function buildProcessSummaryView" in view_models_js.text |
| 188 | + assert "export function buildProcessFailureView" in view_models_js.text |
| 189 | + |
| 190 | + assert "export function createSetupViews" in views_js.text |
| 191 | + assert "renderShellState" in views_js.text |
| 192 | + assert "renderDiagnosticsSummary" in views_js.text |
| 193 | + assert "renderRuntimeSummary" in views_js.text |
| 194 | + assert "renderRuntimeDetails" in views_js.text |
| 195 | + assert "renderProcessSummary" in views_js.text |
| 196 | + assert "renderProcessDetails" in views_js.text |
| 197 | + assert "bindMobileStepToggle" in views_js.text |
| 198 | + assert 'document.getElementById("mobile-step-toggle")' in views_js.text |
| 199 | + assert ( |
| 200 | + 'progressRail.dataset.mobileOpen = expanded ? "true" : "false";' |
| 201 | + in views_js.text |
| 202 | + ) |
| 203 | + assert ( |
| 204 | + 'mobileStepToggle.setAttribute("aria-expanded", String(expanded));' |
| 205 | + in views_js.text |
| 206 | + ) |
| 207 | + assert "views.bindMobileStepToggle();" in entry_js.text |
| 208 | + |
| 209 | + |
| 210 | +def test_setup_js_prefers_backend_setup_views_for_state_transitions() -> None: |
| 211 | + entry_js = client.get("/static/setup.js") |
| 212 | + |
| 213 | + assert entry_js.status_code == 200 |
| 214 | + assert "function toDiagnosticsView(report)" in entry_js.text |
| 215 | + assert ( |
| 216 | + 'return report.setup_view || { state: report.ready ? "ready" : "blocked" };' |
| 217 | + in entry_js.text |
| 218 | + ) |
| 219 | + assert "function toRuntimeView(runtime)" in entry_js.text |
| 220 | + assert ( |
| 221 | + 'return runtime.setup_view || { state: runtime.reachable ? "ready" : "blocked" };' |
| 222 | + in entry_js.text |
| 223 | + ) |
| 224 | + assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text |
| 225 | + assert "state.applyDiagnosticsResult(diagnosticsView);" in entry_js.text |
| 226 | + assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text |
| 227 | + assert "state.completeRuntimeCheck(runtimeView);" in entry_js.text |
| 228 | + |
| 229 | + |
| 230 | +def test_setup_js_renders_shaped_setup_view_summaries_and_common_fixes() -> None: |
| 231 | + entry_js = client.get("/static/setup.js") |
| 232 | + |
| 233 | + assert entry_js.status_code == 200 |
| 234 | + assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text |
| 235 | + assert "views.renderDiagnosticsSummary(diagnosticsView);" in entry_js.text |
| 236 | + assert "views.renderCommonFixes(diagnosticsView);" in entry_js.text |
| 237 | + assert 'if (diagnosticsView.state === "ready") {' in entry_js.text |
| 238 | + assert "if (report.ready) {" not in entry_js.text |
| 239 | + assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text |
| 240 | + assert "renderRuntimeState(runtimeView, runtime);" in entry_js.text |
| 241 | + assert ( |
| 242 | + "renderRuntimeState(runtimeView, { ...runtime, bootstrap: report });" |
| 243 | + in entry_js.text |
| 244 | + ) |
| 245 | + |
| 246 | + |
| 247 | +def test_setup_views_expose_common_fixes_and_runtime_readiness_copy() -> None: |
| 248 | + views_js = client.get("/static/setup_views.js") |
| 249 | + |
| 250 | + assert views_js.status_code == 200 |
| 251 | + assert "function renderCommonFixes(view)" in views_js.text |
| 252 | + assert 'const container = document.getElementById("common-fixes");' in views_js.text |
| 253 | + assert "const fixes = Array.isArray(view?.checks)" in views_js.text |
| 254 | + assert "view.checks.filter((check) => check.fix)" in views_js.text |
| 255 | + assert "function renderRuntimeSummary(runtimeView, runtime)" in views_js.text |
| 256 | + assert ( |
| 257 | + '["Required models", `${requiredModels.length} configured`],' in views_js.text |
| 258 | + ) |
| 259 | + assert '["Available locally", `${localModels.length} ready`],' in views_js.text |
| 260 | + assert "runtimeView?.next_action ||" in views_js.text |
| 261 | + assert "renderCommonFixes," in views_js.text |
| 262 | + |
| 263 | + |
| 264 | +def test_setup_process_summary_view_reads_nested_model_used() -> None: |
| 265 | + view_models_js = client.get("/static/setup_view_models.js") |
| 266 | + |
| 267 | + assert view_models_js.status_code == 200 |
| 268 | + assert "function collectProcessModel(result)" in view_models_js.text |
| 269 | + assert "result.summary?.model_used" in view_models_js.text |
0 commit comments