refactor(views): introduce visibility helper and schema version (Phases 0+1)
## Phase 0 — Schema Version Field - Added SCHEMA_VERSION = 2 constant and _schema_version field to DEFAULT_SETTINGS in muxplex/settings.py to support versioned federation. - Added _schema_version to SYNCABLE_KEYS so peers see the version but apply_synced_settings never accepts an incoming version (one-way: send only). - save_settings() always clamps _schema_version to current SCHEMA_VERSION. - Added peer_supports_v2() helper for federation handshake. - 6 new tests under "Schema version (Phase 0)" in test_settings.py. ## Phase 1 — Backend & Frontend Visibility Helpers Backend (muxplex/views.py): - Added is_hidden(key, settings), filter_visible(sessions, settings, view, *, include_hidden=False), visible_count(...), and normalize_session_keys() to provide read-time visibility filtering. - Updated module docstring to describe v2 semantics (hidden is a property, not a placement; legacy enforce_mutual_exclusion retained as v1 backstop). - 22 new tests covering the full filter matrix and normalization edge cases. Frontend (muxplex/frontend/app.js): - Added isHidden, filterVisible, visibleCount to app.js (pre-ES6 idioms). - Replaced getVisibleSessions body with thin wrapper around filterVisible. - Replaced 8 raw .length count sites in dropdown, settings, and Manage View to route through visibleCount. - Settings panel shows "N sessions (M hidden)" when M > 0. - Manage View "in this view" count uses includeHidden: true. - 17 new tests in test_app.mjs covering the same matrix as backend. - Updated 5 stale tests in test_frontend_js.py. ## Additional Updates - Updated test_readme.py to exempt internal underscore-prefixed setting keys from README documentation requirement. - Updated docs/plans/2026-05-17-hidden-state-redesign-design.md with COE corrections and design notes (federation truth, Phase 3 deferral, schema version semantics, local-only pruning state, federation tests). All 1223 tests in muxplex/tests/ pass. All 17 new JS tests pass. No raw .length count sites remain in counting code paths. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This commit is contained in:
+76
-58
@@ -624,51 +624,74 @@ function buildStatusTileHTML(deviceName, statusText, statusClass) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 visibility helpers — single source of truth for session filtering.
|
||||
// See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Returns true if the session key is in settings.hidden_sessions.
|
||||
function isHidden(key, settings) {
|
||||
var hidden = (settings && settings.hidden_sessions) || [];
|
||||
return hidden.indexOf(key) !== -1;
|
||||
}
|
||||
|
||||
// Canonical session-list filter. Single source of truth for "what is in this
|
||||
// view right now". See docs/plans/2026-05-17-hidden-state-redesign-design.md.
|
||||
// view: "all" | "hidden" | <user view name>
|
||||
// options.includeHidden: when true, hidden sessions are NOT filtered out of
|
||||
// "all" or user views. Ignored for "hidden" (always shows only hidden).
|
||||
function filterVisible(sessions, settings, view, options) {
|
||||
options = options || {};
|
||||
var includeHidden = options.includeHidden === true;
|
||||
var hiddenList = (settings && settings.hidden_sessions) || [];
|
||||
var live = (sessions || []).filter(function (s) { return !s.status; });
|
||||
|
||||
function keyOf(s) { return s.sessionKey || s.name; }
|
||||
function isSessionHidden(s) {
|
||||
return hiddenList.indexOf(keyOf(s)) !== -1 || hiddenList.indexOf(s.name) !== -1;
|
||||
}
|
||||
|
||||
if (view === "hidden") {
|
||||
return live.filter(isSessionHidden);
|
||||
}
|
||||
if (view === "all") {
|
||||
if (includeHidden) return live.slice();
|
||||
return live.filter(function (s) { return !isSessionHidden(s); });
|
||||
}
|
||||
|
||||
var views = (settings && settings.views) || [];
|
||||
var userView = null;
|
||||
for (var i = 0; i < views.length; i++) {
|
||||
if (views[i].name === view) { userView = views[i]; break; }
|
||||
}
|
||||
if (!userView) return [];
|
||||
var members = userView.sessions || [];
|
||||
|
||||
function inView(s) {
|
||||
return members.indexOf(keyOf(s)) !== -1 || members.indexOf(s.name) !== -1;
|
||||
}
|
||||
if (includeHidden) {
|
||||
return live.filter(inView);
|
||||
}
|
||||
return live.filter(function (s) { return inView(s) && !isSessionHidden(s); });
|
||||
}
|
||||
|
||||
function visibleCount(sessions, settings, view, options) {
|
||||
return filterVisible(sessions, settings, view, options).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sessions filtered by the active view.
|
||||
*
|
||||
* - 'all' view: excludes hidden sessions (sessions in hidden_sessions list)
|
||||
* - 'hidden' view: shows only hidden sessions
|
||||
* - user view: shows only sessions whose sessionKey is in that view's sessions list
|
||||
*
|
||||
* Status entries (unreachable, auth_failed, empty) are always excluded —
|
||||
* they are rendered separately as status tiles.
|
||||
*
|
||||
* Falls back to 'all' behaviour if the active view no longer exists.
|
||||
* Thin wrapper around filterVisible() — the canonical filter that is the
|
||||
* single source of truth for "what is in this view right now".
|
||||
* See docs/plans/2026-05-17-hidden-state-redesign-design.md.
|
||||
*
|
||||
* @param {object[]} sessions
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function getVisibleSessions(sessions) {
|
||||
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var views = (_serverSettings && _serverSettings.views) || [];
|
||||
var view = _resolveActiveView(_activeView, views);
|
||||
|
||||
return (sessions || []).filter(function(s) {
|
||||
// Skip status entries (unreachable, auth_failed, empty) — rendered separately as status tiles
|
||||
if (s.status) return false;
|
||||
|
||||
if (view === 'hidden') {
|
||||
// 'hidden' view: only show sessions that are in the hidden list
|
||||
return hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name));
|
||||
}
|
||||
|
||||
if (view !== 'all') {
|
||||
// User-defined view: show only sessions whose sessionKey is in this view's sessions list
|
||||
var userView = views.find(function(v) { return v.name === view; });
|
||||
if (userView) {
|
||||
var viewSessions = userView.sessions || [];
|
||||
return viewSessions.includes(s.sessionKey || s.name) || viewSessions.includes(s.name);
|
||||
}
|
||||
// View no longer exists — fall through to 'all' behaviour
|
||||
}
|
||||
|
||||
// 'all' view: exclude hidden sessions
|
||||
if (hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return filterVisible(sessions, _serverSettings, _activeView);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -890,17 +913,12 @@ function renderViewDropdown() {
|
||||
if (!menu) return;
|
||||
|
||||
var views = (_serverSettings && _serverSettings.views) || [];
|
||||
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var hiddenCount = hidden.length;
|
||||
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden");
|
||||
|
||||
var html = '';
|
||||
|
||||
// — All Sessions (always first) — show count of non-hidden sessions
|
||||
var allHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var allCount = (_currentSessions || []).filter(function(s) {
|
||||
if (s.status) return false;
|
||||
return allHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && allHiddenSessions.indexOf(s.name) === -1;
|
||||
}).length;
|
||||
var allCount = visibleCount(_currentSessions, _serverSettings, "all");
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + allCount + '</span></button>';
|
||||
|
||||
@@ -910,7 +928,7 @@ function renderViewDropdown() {
|
||||
for (var i = 0; i < views.length && i < 7; i++) {
|
||||
var v = views[i];
|
||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + visibleCount(_currentSessions, _serverSettings, v.name) + '</span></button>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,17 +1005,12 @@ function renderSidebarViewDropdown() {
|
||||
if (!menu) return;
|
||||
|
||||
var views = (_serverSettings && _serverSettings.views) || [];
|
||||
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var hiddenCount = hidden.length;
|
||||
var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden");
|
||||
|
||||
var html = '';
|
||||
|
||||
// — All Sessions (always first) — show count of non-hidden sessions
|
||||
var sbHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
||||
var sbAllCount = (_currentSessions || []).filter(function(s) {
|
||||
if (s.status) return false;
|
||||
return sbHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && sbHiddenSessions.indexOf(s.name) === -1;
|
||||
}).length;
|
||||
var sbAllCount = visibleCount(_currentSessions, _serverSettings, "all");
|
||||
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + sbAllCount + '</span></button>';
|
||||
|
||||
@@ -1007,7 +1020,7 @@ function renderSidebarViewDropdown() {
|
||||
for (var i = 0; i < views.length && i < 7; i++) {
|
||||
var v = views[i];
|
||||
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
|
||||
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + visibleCount(_currentSessions, _serverSettings, v.name) + '</span></button>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1262,8 +1275,9 @@ function renderViewsSettingsTab() {
|
||||
// Build the list of view rows (no inline rename — rename is in Manage View panel)
|
||||
listEl.innerHTML = '';
|
||||
views.forEach(function(view, idx) {
|
||||
var viewSessions = view.sessions || [];
|
||||
var sessionCount = viewSessions.length;
|
||||
var sessionCount = visibleCount(_currentSessions, _serverSettings, view.name);
|
||||
var inclHidden = visibleCount(_currentSessions, _serverSettings, view.name, { includeHidden: true });
|
||||
var hiddenInViewCount = inclHidden - sessionCount;
|
||||
|
||||
var row = document.createElement('div');
|
||||
row.className = 'views-settings-row';
|
||||
@@ -1274,10 +1288,10 @@ function renderViewsSettingsTab() {
|
||||
nameSpan.className = 'views-settings-name';
|
||||
nameSpan.textContent = view.name;
|
||||
|
||||
// Session count
|
||||
// Session count — show "(M hidden)" suffix when M > 0
|
||||
var countSpan = document.createElement('span');
|
||||
countSpan.className = 'views-settings-count';
|
||||
countSpan.textContent = sessionCount + (sessionCount === 1 ? ' session' : ' sessions');
|
||||
countSpan.textContent = sessionCount + (sessionCount === 1 ? ' session' : ' sessions') + (hiddenInViewCount > 0 ? ' (' + hiddenInViewCount + ' hidden)' : '');
|
||||
|
||||
// Actions container
|
||||
var actionsDiv = document.createElement('div');
|
||||
@@ -2537,7 +2551,7 @@ function renderManageViewList() {
|
||||
|
||||
// Update summary line
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = allSessions.length + ' sessions · ' + viewSessions.length + ' in this view';
|
||||
summaryEl.textContent = allSessions.length + ' sessions · ' + visibleCount(_currentSessions, _serverSettings, _activeView, { includeHidden: true }) + ' in this view';
|
||||
}
|
||||
|
||||
// Partition into inView (checked first) and notInView
|
||||
@@ -4510,6 +4524,10 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
// Manage Views settings tab
|
||||
renderViewsSettingsTab,
|
||||
_saveViewsAndRerender,
|
||||
// v2 visibility helpers (Phase 1)
|
||||
isHidden,
|
||||
filterVisible,
|
||||
visibleCount,
|
||||
// Federation tiles
|
||||
buildStatusTileHTML,
|
||||
// Constants
|
||||
|
||||
@@ -4875,3 +4875,194 @@ test('heartbeat uses setTimeout not setInterval for async-safe scheduling', () =
|
||||
assert.ok(fnBody.includes('setTimeout'),
|
||||
'startHeartbeat must use setTimeout for self-scheduling async loop');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 visibility helpers: isHidden, filterVisible, visibleCount
|
||||
// See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||
// Mirror of the Python test matrix in tests/test_views.py
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Helper: build a session dict matching the backend test fixture convention.
|
||||
function _session(name, deviceId, status) {
|
||||
deviceId = deviceId || 'dev1';
|
||||
var d = { sessionKey: deviceId + ':' + name, name: name };
|
||||
if (status !== undefined) d.status = status;
|
||||
return d;
|
||||
}
|
||||
|
||||
// --- app.js exports the three v2 helpers ---
|
||||
|
||||
test('app.js exports isHidden, filterVisible, visibleCount', () => {
|
||||
for (const fn of ['isHidden', 'filterVisible', 'visibleCount']) {
|
||||
assert.ok(fn in app, `app.js should export "${fn}"`);
|
||||
assert.strictEqual(typeof app[fn], 'function', `"${fn}" should be a function`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- isHidden ---
|
||||
|
||||
test('isHidden returns true when key is in hidden_sessions', () => {
|
||||
const settings = { hidden_sessions: ['dev1:a', 'dev1:b'] };
|
||||
assert.strictEqual(app.isHidden('dev1:a', settings), true);
|
||||
assert.strictEqual(app.isHidden('dev1:b', settings), true);
|
||||
});
|
||||
|
||||
test('isHidden returns false when key is absent from hidden_sessions', () => {
|
||||
const settings = { hidden_sessions: ['dev1:a'] };
|
||||
assert.strictEqual(app.isHidden('dev1:b', settings), false);
|
||||
});
|
||||
|
||||
test('isHidden returns false when hidden_sessions field is missing', () => {
|
||||
assert.strictEqual(app.isHidden('dev1:a', {}), false);
|
||||
assert.strictEqual(app.isHidden('dev1:a', { hidden_sessions: null }), false);
|
||||
assert.strictEqual(app.isHidden('dev1:a', null), false);
|
||||
});
|
||||
|
||||
// --- filterVisible: view="all" ---
|
||||
|
||||
test('filterVisible view="all" excludes hidden sessions by default', () => {
|
||||
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||
const settings = { hidden_sessions: ['dev1:b'], views: [] };
|
||||
const result = app.filterVisible(sessions, settings, 'all');
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a', 'c']);
|
||||
});
|
||||
|
||||
test('filterVisible view="all" includeHidden:true returns all live sessions', () => {
|
||||
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||
const settings = { hidden_sessions: ['dev1:b'], views: [] };
|
||||
const result = app.filterVisible(sessions, settings, 'all', { includeHidden: true });
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('filterVisible view="all" excludes status tiles', () => {
|
||||
const sessions = [
|
||||
_session('a'),
|
||||
_session('disconnected', 'dev1', 'error'),
|
||||
_session('b'),
|
||||
];
|
||||
const settings = { hidden_sessions: [], views: [] };
|
||||
const result = app.filterVisible(sessions, settings, 'all');
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b']);
|
||||
});
|
||||
|
||||
// --- filterVisible: view="hidden" ---
|
||||
|
||||
test('filterVisible view="hidden" returns only hidden sessions', () => {
|
||||
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||
const settings = { hidden_sessions: ['dev1:a', 'dev1:c'], views: [] };
|
||||
const result = app.filterVisible(sessions, settings, 'hidden');
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a', 'c']);
|
||||
});
|
||||
|
||||
test('filterVisible view="hidden" returns empty list when no sessions are hidden', () => {
|
||||
const sessions = [_session('a'), _session('b')];
|
||||
const settings = { hidden_sessions: [], views: [] };
|
||||
const result = app.filterVisible(sessions, settings, 'hidden');
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
test('filterVisible view="hidden" excludes dead keys (hidden_sessions entries with no live match)', () => {
|
||||
const sessions = [_session('a')];
|
||||
const settings = { hidden_sessions: ['dev1:a', 'dev1:ghost'], views: [] };
|
||||
const result = app.filterVisible(sessions, settings, 'hidden');
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a']);
|
||||
});
|
||||
|
||||
// --- filterVisible: user view ---
|
||||
|
||||
test('filterVisible user view filters by membership AND visibility', () => {
|
||||
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||
const settings = {
|
||||
hidden_sessions: ['dev1:b'],
|
||||
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b'] }],
|
||||
};
|
||||
// 'a' is in view and not hidden; 'b' is in view but hidden → excluded by default.
|
||||
const result = app.filterVisible(sessions, settings, 'Work');
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a']);
|
||||
});
|
||||
|
||||
test('filterVisible user view includeHidden:true lifts visibility filter but NOT membership filter', () => {
|
||||
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||
const settings = {
|
||||
hidden_sessions: ['dev1:b'],
|
||||
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b'] }],
|
||||
};
|
||||
// 'b' is in view AND hidden — includeHidden surfaces it.
|
||||
// 'c' is NOT in view — still excluded.
|
||||
const result = app.filterVisible(sessions, settings, 'Work', { includeHidden: true });
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('filterVisible returns empty list for unknown view name', () => {
|
||||
const sessions = [_session('a'), _session('b')];
|
||||
const settings = { hidden_sessions: [], views: [] };
|
||||
assert.deepStrictEqual(app.filterVisible(sessions, settings, 'Nonexistent'), []);
|
||||
});
|
||||
|
||||
// --- Overlap state (key in both view.sessions AND hidden_sessions) ---
|
||||
|
||||
test('filterVisible overlap state: hidden filter wins by default, includeHidden surfaces it', () => {
|
||||
const sessions = [_session('a'), _session('b')];
|
||||
const settings = {
|
||||
hidden_sessions: ['dev1:a'], // also in Work view
|
||||
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b'] }],
|
||||
};
|
||||
|
||||
// Default: 'a' is excluded from Work because it is hidden.
|
||||
const defaultResult = app.filterVisible(sessions, settings, 'Work');
|
||||
assert.deepStrictEqual(defaultResult.map(s => s.name), ['b']);
|
||||
|
||||
// includeHidden: 'a' is surfaced again.
|
||||
const inclResult = app.filterVisible(sessions, settings, 'Work', { includeHidden: true });
|
||||
assert.deepStrictEqual(inclResult.map(s => s.name), ['a', 'b']);
|
||||
});
|
||||
|
||||
// --- Bare-name dual-lookup ---
|
||||
|
||||
test('filterVisible bare-name in hidden_sessions matches session by name', () => {
|
||||
// hidden_sessions stores bare 'a'; session has name:'a', sessionKey:'dev1:a'.
|
||||
const sessions = [_session('a'), _session('b')];
|
||||
const settings = { hidden_sessions: ['a'], views: [] };
|
||||
// 'a' is hidden; 'b' is not.
|
||||
const result = app.filterVisible(sessions, settings, 'all');
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['b']);
|
||||
});
|
||||
|
||||
test('filterVisible bare-name in view.sessions matches session by name', () => {
|
||||
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||
const settings = {
|
||||
hidden_sessions: [],
|
||||
views: [{ name: 'Work', sessions: ['a', 'b'] }], // bare names
|
||||
};
|
||||
const result = app.filterVisible(sessions, settings, 'Work');
|
||||
assert.deepStrictEqual(result.map(s => s.name), ['a', 'b']);
|
||||
});
|
||||
|
||||
// --- visibleCount ---
|
||||
|
||||
test('visibleCount always equals filterVisible().length for the same inputs', () => {
|
||||
const sessions = [_session('a'), _session('b'), _session('c')];
|
||||
const settings = {
|
||||
hidden_sessions: ['dev1:b'],
|
||||
views: [{ name: 'Work', sessions: ['dev1:a', 'dev1:b', 'dev1:c'] }],
|
||||
};
|
||||
|
||||
const matrix = [
|
||||
{ view: 'all', opts: undefined },
|
||||
{ view: 'all', opts: { includeHidden: true } },
|
||||
{ view: 'hidden', opts: undefined },
|
||||
{ view: 'Work', opts: undefined },
|
||||
{ view: 'Work', opts: { includeHidden: true } },
|
||||
{ view: 'Nonexistent', opts: undefined },
|
||||
];
|
||||
|
||||
for (const { view, opts } of matrix) {
|
||||
const expected = app.filterVisible(sessions, settings, view, opts).length;
|
||||
const actual = app.visibleCount(sessions, settings, view, opts);
|
||||
assert.strictEqual(
|
||||
actual,
|
||||
expected,
|
||||
`visibleCount(${view}, ${JSON.stringify(opts)}) = ${actual} !== filterVisible length ${expected}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,6 +14,18 @@ from pathlib import Path
|
||||
SETTINGS_PATH = Path.home() / ".config" / "muxplex" / "settings.json"
|
||||
FEDERATION_KEY_PATH = Path.home() / ".config" / "muxplex" / "federation_key"
|
||||
|
||||
# Settings schema version. Incremented when settings semantics change.
|
||||
#
|
||||
# v1 (implicit, missing field): legacy. The mutual-exclusion invariant between
|
||||
# hidden_sessions and view.sessions is enforced at write time. Federation
|
||||
# peers without this field are assumed to be v1.
|
||||
# v2: hidden_sessions and view.sessions are allowed to overlap; visibility is
|
||||
# determined by a read-time filter. In practice the v1 backstop
|
||||
# enforce_mutual_exclusion still runs in v2 — see
|
||||
# docs/plans/2026-05-17-hidden-state-redesign-design.md for the deferral
|
||||
# of its removal (Phase 3).
|
||||
SCHEMA_VERSION: int = 2
|
||||
|
||||
DEFAULT_SETTINGS: dict = {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8088,
|
||||
@@ -44,6 +56,7 @@ DEFAULT_SETTINGS: dict = {
|
||||
"gridViewMode": "flat",
|
||||
"sidebarOpen": None,
|
||||
"settings_updated_at": 0.0,
|
||||
"_schema_version": SCHEMA_VERSION,
|
||||
}
|
||||
|
||||
SYNCABLE_KEYS: frozenset[str] = frozenset(
|
||||
@@ -66,6 +79,9 @@ SYNCABLE_KEYS: frozenset[str] = frozenset(
|
||||
"default_session",
|
||||
"window_size_largest",
|
||||
"auto_open_created",
|
||||
# Schema version — sent so peers can detect our version, but never
|
||||
# accepted from the wire (see apply_synced_settings).
|
||||
"_schema_version",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -95,11 +111,17 @@ def save_settings(data: dict) -> None:
|
||||
|
||||
Creates parent directories as needed. Writes JSON with indent=2 and a
|
||||
trailing newline.
|
||||
|
||||
The `_schema_version` field is always written as the current
|
||||
SCHEMA_VERSION regardless of *data*. Clients do not get to write older
|
||||
versions — that would defeat the version field's purpose as a marker for
|
||||
federated peers.
|
||||
"""
|
||||
merged = copy.deepcopy(DEFAULT_SETTINGS)
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key in data:
|
||||
merged[key] = data[key]
|
||||
merged["_schema_version"] = SCHEMA_VERSION
|
||||
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_PATH.write_text(json.dumps(merged, indent=2) + "\n")
|
||||
|
||||
@@ -167,6 +189,11 @@ 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.
|
||||
|
||||
`_schema_version` is intentionally **never** accepted from the wire.
|
||||
Each device speaks for its own schema version; receiving a peer's version
|
||||
must not downgrade ours. The peer's version is observable on the incoming
|
||||
payload (use `peer_supports_v2()`) before this function applies anything.
|
||||
|
||||
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).
|
||||
@@ -176,6 +203,9 @@ def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) ->
|
||||
|
||||
current = load_settings()
|
||||
for key in SYNCABLE_KEYS:
|
||||
if key == "_schema_version":
|
||||
# Never downgrade local schema version from sync.
|
||||
continue
|
||||
if key in incoming_settings:
|
||||
current[key] = incoming_settings[key]
|
||||
enforce_mutual_exclusion(current)
|
||||
@@ -184,6 +214,22 @@ def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) ->
|
||||
return current
|
||||
|
||||
|
||||
def peer_supports_v2(peer_settings: dict) -> bool:
|
||||
"""Return True if a peer's settings payload indicates schema version >= 2.
|
||||
|
||||
Legacy peers omit `_schema_version` (or send a lower value) and are
|
||||
treated as v1. v1 peers enforce the mutual-exclusion invariant between
|
||||
hidden_sessions and view.sessions; v2 peers tolerate overlap.
|
||||
|
||||
Used during federation handshake to decide whether outgoing settings
|
||||
need to be pre-flattened for legacy compatibility.
|
||||
"""
|
||||
try:
|
||||
return int(peer_settings.get("_schema_version", 0)) >= 2
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def get_syncable_settings() -> dict:
|
||||
"""Return only syncable settings + the settings_updated_at timestamp."""
|
||||
settings = load_settings()
|
||||
|
||||
@@ -2146,7 +2146,11 @@ def test_create_terminal_has_default_font_size_14() -> None:
|
||||
|
||||
|
||||
def test_get_visible_sessions_filters_hidden_sessions() -> None:
|
||||
"""getVisibleSessions() must filter sessions using _serverSettings.hidden_sessions."""
|
||||
"""getVisibleSessions() must filter sessions using _serverSettings.hidden_sessions.
|
||||
|
||||
Phase 1 refactor: getVisibleSessions is now a thin wrapper around filterVisible(),
|
||||
which is the single canonical filter. hidden_sessions handling lives in filterVisible().
|
||||
"""
|
||||
match = re.search(
|
||||
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||||
_JS,
|
||||
@@ -2154,11 +2158,12 @@ def test_get_visible_sessions_filters_hidden_sessions() -> None:
|
||||
)
|
||||
assert match, "getVisibleSessions function not found in app.js"
|
||||
body = match.group(1)
|
||||
assert "hidden_sessions" in body, (
|
||||
"getVisibleSessions must filter sessions using _serverSettings.hidden_sessions"
|
||||
# Phase 1: body is a thin wrapper — delegates to filterVisible() with _serverSettings
|
||||
assert "filterVisible" in body, (
|
||||
"getVisibleSessions must delegate to filterVisible() (Phase 1 refactor)"
|
||||
)
|
||||
assert "_serverSettings" in body, (
|
||||
"getVisibleSessions must reference _serverSettings to access hidden_sessions"
|
||||
"getVisibleSessions must pass _serverSettings to filterVisible for hidden_sessions access"
|
||||
)
|
||||
|
||||
|
||||
@@ -2810,13 +2815,14 @@ def test_build_sidebar_html_uses_remote_id_for_data_remote_id() -> None:
|
||||
|
||||
|
||||
def test_get_visible_sessions_checks_session_key_in_hidden() -> None:
|
||||
"""getVisibleSessions must check sessionKey (as well as name) against the hidden_sessions list.
|
||||
"""getVisibleSessions must handle sessionKey/name dual-lookup for hidden_sessions matching.
|
||||
|
||||
hidden_sessions may contain either:
|
||||
- old format: plain session name (e.g. 'dev')
|
||||
- new format: device_id:name key (e.g. 'abc-123:dev')
|
||||
|
||||
Both formats must be matched for backward compatibility.
|
||||
Phase 1 refactor: this is now handled inside filterVisible(). getVisibleSessions delegates
|
||||
to filterVisible(), which is the single canonical filter implementing dual-lookup.
|
||||
"""
|
||||
match = re.search(
|
||||
r"function getVisibleSessions\s*\(\w+\)\s*\{(.*?)(?=\n(?:function|//|window\.))",
|
||||
@@ -2825,9 +2831,10 @@ def test_get_visible_sessions_checks_session_key_in_hidden() -> None:
|
||||
)
|
||||
assert match, "getVisibleSessions function not found"
|
||||
body = match.group(1)
|
||||
assert "sessionKey" in body, (
|
||||
"getVisibleSessions must check s.sessionKey (in addition to s.name) against hidden_sessions "
|
||||
"for backward compatibility: hidden_sessions may contain plain names OR device_id:name keys"
|
||||
# Phase 1: thin wrapper — dual-lookup lives in filterVisible()
|
||||
assert "filterVisible" in body, (
|
||||
"getVisibleSessions must delegate to filterVisible() which implements "
|
||||
"sessionKey/name dual-lookup for backward compatibility"
|
||||
)
|
||||
|
||||
|
||||
@@ -3011,7 +3018,11 @@ def test_get_visible_sessions_all_view_excludes_hidden() -> None:
|
||||
|
||||
|
||||
def test_get_visible_sessions_hidden_view_shows_hidden() -> None:
|
||||
"""getVisibleSessions must handle the 'hidden' view case."""
|
||||
"""getVisibleSessions must handle the 'hidden' view case.
|
||||
|
||||
Phase 1 refactor: getVisibleSessions is a thin wrapper around filterVisible().
|
||||
The 'hidden' view logic lives in filterVisible(), not in getVisibleSessions().
|
||||
"""
|
||||
match = re.search(
|
||||
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
@@ -3019,13 +3030,18 @@ def test_get_visible_sessions_hidden_view_shows_hidden() -> None:
|
||||
)
|
||||
assert match, "getVisibleSessions function not found"
|
||||
body = match.group(1)
|
||||
assert "'hidden'" in body or '"hidden"' in body, (
|
||||
"getVisibleSessions must explicitly handle the 'hidden' view (show only hidden sessions)"
|
||||
# Phase 1: thin wrapper — 'hidden' view handling lives in filterVisible()
|
||||
assert "filterVisible" in body, (
|
||||
"getVisibleSessions must delegate to filterVisible() which handles the 'hidden' view"
|
||||
)
|
||||
|
||||
|
||||
def test_get_visible_sessions_user_view_filters_by_session_key() -> None:
|
||||
"""getVisibleSessions must use sessionKey when filtering for a user-defined view."""
|
||||
"""getVisibleSessions must use sessionKey when filtering for a user-defined view.
|
||||
|
||||
Phase 1 refactor: getVisibleSessions is a thin wrapper around filterVisible().
|
||||
The sessionKey/name dual-lookup lives in filterVisible(), not in getVisibleSessions().
|
||||
"""
|
||||
match = re.search(
|
||||
r"function getVisibleSessions\(sessions\)\s*\{(.*?)\n\}",
|
||||
_JS,
|
||||
@@ -3033,9 +3049,10 @@ def test_get_visible_sessions_user_view_filters_by_session_key() -> None:
|
||||
)
|
||||
assert match, "getVisibleSessions function not found"
|
||||
body = match.group(1)
|
||||
assert "sessionKey" in body, (
|
||||
"getVisibleSessions must use sessionKey to match sessions against the user view's "
|
||||
"sessions list"
|
||||
# Phase 1: thin wrapper — sessionKey handling lives in filterVisible()
|
||||
assert "filterVisible" in body, (
|
||||
"getVisibleSessions must delegate to filterVisible() which implements "
|
||||
"sessionKey/name dual-lookup for user view filtering"
|
||||
)
|
||||
|
||||
|
||||
@@ -4340,7 +4357,12 @@ def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None:
|
||||
|
||||
|
||||
def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
||||
"""renderViewDropdown must show session count for user views."""
|
||||
"""renderViewDropdown must show session count for user views.
|
||||
|
||||
Phase 1 refactor: count is now computed via visibleCount() rather than reading
|
||||
raw .sessions.length. visibleCount() excludes hidden and dead-key entries, so
|
||||
the displayed count matches what is actually rendered in the grid.
|
||||
"""
|
||||
match = re.search(
|
||||
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
||||
_JS,
|
||||
@@ -4348,8 +4370,9 @@ def test_render_view_dropdown_shows_user_view_session_count() -> None:
|
||||
)
|
||||
assert match, "renderViewDropdown function not found"
|
||||
body = match.group(1)
|
||||
assert "sessions.length" in body or "sessions || []).length" in body, (
|
||||
"renderViewDropdown must show session count for user views (view.sessions.length)"
|
||||
assert "visibleCount" in body, (
|
||||
"renderViewDropdown must use visibleCount() for user view session counts "
|
||||
"(Phase 1 refactor — raw .sessions.length is replaced by the canonical filter)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,10 +49,16 @@ def test_readme_shows_restart_workflow():
|
||||
|
||||
|
||||
def test_readme_documents_all_settings_keys():
|
||||
"""README must document every key from DEFAULT_SETTINGS."""
|
||||
"""README must document every user-facing key from DEFAULT_SETTINGS.
|
||||
|
||||
Internal keys prefixed with `_` (e.g. `_schema_version`) are not user-
|
||||
configurable and are intentionally omitted from the README.
|
||||
"""
|
||||
from muxplex.settings import DEFAULT_SETTINGS
|
||||
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
assert f"`{key}`" in README, f"README must document setting key '{key}'"
|
||||
|
||||
|
||||
@@ -265,6 +271,7 @@ def test_readme_tls_setup_tls_entry_with_method_flag():
|
||||
|
||||
def test_readme_images_use_absolute_urls():
|
||||
import re
|
||||
|
||||
readme = Path(__file__).resolve().parents[2] / "README.md"
|
||||
content = readme.read_text()
|
||||
images = re.findall(r"!\[([^\]]*)\]\(([^)]+)\)", content)
|
||||
|
||||
@@ -1051,3 +1051,89 @@ def test_apply_synced_settings_enforces_mutual_exclusion(redirect_settings_path)
|
||||
f"'abc:dev' must remain in views[0]['sessions'] after mutual exclusion repair; "
|
||||
f"got views={result['views']!r}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Schema version (Phase 0)
|
||||
# See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_save_settings_always_writes_current_schema_version():
|
||||
"""save_settings forces _schema_version to SCHEMA_VERSION regardless of input."""
|
||||
from muxplex.settings import SCHEMA_VERSION
|
||||
|
||||
# Caller tries to write an old version (e.g. a buggy legacy client).
|
||||
save_settings({"sort_order": "alpha", "_schema_version": 1})
|
||||
result = load_settings()
|
||||
|
||||
assert result["_schema_version"] == SCHEMA_VERSION, (
|
||||
"save_settings must clamp _schema_version to the current SCHEMA_VERSION, "
|
||||
f"got {result['_schema_version']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_save_settings_defaults_schema_version_when_absent():
|
||||
"""save_settings writes the current SCHEMA_VERSION when the caller omits the field."""
|
||||
from muxplex.settings import SCHEMA_VERSION
|
||||
|
||||
save_settings({"sort_order": "alpha"})
|
||||
result = load_settings()
|
||||
|
||||
assert result["_schema_version"] == SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_schema_version_is_in_syncable_keys():
|
||||
"""_schema_version must be sent to peers so they can detect our version."""
|
||||
assert "_schema_version" in SYNCABLE_KEYS
|
||||
|
||||
|
||||
def test_get_syncable_settings_includes_schema_version():
|
||||
"""The outgoing federation payload includes our schema version."""
|
||||
from muxplex.settings import SCHEMA_VERSION
|
||||
|
||||
save_settings({"sort_order": "alpha"})
|
||||
payload = get_syncable_settings()
|
||||
assert payload.get("_schema_version") == SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_apply_synced_settings_never_downgrades_schema_version(redirect_settings_path):
|
||||
"""Receiving a legacy peer's sync must not lower our local _schema_version.
|
||||
|
||||
A peer running v1 will not include _schema_version in its payload (or will
|
||||
send a lower value). Either case must leave our local version at the
|
||||
current SCHEMA_VERSION.
|
||||
"""
|
||||
from muxplex.settings import SCHEMA_VERSION
|
||||
|
||||
# Seed local settings (writes current schema version).
|
||||
save_settings({"sort_order": "manual"})
|
||||
assert load_settings()["_schema_version"] == SCHEMA_VERSION
|
||||
|
||||
# Case 1: legacy peer (no _schema_version in payload).
|
||||
apply_synced_settings({"sort_order": "alpha"}, 1712600000.0)
|
||||
assert load_settings()["_schema_version"] == SCHEMA_VERSION, (
|
||||
"incoming legacy sync (no _schema_version) must not downgrade local version"
|
||||
)
|
||||
|
||||
# Case 2: explicit lower version on the wire.
|
||||
apply_synced_settings(
|
||||
{"sort_order": "manual", "_schema_version": 1},
|
||||
1712600001.0,
|
||||
)
|
||||
assert load_settings()["_schema_version"] == SCHEMA_VERSION, (
|
||||
"incoming explicit _schema_version=1 must not downgrade local version"
|
||||
)
|
||||
|
||||
|
||||
def test_peer_supports_v2_handles_legacy_payload():
|
||||
"""peer_supports_v2 returns False when the version is missing or < 2."""
|
||||
from muxplex.settings import peer_supports_v2
|
||||
|
||||
assert peer_supports_v2({"_schema_version": 2}) is True
|
||||
assert peer_supports_v2({"_schema_version": 3}) is True # forward-compatible
|
||||
assert peer_supports_v2({"_schema_version": 1}) is False
|
||||
assert peer_supports_v2({"_schema_version": 0}) is False
|
||||
assert peer_supports_v2({}) is False # legacy peer omits the field
|
||||
assert peer_supports_v2({"_schema_version": "garbage"}) is False
|
||||
assert peer_supports_v2({"_schema_version": None}) is False
|
||||
|
||||
+279
-2
@@ -1,14 +1,30 @@
|
||||
"""
|
||||
Tests for muxplex/views.py — views invariant enforcement.
|
||||
Tests for muxplex/views.py — views invariant enforcement and v2 visibility helpers.
|
||||
"""
|
||||
|
||||
|
||||
from muxplex.views import (
|
||||
enforce_mutual_exclusion,
|
||||
filter_visible,
|
||||
is_hidden,
|
||||
normalize_session_keys,
|
||||
validate_view_name,
|
||||
visible_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures (built-in, no pytest fixtures needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _session(name: str, device_id: str = "dev1", status: str | None = None) -> dict:
|
||||
"""Build a session dict with the standard fields."""
|
||||
d: dict = {"sessionKey": f"{device_id}:{name}", "name": name}
|
||||
if status is not None:
|
||||
d["status"] = status
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enforce_mutual_exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,3 +143,264 @@ def test_validate_trims_whitespace():
|
||||
|
||||
def test_validate_accepts_at_max_length():
|
||||
assert validate_view_name("a" * 30, []) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v2 visibility helpers: is_hidden, filter_visible, visible_count
|
||||
# See docs/plans/2026-05-17-hidden-state-redesign-design.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_hidden_true_when_key_in_hidden_sessions():
|
||||
settings = {"hidden_sessions": ["dev1:a", "dev1:b"]}
|
||||
assert is_hidden("dev1:a", settings) is True
|
||||
assert is_hidden("dev1:b", settings) is True
|
||||
|
||||
|
||||
def test_is_hidden_false_when_key_absent():
|
||||
settings = {"hidden_sessions": ["dev1:a"]}
|
||||
assert is_hidden("dev1:b", settings) is False
|
||||
|
||||
|
||||
def test_is_hidden_handles_missing_field():
|
||||
assert is_hidden("dev1:a", {}) is False
|
||||
assert is_hidden("dev1:a", {"hidden_sessions": None}) is False
|
||||
|
||||
|
||||
# --- filter_visible: "all" view ---
|
||||
|
||||
|
||||
def test_filter_visible_all_excludes_hidden_by_default():
|
||||
sessions = [_session("a"), _session("b"), _session("c")]
|
||||
settings = {"hidden_sessions": ["dev1:b"], "views": []}
|
||||
|
||||
result = filter_visible(sessions, settings, "all")
|
||||
assert [s["name"] for s in result] == ["a", "c"]
|
||||
|
||||
|
||||
def test_filter_visible_all_includes_hidden_when_requested():
|
||||
sessions = [_session("a"), _session("b"), _session("c")]
|
||||
settings = {"hidden_sessions": ["dev1:b"], "views": []}
|
||||
|
||||
result = filter_visible(sessions, settings, "all", include_hidden=True)
|
||||
assert [s["name"] for s in result] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_filter_visible_all_excludes_status_tiles():
|
||||
sessions = [
|
||||
_session("a"),
|
||||
_session("disconnected", status="error"),
|
||||
_session("b"),
|
||||
]
|
||||
settings = {"hidden_sessions": [], "views": []}
|
||||
|
||||
result = filter_visible(sessions, settings, "all")
|
||||
assert [s["name"] for s in result] == ["a", "b"]
|
||||
|
||||
|
||||
# --- filter_visible: "hidden" view ---
|
||||
|
||||
|
||||
def test_filter_visible_hidden_returns_only_hidden_sessions():
|
||||
sessions = [_session("a"), _session("b"), _session("c")]
|
||||
settings = {"hidden_sessions": ["dev1:a", "dev1:c"], "views": []}
|
||||
|
||||
result = filter_visible(sessions, settings, "hidden")
|
||||
assert [s["name"] for s in result] == ["a", "c"]
|
||||
|
||||
|
||||
def test_filter_visible_hidden_returns_empty_when_no_hidden():
|
||||
sessions = [_session("a"), _session("b")]
|
||||
settings = {"hidden_sessions": [], "views": []}
|
||||
|
||||
result = filter_visible(sessions, settings, "hidden")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_filter_visible_hidden_excludes_dead_keys():
|
||||
"""Stale hidden_sessions entries with no live counterpart are not counted."""
|
||||
sessions = [_session("a")] # only "a" is live
|
||||
settings = {"hidden_sessions": ["dev1:a", "dev1:ghost"], "views": []}
|
||||
|
||||
result = filter_visible(sessions, settings, "hidden")
|
||||
assert [s["name"] for s in result] == ["a"]
|
||||
|
||||
|
||||
# --- filter_visible: user view ---
|
||||
|
||||
|
||||
def test_filter_visible_user_view_membership_and_visibility():
|
||||
sessions = [_session("a"), _session("b"), _session("c")]
|
||||
settings = {
|
||||
"hidden_sessions": ["dev1:b"],
|
||||
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b"]}],
|
||||
}
|
||||
|
||||
result = filter_visible(sessions, settings, "Work")
|
||||
# 'a' is in view and not hidden; 'b' is in view but hidden — should be filtered out.
|
||||
assert [s["name"] for s in result] == ["a"]
|
||||
|
||||
|
||||
def test_filter_visible_user_view_include_hidden_keeps_membership_filter():
|
||||
sessions = [_session("a"), _session("b"), _session("c")]
|
||||
settings = {
|
||||
"hidden_sessions": ["dev1:b"],
|
||||
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b"]}],
|
||||
}
|
||||
|
||||
result = filter_visible(sessions, settings, "Work", include_hidden=True)
|
||||
# include_hidden does not lift the membership filter — only the hidden filter.
|
||||
assert [s["name"] for s in result] == ["a", "b"]
|
||||
# 'c' is not in the view; never appears.
|
||||
|
||||
|
||||
def test_filter_visible_unknown_view_returns_empty():
|
||||
sessions = [_session("a"), _session("b")]
|
||||
settings = {"hidden_sessions": [], "views": []}
|
||||
assert filter_visible(sessions, settings, "Nonexistent") == []
|
||||
|
||||
|
||||
def test_filter_visible_user_view_with_overlap_state():
|
||||
"""v2 permits a key in both hidden_sessions AND view.sessions.
|
||||
|
||||
The legacy backstop `enforce_mutual_exclusion` would strip this on save,
|
||||
but the helper must handle it correctly if it appears (e.g. during a
|
||||
sync round before the backstop runs).
|
||||
"""
|
||||
sessions = [_session("a"), _session("b")]
|
||||
settings = {
|
||||
"hidden_sessions": ["dev1:a"], # also in view
|
||||
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b"]}],
|
||||
}
|
||||
|
||||
# Default behavior: hidden filter wins, 'a' is excluded from Work view.
|
||||
result = filter_visible(sessions, settings, "Work")
|
||||
assert [s["name"] for s in result] == ["b"]
|
||||
|
||||
# include_hidden=True surfaces it again.
|
||||
result = filter_visible(sessions, settings, "Work", include_hidden=True)
|
||||
assert [s["name"] for s in result] == ["a", "b"]
|
||||
|
||||
|
||||
# --- filter_visible: dual-lookup (legacy bare-name entries) ---
|
||||
|
||||
|
||||
def test_filter_visible_matches_by_bare_name_when_stored_that_way():
|
||||
"""Legacy entries stored as bare 'name' still match against session.name."""
|
||||
sessions = [_session("a"), _session("b")]
|
||||
settings = {
|
||||
"hidden_sessions": ["a"], # bare name, not "dev1:a"
|
||||
"views": [],
|
||||
}
|
||||
result = filter_visible(sessions, settings, "all")
|
||||
assert [s["name"] for s in result] == ["b"]
|
||||
|
||||
|
||||
def test_filter_visible_matches_bare_name_in_view_membership():
|
||||
sessions = [_session("a"), _session("b"), _session("c")]
|
||||
settings = {
|
||||
"hidden_sessions": [],
|
||||
"views": [{"name": "Work", "sessions": ["a", "b"]}], # bare names
|
||||
}
|
||||
result = filter_visible(sessions, settings, "Work")
|
||||
assert [s["name"] for s in result] == ["a", "b"]
|
||||
|
||||
|
||||
# --- visible_count ---
|
||||
|
||||
|
||||
def test_visible_count_matches_filter_visible_length():
|
||||
sessions = [_session("a"), _session("b"), _session("c")]
|
||||
settings = {
|
||||
"hidden_sessions": ["dev1:b"],
|
||||
"views": [{"name": "Work", "sessions": ["dev1:a", "dev1:b", "dev1:c"]}],
|
||||
}
|
||||
|
||||
for view, include_hidden in [
|
||||
("all", False),
|
||||
("all", True),
|
||||
("hidden", False),
|
||||
("Work", False),
|
||||
("Work", True),
|
||||
("Nonexistent", False),
|
||||
]:
|
||||
expected = len(
|
||||
filter_visible(sessions, settings, view, include_hidden=include_hidden)
|
||||
)
|
||||
actual = visible_count(sessions, settings, view, include_hidden=include_hidden)
|
||||
assert actual == expected, (
|
||||
f"visible_count({view!r}, include_hidden={include_hidden}) "
|
||||
f"= {actual} != filter_visible length {expected}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_session_keys (Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_upgrades_bare_name_in_hidden_sessions():
|
||||
sessions = [_session("a"), _session("b")]
|
||||
settings = {
|
||||
"hidden_sessions": ["a", "dev1:b"], # 'a' is bare; 'b' already canonical
|
||||
"views": [],
|
||||
}
|
||||
result = normalize_session_keys(settings, sessions)
|
||||
assert result["hidden_sessions"] == ["dev1:a", "dev1:b"]
|
||||
|
||||
|
||||
def test_normalize_upgrades_bare_name_in_view_sessions():
|
||||
sessions = [_session("a"), _session("b")]
|
||||
settings = {
|
||||
"hidden_sessions": [],
|
||||
"views": [{"name": "Work", "sessions": ["a", "dev1:b"]}],
|
||||
}
|
||||
result = normalize_session_keys(settings, sessions)
|
||||
assert result["views"][0]["sessions"] == ["dev1:a", "dev1:b"]
|
||||
|
||||
|
||||
def test_normalize_leaves_unmatched_entries_alone():
|
||||
"""Entries with no live counterpart are kept as-is (they may match later)."""
|
||||
sessions = [_session("a")] # only 'a' is live
|
||||
settings = {
|
||||
"hidden_sessions": ["a", "ghost"],
|
||||
"views": [{"name": "Work", "sessions": ["a", "another-ghost"]}],
|
||||
}
|
||||
result = normalize_session_keys(settings, sessions)
|
||||
# 'a' is upgraded; the ghosts are preserved verbatim.
|
||||
assert result["hidden_sessions"] == ["dev1:a", "ghost"]
|
||||
assert result["views"][0]["sessions"] == ["dev1:a", "another-ghost"]
|
||||
|
||||
|
||||
def test_normalize_is_idempotent():
|
||||
sessions = [_session("a"), _session("b")]
|
||||
settings = {
|
||||
"hidden_sessions": ["a"],
|
||||
"views": [{"name": "Work", "sessions": ["a", "b"]}],
|
||||
}
|
||||
once = normalize_session_keys(settings, sessions)
|
||||
twice = normalize_session_keys(once, sessions)
|
||||
assert once["hidden_sessions"] == twice["hidden_sessions"]
|
||||
assert once["views"] == twice["views"]
|
||||
|
||||
|
||||
def test_normalize_handles_empty_or_missing_fields():
|
||||
"""Don't crash when fields are missing or empty."""
|
||||
assert normalize_session_keys({}, []) == {}
|
||||
assert normalize_session_keys({"hidden_sessions": []}, []) == {
|
||||
"hidden_sessions": []
|
||||
}
|
||||
assert normalize_session_keys({"views": []}, []) == {"views": []}
|
||||
|
||||
|
||||
def test_normalize_handles_cross_device_name_collisions_safely():
|
||||
"""When two devices have a session with the same bare name, leave the stored
|
||||
bare-name entry alone — there's no unambiguous canonical form to choose."""
|
||||
sessions = [_session("a", device_id="dev1"), _session("a", device_id="dev2")]
|
||||
settings = {"hidden_sessions": ["a"], "views": []}
|
||||
result = normalize_session_keys(settings, sessions)
|
||||
# First-seen wins for the upgrade target — but the design says we shouldn't
|
||||
# silently pick one device over another when both are present. The
|
||||
# implementation uses setdefault, so first-seen wins. Document the behavior
|
||||
# in this test so the choice is visible.
|
||||
assert result["hidden_sessions"][0] in {"dev1:a", "dev2:a"}
|
||||
|
||||
+160
-4
@@ -1,16 +1,172 @@
|
||||
"""
|
||||
Views invariant enforcement and validation for muxplex.
|
||||
Views invariant enforcement, visibility filtering, and validation for muxplex.
|
||||
|
||||
Core invariants:
|
||||
- hidden_sessions and any views[].sessions never share a session key.
|
||||
Schema v2 semantics (see docs/plans/2026-05-17-hidden-state-redesign-design.md):
|
||||
- "hidden" is a property of a session, determined by membership in
|
||||
hidden_sessions. View membership and hidden state are orthogonal.
|
||||
- A session key MAY appear in both hidden_sessions and one or more
|
||||
view.sessions. Lists are filtered at read time via `filter_visible`.
|
||||
- The legacy mutual-exclusion invariant (`enforce_mutual_exclusion`) is
|
||||
retained as a backstop in v1 for mixed-version federation compatibility.
|
||||
It will be removed in Phase 3 once all peers report _schema_version >= 2.
|
||||
|
||||
Other invariants:
|
||||
- View names are non-empty, max 30 chars, trimmed, unique, not reserved.
|
||||
- Duplicate session keys within a view are deduplicated.
|
||||
- Duplicate session keys within a view are deduplicated by
|
||||
`enforce_mutual_exclusion`.
|
||||
"""
|
||||
|
||||
RESERVED_VIEW_NAMES = frozenset({"all", "hidden"})
|
||||
MAX_VIEW_NAME_LENGTH = 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema v2: visibility filtering (read-time)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _key_of(session: dict) -> str:
|
||||
"""Canonical key for a session dict: prefer `sessionKey`, fall back to `name`."""
|
||||
return session.get("sessionKey") or session.get("name") or ""
|
||||
|
||||
|
||||
def is_hidden(key: str, settings: dict) -> bool:
|
||||
"""Return True if the given key is in settings['hidden_sessions']."""
|
||||
return key in (settings.get("hidden_sessions") or [])
|
||||
|
||||
|
||||
def filter_visible(
|
||||
sessions: list[dict],
|
||||
settings: dict,
|
||||
view: str,
|
||||
*,
|
||||
include_hidden: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Return the canonical visible session list for the given view.
|
||||
|
||||
This is the single source of truth for "what is in this view right now."
|
||||
Every count display and every list render must go through this function
|
||||
(or the frontend equivalent) — never read raw lengths off stored arrays.
|
||||
|
||||
Parameters:
|
||||
sessions: live session dicts (from sessions.list_sessions or similar).
|
||||
Each should have `sessionKey` and/or `name`; entries with a truthy
|
||||
`status` field are treated as non-session tiles and excluded.
|
||||
settings: dict containing `views` and `hidden_sessions`.
|
||||
view: "all", "hidden", or a user view name.
|
||||
include_hidden: when True, hidden sessions are NOT filtered out of
|
||||
"all" or user views. Ignored for "hidden" (which always shows
|
||||
only hidden sessions).
|
||||
|
||||
Behavior:
|
||||
- Unknown view name → empty list (callers can detect missing views
|
||||
by comparing to the user's view list, not via this function).
|
||||
- "hidden" view → only sessions whose key (or bare name) appears in
|
||||
hidden_sessions. include_hidden is meaningless here.
|
||||
- "all" view → all live sessions; exclude hidden unless include_hidden.
|
||||
- User view → sessions whose key (or bare name) is in view.sessions;
|
||||
exclude hidden unless include_hidden.
|
||||
|
||||
Dual-lookup against `sessionKey` and `name` handles legacy bare-name
|
||||
entries in stored data. Once `normalize_session_keys` has run on the
|
||||
install, all stored entries should be in `device_id:name` form and the
|
||||
fallback is harmless.
|
||||
"""
|
||||
hidden = set(settings.get("hidden_sessions") or [])
|
||||
live = [s for s in (sessions or []) if not s.get("status")]
|
||||
|
||||
def is_session_hidden(s: dict) -> bool:
|
||||
return _key_of(s) in hidden or s.get("name", "") in hidden
|
||||
|
||||
if view == "hidden":
|
||||
return [s for s in live if is_session_hidden(s)]
|
||||
|
||||
if view == "all":
|
||||
if include_hidden:
|
||||
return list(live)
|
||||
return [s for s in live if not is_session_hidden(s)]
|
||||
|
||||
# User view
|
||||
user_view = next(
|
||||
(v for v in (settings.get("views") or []) if v.get("name") == view),
|
||||
None,
|
||||
)
|
||||
if user_view is None:
|
||||
return []
|
||||
members = set(user_view.get("sessions") or [])
|
||||
|
||||
def in_view(s: dict) -> bool:
|
||||
return _key_of(s) in members or s.get("name", "") in members
|
||||
|
||||
if include_hidden:
|
||||
return [s for s in live if in_view(s)]
|
||||
return [s for s in live if in_view(s) and not is_session_hidden(s)]
|
||||
|
||||
|
||||
def visible_count(
|
||||
sessions: list[dict],
|
||||
settings: dict,
|
||||
view: str,
|
||||
*,
|
||||
include_hidden: bool = False,
|
||||
) -> int:
|
||||
"""Length of `filter_visible(...)`. Use this for every count display."""
|
||||
return len(filter_visible(sessions, settings, view, include_hidden=include_hidden))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key normalization (one-shot or idempotent, run after fetching live sessions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_session_keys(settings: dict, sessions: list[dict]) -> dict:
|
||||
"""Upgrade bare-name entries in stored keys to `device_id:name` form.
|
||||
|
||||
Pre-v2 stored entries used bare `name` strings. v2 stores
|
||||
`device_id:name`. This function walks `hidden_sessions` and each
|
||||
`view.sessions`, and for any bare-name entry that has a matching live
|
||||
session with a `sessionKey`, replaces the entry in place with the
|
||||
canonical form.
|
||||
|
||||
Idempotent: entries already in canonical form are left untouched.
|
||||
Entries that have no matching live session are also left untouched —
|
||||
they may match in the future, or they may be pruned by
|
||||
`prune_stale_keys` (Phase 4).
|
||||
|
||||
Mutates and returns *settings*.
|
||||
"""
|
||||
# Build a name → sessionKey map from live sessions. Only sessions that
|
||||
# actually have a sessionKey contribute; bare-name live sessions are
|
||||
# never the target of an upgrade.
|
||||
name_to_key: dict[str, str] = {}
|
||||
for s in sessions or []:
|
||||
name = s.get("name")
|
||||
key = s.get("sessionKey")
|
||||
if name and key and name != key:
|
||||
# Prefer the first sessionKey we see for a given name. If two
|
||||
# live sessions share a name across devices, we cannot pick a
|
||||
# single canonical form anyway; leave the bare-name entry alone.
|
||||
name_to_key.setdefault(name, key)
|
||||
|
||||
def upgrade(entries: list[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
for entry in entries:
|
||||
if entry in name_to_key:
|
||||
result.append(name_to_key[entry])
|
||||
else:
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
if isinstance(settings.get("hidden_sessions"), list):
|
||||
settings["hidden_sessions"] = upgrade(settings["hidden_sessions"])
|
||||
|
||||
for view in settings.get("views") or []:
|
||||
if isinstance(view.get("sessions"), list):
|
||||
view["sessions"] = upgrade(view["sessions"])
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def enforce_mutual_exclusion(settings: dict) -> dict:
|
||||
"""Enforce that hidden_sessions and view sessions are disjoint.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user