Files
DashBoard/tests/test_page_registry.py
egg 9b1d2edc52 feat(portal): implement dynamic drawer/page navigation management
Replace hardcoded sidebar drawer configuration with admin-manageable
dynamic system. Extend page_status.json with drawer definitions and
page assignments, add drawer CRUD API endpoints, render portal sidebar
via Jinja2 loops, and extend /admin/pages UI with drawer management.
Fix multi-worker cache invalidation via mtime-based staleness detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:34:04 +08:00

244 lines
8.9 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for page_registry module."""
import json
import os
import sys
import threading
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from mes_dashboard.services import page_registry
@pytest.fixture
def temp_data_file(tmp_path):
"""Create a temporary legacy data file for migration tests."""
data_file = tmp_path / "page_status.json"
initial_data = {
"pages": [
{"route": "/", "name": "Home", "status": "released"},
{"route": "/wip-overview", "name": "WIP Overview", "status": "released"},
{"route": "/tables", "name": "Tables", "status": "dev"},
{"route": "/dev-page", "name": "Dev Page", "status": "dev"},
],
"api_public": True,
}
data_file.write_text(json.dumps(initial_data), encoding="utf-8")
return data_file
@pytest.fixture
def mock_registry(temp_data_file):
"""Mock page_registry to use temp file."""
original_data_file = page_registry.DATA_FILE
original_cache = page_registry._cache
page_registry.DATA_FILE = temp_data_file
page_registry._cache = None
yield temp_data_file
page_registry.DATA_FILE = original_data_file
page_registry._cache = original_cache
class TestSchemaMigration:
"""Tests for first-run drawers migration."""
def test_migration_adds_drawers_and_assignments(self, mock_registry):
drawers = page_registry.get_all_drawers()
drawer_ids = [drawer["id"] for drawer in drawers]
assert drawer_ids == ["reports", "queries", "dev-tools"]
pages = page_registry.get_all_pages()
page_by_route = {page["route"]: page for page in pages}
assert page_by_route["/wip-overview"]["drawer_id"] == "reports"
assert page_by_route["/wip-overview"]["order"] == 1
assert page_by_route["/tables"]["drawer_id"] == "queries"
assert page_by_route["/tables"]["order"] == 1
# Admin tools should be backfilled from legacy hardcoded sidebar mapping.
assert page_by_route["/admin/pages"]["drawer_id"] == "dev-tools"
assert page_by_route["/admin/performance"]["drawer_id"] == "dev-tools"
def test_subsequent_load_does_not_reset_drawers(self, mock_registry):
page_registry.get_all_drawers()
page_registry.create_drawer("custom", order=10, admin_only=False)
page_registry.reload_cache()
drawers = page_registry.get_all_drawers()
assert any(drawer["id"] == "custom" for drawer in drawers)
class TestGetPageStatus:
"""Tests for get_page_status function."""
def test_get_released_page_status(self, mock_registry):
status = page_registry.get_page_status("/")
assert status == "released"
def test_get_dev_page_status(self, mock_registry):
status = page_registry.get_page_status("/dev-page")
assert status == "dev"
def test_get_unregistered_page_status(self, mock_registry):
status = page_registry.get_page_status("/not-registered")
assert status is None
class TestSetPageStatus:
"""Tests for set_page_status function."""
def test_update_existing_page_status(self, mock_registry):
page_registry.set_page_status("/dev-page", "released")
assert page_registry.get_page_status("/dev-page") == "released"
def test_set_page_drawer_and_order(self, mock_registry):
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
pages = page_registry.get_all_pages()
dev_page = next(page for page in pages if page["route"] == "/dev-page")
assert dev_page["drawer_id"] == "queries"
assert dev_page["order"] == 9
def test_clear_page_drawer_and_order(self, mock_registry):
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
page_registry.set_page_status("/dev-page", "dev", drawer_id=None, order=None)
pages = page_registry.get_all_pages()
dev_page = next(page for page in pages if page["route"] == "/dev-page")
assert "drawer_id" not in dev_page
assert "order" not in dev_page
def test_set_invalid_drawer_raises_error(self, mock_registry):
with pytest.raises(ValueError, match="Drawer not found"):
page_registry.set_page_status("/dev-page", "dev", drawer_id="not-exists")
def test_invalid_status_raises_error(self, mock_registry):
with pytest.raises(ValueError, match="Invalid status"):
page_registry.set_page_status("/", "invalid")
class TestDrawerCrud:
"""Tests for drawer CRUD functions."""
def test_create_drawer(self, mock_registry):
created = page_registry.create_drawer("Custom Drawer", order=4, admin_only=True)
assert created["name"] == "Custom Drawer"
assert created["order"] == 4
assert created["admin_only"] is True
def test_create_duplicate_drawer_name_raises_conflict(self, mock_registry):
with pytest.raises(page_registry.DrawerConflictError):
page_registry.create_drawer("報表類", order=4)
def test_update_drawer(self, mock_registry):
updated = page_registry.update_drawer(
"reports",
name="報表中心",
order=7,
admin_only=True,
)
assert updated["name"] == "報表中心"
assert updated["order"] == 7
assert updated["admin_only"] is True
def test_delete_drawer_rejects_assigned_pages(self, mock_registry):
with pytest.raises(page_registry.DrawerConflictError, match="assigned pages"):
page_registry.delete_drawer("reports")
def test_delete_empty_drawer(self, mock_registry):
created = page_registry.create_drawer("Temporary", order=8)
page_registry.delete_drawer(created["id"])
drawers = page_registry.get_all_drawers()
assert all(drawer["id"] != created["id"] for drawer in drawers)
class TestNavigationConfig:
"""Tests for navigation config generation."""
def test_navigation_config_grouped_and_sorted(self, mock_registry):
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=5)
nav = page_registry.get_navigation_config()
assert [drawer["id"] for drawer in nav] == ["reports", "queries", "dev-tools"]
reports = next(drawer for drawer in nav if drawer["id"] == "reports")
assert [page["route"] for page in reports["pages"]] == ["/wip-overview"]
assert reports["pages"][0]["frame_id"] == "wipOverviewFrame"
assert reports["pages"][0]["tool_src"] is None
queries = next(drawer for drawer in nav if drawer["id"] == "queries")
assert queries["pages"][0]["route"] == "/tables"
assert queries["pages"][-1]["route"] == "/dev-page"
dev_tools = next(drawer for drawer in nav if drawer["id"] == "dev-tools")
assert all(page["frame_id"] == "toolFrame" for page in dev_tools["pages"])
assert dev_tools["pages"][0]["tool_src"] == "/admin/pages"
class TestIsApiPublic:
"""Tests for is_api_public function."""
def test_api_public_true(self, mock_registry):
assert page_registry.is_api_public() is True
def test_api_public_false(self, mock_registry, temp_data_file):
data = json.loads(temp_data_file.read_text())
data["api_public"] = False
temp_data_file.write_text(json.dumps(data))
page_registry._cache = None
assert page_registry.is_api_public() is False
class TestReloadCache:
"""Tests for reload_cache function."""
def test_reload_cache(self, mock_registry, temp_data_file):
assert page_registry.get_page_status("/") == "released"
data = json.loads(temp_data_file.read_text())
home = next(page for page in data["pages"] if page["route"] == "/")
home["status"] = "dev"
temp_data_file.write_text(json.dumps(data))
assert page_registry.get_page_status("/") == "released"
page_registry.reload_cache()
assert page_registry.get_page_status("/") == "dev"
class TestConcurrency:
"""Tests for thread safety."""
def test_concurrent_access(self, mock_registry):
errors = []
def reader():
try:
for _ in range(100):
page_registry.get_page_status("/")
except Exception as exc: # pragma: no cover - defensive
errors.append(exc)
def writer():
try:
for index in range(100):
status = "released" if index % 2 == 0 else "dev"
page_registry.set_page_status("/", status)
except Exception as exc: # pragma: no cover - defensive
errors.append(exc)
threads = [threading.Thread(target=reader) for _ in range(3)] + [
threading.Thread(target=writer) for _ in range(2)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert len(errors) == 0, f"Errors occurred: {errors}"