feat: add views.py with mutual exclusion invariant and name validation
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for muxplex/views.py — views invariant enforcement.
|
||||
"""
|
||||
|
||||
|
||||
from muxplex.views import (
|
||||
enforce_mutual_exclusion,
|
||||
validate_view_name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enforce_mutual_exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enforce_removes_from_hidden_when_in_view():
|
||||
"""If a session is in both hidden_sessions and a view, remove from hidden (favor visibility)."""
|
||||
settings = {
|
||||
"hidden_sessions": ["abc:dev", "def:build"],
|
||||
"views": [
|
||||
{"name": "Work", "sessions": ["abc:dev", "abc:web"]},
|
||||
],
|
||||
}
|
||||
result = enforce_mutual_exclusion(settings)
|
||||
assert "abc:dev" not in result["hidden_sessions"]
|
||||
assert "def:build" in result["hidden_sessions"]
|
||||
assert "abc:dev" in result["views"][0]["sessions"]
|
||||
|
||||
|
||||
def test_enforce_no_change_when_no_overlap():
|
||||
"""No changes when there is no overlap between hidden and views."""
|
||||
settings = {
|
||||
"hidden_sessions": ["abc:old"],
|
||||
"views": [
|
||||
{"name": "Work", "sessions": ["abc:dev"]},
|
||||
],
|
||||
}
|
||||
result = enforce_mutual_exclusion(settings)
|
||||
assert result["hidden_sessions"] == ["abc:old"]
|
||||
assert result["views"][0]["sessions"] == ["abc:dev"]
|
||||
|
||||
|
||||
def test_enforce_handles_empty_views():
|
||||
"""Works when views is an empty list."""
|
||||
settings = {
|
||||
"hidden_sessions": ["abc:dev"],
|
||||
"views": [],
|
||||
}
|
||||
result = enforce_mutual_exclusion(settings)
|
||||
assert result["hidden_sessions"] == ["abc:dev"]
|
||||
|
||||
|
||||
def test_enforce_handles_empty_hidden():
|
||||
"""Works when hidden_sessions is empty."""
|
||||
settings = {
|
||||
"hidden_sessions": [],
|
||||
"views": [{"name": "Work", "sessions": ["abc:dev"]}],
|
||||
}
|
||||
result = enforce_mutual_exclusion(settings)
|
||||
assert result["hidden_sessions"] == []
|
||||
|
||||
|
||||
def test_enforce_deduplicates_view_sessions():
|
||||
"""Duplicate session keys within a view are deduplicated."""
|
||||
settings = {
|
||||
"hidden_sessions": [],
|
||||
"views": [
|
||||
{"name": "Work", "sessions": ["abc:dev", "abc:dev", "abc:web"]},
|
||||
],
|
||||
}
|
||||
result = enforce_mutual_exclusion(settings)
|
||||
assert result["views"][0]["sessions"] == ["abc:dev", "abc:web"]
|
||||
|
||||
|
||||
def test_enforce_overlap_across_multiple_views():
|
||||
"""A hidden session appearing in multiple views is removed from hidden."""
|
||||
settings = {
|
||||
"hidden_sessions": ["abc:dev"],
|
||||
"views": [
|
||||
{"name": "Work", "sessions": ["abc:dev"]},
|
||||
{"name": "Hobby", "sessions": ["abc:dev", "abc:printer"]},
|
||||
],
|
||||
}
|
||||
result = enforce_mutual_exclusion(settings)
|
||||
assert "abc:dev" not in result["hidden_sessions"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_view_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_rejects_empty_name():
|
||||
assert validate_view_name("", []) is not None
|
||||
|
||||
|
||||
def test_validate_rejects_whitespace_only():
|
||||
assert validate_view_name(" ", []) is not None
|
||||
|
||||
|
||||
def test_validate_rejects_too_long():
|
||||
assert validate_view_name("a" * 31, []) is not None
|
||||
|
||||
|
||||
def test_validate_rejects_reserved_all():
|
||||
assert validate_view_name("all", []) is not None
|
||||
|
||||
|
||||
def test_validate_rejects_reserved_hidden():
|
||||
assert validate_view_name("Hidden", []) is not None
|
||||
|
||||
|
||||
def test_validate_rejects_duplicate():
|
||||
existing = [{"name": "Work", "sessions": []}]
|
||||
assert validate_view_name("Work", existing) is not None
|
||||
|
||||
|
||||
def test_validate_accepts_valid_name():
|
||||
assert validate_view_name("My Project", []) is None
|
||||
|
||||
|
||||
def test_validate_trims_whitespace():
|
||||
"""A name that is valid after trimming should pass."""
|
||||
assert validate_view_name(" My Project ", []) is None
|
||||
|
||||
|
||||
def test_validate_accepts_at_max_length():
|
||||
assert validate_view_name("a" * 30, []) is None
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Views invariant enforcement and validation for muxplex.
|
||||
|
||||
Core invariants:
|
||||
- hidden_sessions and any views[].sessions never share a session key.
|
||||
- View names are non-empty, max 30 chars, trimmed, unique, not reserved.
|
||||
- Duplicate session keys within a view are deduplicated.
|
||||
"""
|
||||
|
||||
RESERVED_VIEW_NAMES = frozenset({"all", "hidden"})
|
||||
MAX_VIEW_NAME_LENGTH = 30
|
||||
|
||||
|
||||
def enforce_mutual_exclusion(settings: dict) -> dict:
|
||||
"""Enforce that hidden_sessions and view sessions are disjoint.
|
||||
|
||||
If a session key appears in both hidden_sessions and any view,
|
||||
it is removed from hidden_sessions (favor visibility over hiding).
|
||||
|
||||
Also deduplicates session keys within each view.
|
||||
|
||||
Mutates and returns the settings dict.
|
||||
"""
|
||||
views = settings.get("views", [])
|
||||
hidden = settings.get("hidden_sessions", [])
|
||||
|
||||
# Collect all session keys across all views
|
||||
all_view_sessions: set[str] = set()
|
||||
for view in views:
|
||||
all_view_sessions.update(view.get("sessions", []))
|
||||
|
||||
# Remove overlap from hidden (favor visibility)
|
||||
if all_view_sessions and hidden:
|
||||
settings["hidden_sessions"] = [s for s in hidden if s not in all_view_sessions]
|
||||
|
||||
# Deduplicate session keys within each view (preserve order)
|
||||
for view in views:
|
||||
sessions = view.get("sessions", [])
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for s in sessions:
|
||||
if s not in seen:
|
||||
seen.add(s)
|
||||
deduped.append(s)
|
||||
view["sessions"] = deduped
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def validate_view_name(name: str, existing_views: list[dict]) -> str | None:
|
||||
"""Validate a view name. Returns an error message string, or None if valid.
|
||||
|
||||
Rules:
|
||||
- Non-empty after trimming
|
||||
- Max 30 characters after trimming
|
||||
- Not a reserved name ("all", "hidden") case-insensitive
|
||||
- Unique among existing views (case-sensitive match)
|
||||
"""
|
||||
trimmed = name.strip()
|
||||
if not trimmed:
|
||||
return "View name cannot be empty"
|
||||
if len(trimmed) > MAX_VIEW_NAME_LENGTH:
|
||||
return f"View name must be {MAX_VIEW_NAME_LENGTH} characters or fewer"
|
||||
if trimmed.lower() in RESERVED_VIEW_NAMES:
|
||||
return f"'{trimmed}' is a reserved name"
|
||||
existing_names = {v.get("name", "") for v in existing_views}
|
||||
if trimmed in existing_names:
|
||||
return f"A view named '{trimmed}' already exists"
|
||||
return None
|
||||
Reference in New Issue
Block a user