diff --git a/muxplex/tests/test_views.py b/muxplex/tests/test_views.py new file mode 100644 index 0000000..1396464 --- /dev/null +++ b/muxplex/tests/test_views.py @@ -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 diff --git a/muxplex/views.py b/muxplex/views.py new file mode 100644 index 0000000..3a464ac --- /dev/null +++ b/muxplex/views.py @@ -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