diff --git a/muxplex/settings.py b/muxplex/settings.py index 20a63fd..8df82d6 100644 --- a/muxplex/settings.py +++ b/muxplex/settings.py @@ -166,11 +166,18 @@ def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) -> Only applies keys that are in SYNCABLE_KEYS. Sets settings_updated_at to the incoming timestamp (NOT time.time()) to prevent sync loops. + + After applying synced keys, enforces the mutual exclusion invariant: + any session key that appears in both hidden_sessions and a view's sessions + is removed from hidden_sessions (visibility wins over hiding). """ + from muxplex.views import enforce_mutual_exclusion + current = load_settings() for key in SYNCABLE_KEYS: if key in incoming_settings: current[key] = incoming_settings[key] + enforce_mutual_exclusion(current) current["settings_updated_at"] = incoming_timestamp save_settings(current) return current diff --git a/muxplex/tests/test_settings.py b/muxplex/tests/test_settings.py index c1e4908..3cbe60b 100644 --- a/muxplex/tests/test_settings.py +++ b/muxplex/tests/test_settings.py @@ -1013,3 +1013,43 @@ def test_patch_settings_syncs_views(): assert settings["settings_updated_at"] > 0, ( "settings_updated_at must be > 0 after patching a syncable key (views)" ) + + +# ============================================================ +# Post-sync mutual exclusion invariant repair (task-7) +# ============================================================ + + +def test_apply_synced_settings_enforces_mutual_exclusion(redirect_settings_path): + """apply_synced_settings must call enforce_mutual_exclusion after applying synced keys. + + Pre-populate settings with hidden_sessions=['abc:dev'] and views=[]. + Apply incoming sync with views=[{'name': 'Work', 'sessions': ['abc:dev']}] and + hidden_sessions=['abc:dev']. + Assert 'abc:dev' NOT in result['hidden_sessions'] and IS in result['views'][0]['sessions']. + """ + import json + + # Pre-populate settings with hidden_sessions containing 'abc:dev' + redirect_settings_path.write_text( + json.dumps({"hidden_sessions": ["abc:dev"], "views": []}) + ) + + # Apply incoming sync that has 'abc:dev' in BOTH views and hidden_sessions + incoming = { + "views": [{"name": "Work", "sessions": ["abc:dev"]}], + "hidden_sessions": ["abc:dev"], + } + result = apply_synced_settings(incoming, 1712600000.0) + + # enforce_mutual_exclusion should remove 'abc:dev' from hidden_sessions + # because it now appears in a view's sessions + assert "abc:dev" not in result["hidden_sessions"], ( + f"'abc:dev' must be removed from hidden_sessions after mutual exclusion repair; " + f"got hidden_sessions={result['hidden_sessions']!r}" + ) + # 'abc:dev' must still be in the view's sessions + assert "abc:dev" in result["views"][0]["sessions"], ( + f"'abc:dev' must remain in views[0]['sessions'] after mutual exclusion repair; " + f"got views={result['views']!r}" + )