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