7fa72ef8
test: consolidate native scenarios and fixtures
a73x 2026-09-07 15:22
Commit message
docs/development.md
| Old | New | ||
|---|---|---|---|
| @@ -68,3 +68,14 @@ additional custom sockets remains responsible for stopping those daemons. | |||
| 68 | 68 | ||
| 69 | Use `tools/run-logged.sh LOG COMMAND [ARG ...]` to capture a noisy check. It | 69 | Use `tools/run-logged.sh LOG COMMAND [ARG ...]` to capture a noisy check. It |
| 70 | prints the final 30 lines and returns the command's original exit status. | 70 | prints the final 30 lines and returns the command's original exit status. |
| 71 | |||
| 72 | Native tests keep ordinary pane, input, resize, clipboard and font checks in | ||
| 73 | `make native-e2e`'s single shared journey. Focused scripts retain exceptional | ||
| 74 | cases such as delayed replies, lifecycle failures, real SSH and compositor | ||
| 75 | scaling. Their workspace helpers create the required panes without replaying | ||
| 76 | the persistence scenario first. | ||
| 77 | |||
| 78 | Native fixture directories are removed after successful runs. Failures retain | ||
| 79 | their screenshots, state and logs; set `MUXG_TEST_KEEP_ARTIFACTS=1` to retain a | ||
| 80 | successful run too. Only the fixture's own temporary directory is removed; | ||
| 81 | the command log passed to `tools/run-logged.sh` is separate and remains available. | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -842,6 +842,11 @@ test "test hook retains text and clipboard paths and maps paste" { | |||
| 842 | try std.testing.expect(paste.mods & c.SDL_KMOD_CTRL != 0 and paste.mods & c.SDL_KMOD_SHIFT != 0); | 842 | try std.testing.expect(paste.mods & c.SDL_KMOD_CTRL != 0 and paste.mods & c.SDL_KMOD_SHIFT != 0); |
| 843 | } | 843 | } |
| 844 | 844 | ||
| 845 | // Caller owns Runtime/Wake and must supply window/cache before using their paths. | ||
| 846 | fn testEvents(rt: *runtime.Runtime, wake: *Wake, metrics: model.Metrics, width: i32, height: i32) Events { | ||
| 847 | return .{ .win = undefined, .wake = wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = rt, .metrics = metrics, .fb_w = width, .fb_h = height, .wake_ctx = wake, .wake = Wake.discovery } }; | ||
| 848 | } | ||
| 849 | |||
| 845 | test "delayed End refusal cannot install a hidden force menu over an active picker" { | 850 | test "delayed End refusal cannot install a hidden force menu over an active picker" { |
| 846 | const a = std.testing.allocator; | 851 | const a = std.testing.allocator; |
| 847 | var rt = runtime.Runtime.init(a, .{}); | 852 | var rt = runtime.Runtime.init(a, .{}); |
| @@ -849,7 +854,7 @@ test "delayed End refusal cannot install a hidden force menu over an active pick | |||
| 849 | const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; | 854 | const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; |
| 850 | const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics); | 855 | const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics); |
| 851 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 856 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 852 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; | 857 | var events = testEvents(&rt, &wake, metrics, 800, 600); |
| 853 | defer events.deinit(); | 858 | defer events.deinit(); |
| 854 | try events.ui.relayout(); | 859 | try events.ui.relayout(); |
| 855 | events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 }; | 860 | events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 }; |
| @@ -882,7 +887,7 @@ test "delayed End refusal respects changed focus and recovery context names its | |||
| 882 | const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics); | 887 | const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics); |
| 883 | const key = rt.get(first).?.key; | 888 | const key = rt.get(first).?.key; |
| 884 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 889 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 885 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; | 890 | var events = testEvents(&rt, &wake, metrics, 800, 600); |
| 886 | defer events.deinit(); | 891 | defer events.deinit(); |
| 887 | try events.ui.relayout(); | 892 | try events.ui.relayout(); |
| 888 | events.ui.pending_end = .{ .key = key, .request = 1 }; | 893 | events.ui.pending_end = .{ .key = key, .request = 1 }; |
| @@ -907,25 +912,6 @@ test "delayed End refusal respects changed focus and recovery context names its | |||
| 907 | try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]); | 912 | try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]); |
| 908 | } | 913 | } |
| 909 | 914 | ||
| 910 | test "delayed End refusal does not open force menu during command mode" { | ||
| 911 | const a = std.testing.allocator; | ||
| 912 | var rt = runtime.Runtime.init(a, .{}); | ||
| 913 | defer rt.deinit(); | ||
| 914 | const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 915 | const id = try rt.add(.{ .via = "cat" }, "end-target", 800, 600, metrics); | ||
| 916 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | ||
| 917 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .command_mode = true, .wake_ctx = &wake, .wake = Wake.discovery } }; | ||
| 918 | defer events.deinit(); | ||
| 919 | try events.ui.relayout(); | ||
| 920 | const key = rt.get(id).?.key; | ||
| 921 | events.ui.pending_end = .{ .key = key, .request = 1 }; | ||
| 922 | rt.get(id).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; | ||
| 923 | try events.ui.pollEnd(); | ||
| 924 | try std.testing.expect(events.ui.recovery == null); | ||
| 925 | try std.testing.expect(events.ui.pending_end == null); | ||
| 926 | try std.testing.expect(events.ui.command_mode); | ||
| 927 | } | ||
| 928 | |||
| 929 | test "held detach removes one pane and recovery Enter stays consumed after closing" { | 915 | test "held detach removes one pane and recovery Enter stays consumed after closing" { |
| 930 | const a = std.testing.allocator; | 916 | const a = std.testing.allocator; |
| 931 | var rt = runtime.Runtime.init(a, .{}); | 917 | var rt = runtime.Runtime.init(a, .{}); |
| @@ -934,7 +920,8 @@ test "held detach removes one pane and recovery Enter stays consumed after closi | |||
| 934 | const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); | 920 | const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); |
| 935 | _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); | 921 | _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); |
| 936 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 922 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 937 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .command_mode = true, .wake_ctx = &wake, .wake = Wake.discovery } }; | 923 | var events = testEvents(&rt, &wake, metrics, 800, 600); |
| 924 | events.ui.command_mode = true; | ||
| 938 | defer events.deinit(); | 925 | defer events.deinit(); |
| 939 | try events.ui.relayout(); | 926 | try events.ui.relayout(); |
| 940 | var ev = std.mem.zeroes(c.SDL_Event); | 927 | var ev = std.mem.zeroes(c.SDL_Event); |
| @@ -976,7 +963,7 @@ test "pending End header stays with its origin and rejects stale generations" { | |||
| 976 | const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); | 963 | const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); |
| 977 | const second = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); | 964 | const second = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); |
| 978 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 965 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 979 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; | 966 | var events = testEvents(&rt, &wake, metrics, 800, 600); |
| 980 | defer events.deinit(); | 967 | defer events.deinit(); |
| 981 | try events.ui.relayout(); | 968 | try events.ui.relayout(); |
| 982 | const key = rt.get(first).?.key; | 969 | const key = rt.get(first).?.key; |
| @@ -1158,7 +1145,7 @@ test "resize mode repeats move one cell without leaking held directions after ex | |||
| 1158 | _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); | 1145 | _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); |
| 1159 | _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); | 1146 | _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); |
| 1160 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1147 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 1161 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 801, .fb_h = 601, .wake_ctx = &wake, .wake = Wake.discovery } }; | 1148 | var events = testEvents(&rt, &wake, metrics, 801, 601); |
| 1162 | defer events.deinit(); | 1149 | defer events.deinit(); |
| 1163 | try events.ui.relayout(); | 1150 | try events.ui.relayout(); |
| 1164 | try events.ui.command(.r); | 1151 | try events.ui.command(.r); |
| @@ -1229,7 +1216,7 @@ test "divider drag preserves grab offset clamps signed outside motion and cancel | |||
| 1229 | _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); | 1216 | _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); |
| 1230 | _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); | 1217 | _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); |
| 1231 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1218 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 1232 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 801, .fb_h = 601, .wake_ctx = &wake, .wake = Wake.discovery } }; | 1219 | var events = testEvents(&rt, &wake, metrics, 801, 601); |
| 1233 | defer events.deinit(); | 1220 | defer events.deinit(); |
| 1234 | try events.ui.relayout(); | 1221 | try events.ui.relayout(); |
| 1235 | try events.ui.pointerDown(402, 100, 6, 6); | 1222 | try events.ui.pointerDown(402, 100, 6, 6); |
| @@ -1280,7 +1267,8 @@ test "multi-pane scale transitions resize every content claim and preserve stabl | |||
| 1280 | rt.workspace.arm(.beside); | 1267 | rt.workspace.arm(.beside); |
| 1281 | const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics); | 1268 | const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics); |
| 1282 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1269 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 1283 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = &cache, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 960, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; | 1270 | var events = testEvents(&rt, &wake, metrics, 960, 600); |
| 1271 | events.cache = &cache; | ||
| 1284 | defer events.deinit(); | 1272 | defer events.deinit(); |
| 1285 | try events.ui.relayout(); | 1273 | try events.ui.relayout(); |
| 1286 | const initial = events.ui.layout; | 1274 | const initial = events.ui.layout; |
| @@ -1328,7 +1316,7 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 1328 | defer rt.deinit(); | 1316 | defer rt.deinit(); |
| 1329 | // No live attachment is needed to test the actual SDL event routing state. | 1317 | // No live attachment is needed to test the actual SDL event routing state. |
| 1330 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1318 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 1331 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 960, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; | 1319 | var events = testEvents(&rt, &wake, .{ .cell_w = 8, .cell_h = 16 }, 960, 600); |
| 1332 | defer events.deinit(); | 1320 | defer events.deinit(); |
| 1333 | var event = std.mem.zeroes(c.SDL_Event); | 1321 | var event = std.mem.zeroes(c.SDL_Event); |
| 1334 | event.key.type = c.SDL_EVENT_KEY_DOWN; | 1322 | event.key.type = c.SDL_EVENT_KEY_DOWN; |
| @@ -1380,49 +1368,34 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 1380 | try std.testing.expectEqualSlices(u8, &.{0x1c}, input.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); | 1368 | try std.testing.expectEqualSlices(u8, &.{0x1c}, input.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); |
| 1381 | } | 1369 | } |
| 1382 | 1370 | ||
| 1383 | test "copy chord is exact while plain interrupt remains terminal input" { | 1371 | test "copy and paste shortcuts are platform-specific and exact; plain Ctrl stays terminal input" { |
| 1384 | var copy_event = std.mem.zeroes(c.SDL_KeyboardEvent); | 1372 | for ([_]struct { key: u32, shortcut: NativeShortcut }{ |
| 1385 | copy_event.key = c.SDLK_C; | 1373 | .{ .key = c.SDLK_C, .shortcut = .copy }, |
| 1386 | copy_event.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; | 1374 | .{ .key = c.SDLK_V, .shortcut = .paste }, |
| 1387 | try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event, false).?); | 1375 | }) |case| { |
| 1388 | var interrupt_event = copy_event; | 1376 | for ([_]bool{ false, true }) |macos| { |
| 1389 | interrupt_event.mod = c.SDL_KMOD_CTRL; | 1377 | var event = std.mem.zeroes(c.SDL_KeyboardEvent); |
| 1390 | const interrupt = interactionKey(interrupt_event); | 1378 | event.key = case.key; |
| 1391 | try std.testing.expect(nativeShortcut(interrupt_event, false) == null); | 1379 | for ([_]u16{ c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| { |
| 1392 | try std.testing.expect(interrupt.terminal != null); | 1380 | event.mod = mods; |
| 1393 | for ([_]u16{ c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| { | 1381 | try std.testing.expectEqual(case.shortcut, nativeShortcut(event, macos).?); |
| 1394 | copy_event.mod = mods; | 1382 | for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| { |
| 1395 | try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event, false).?); | 1383 | event.mod = mods | extra; |
| 1396 | } | 1384 | try std.testing.expect(nativeShortcut(event, macos) == null); |
| 1397 | for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| { | 1385 | } |
| 1398 | var excluded = copy_event; | 1386 | event.mod = mods; |
| 1399 | excluded.mod |= extra; | 1387 | event.repeat = true; |
| 1400 | try std.testing.expect(nativeShortcut(excluded, false) == null); | 1388 | try std.testing.expect(nativeShortcut(event, macos) == null); |
| 1401 | } | 1389 | event.repeat = false; |
| 1402 | copy_event.repeat = true; | 1390 | } |
| 1403 | try std.testing.expect(nativeShortcut(copy_event, false) == null); | 1391 | event.mod = c.SDL_KMOD_CTRL; |
| 1404 | } | 1392 | try std.testing.expect(nativeShortcut(event, macos) == null); |
| 1405 | 1393 | try std.testing.expect(interactionKey(event).terminal != null); | |
| 1406 | test "paste shortcuts are platform-specific and exact" { | 1394 | event.mod = c.SDL_KMOD_GUI; |
| 1407 | var event = std.mem.zeroes(c.SDL_KeyboardEvent); | 1395 | const gui_shortcut: ?NativeShortcut = if (macos and case.shortcut == .paste) .paste else null; |
| 1408 | event.key = c.SDLK_V; | 1396 | try std.testing.expectEqual(gui_shortcut, nativeShortcut(event, macos)); |
| 1409 | const ctrl_shift: u16 = @intCast(c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT); | 1397 | } |
| 1410 | event.mod = ctrl_shift; | ||
| 1411 | try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, false).?); | ||
| 1412 | try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, true).?); | ||
| 1413 | event.mod = c.SDL_KMOD_CTRL; | ||
| 1414 | try std.testing.expect(nativeShortcut(event, false) == null); | ||
| 1415 | try std.testing.expect(interactionKey(event).terminal != null); | ||
| 1416 | event.mod = c.SDL_KMOD_GUI; | ||
| 1417 | try std.testing.expect(nativeShortcut(event, false) == null); | ||
| 1418 | try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, true).?); | ||
| 1419 | for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| { | ||
| 1420 | event.mod = ctrl_shift | extra; | ||
| 1421 | try std.testing.expect(nativeShortcut(event, false) == null); | ||
| 1422 | } | 1398 | } |
| 1423 | event.mod = ctrl_shift; | ||
| 1424 | event.repeat = true; | ||
| 1425 | try std.testing.expect(nativeShortcut(event, true) == null); | ||
| 1426 | } | 1399 | } |
| 1427 | 1400 | ||
| 1428 | test "later pane atlas growth precedes earlier pane UV generation" { | 1401 | test "later pane atlas growth precedes earlier pane UV generation" { |
| @@ -1556,7 +1529,8 @@ test "modal Enter remains consumed through insertion and repeated keydown until | |||
| 1556 | const picker = try a.create(picker_mod.Picker); | 1529 | const picker = try a.create(picker_mod.Picker); |
| 1557 | picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null }; | 1530 | picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null }; |
| 1558 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1531 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 1559 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .opening = picker, .wake_ctx = &wake, .wake = Wake.discovery } }; | 1532 | var events = testEvents(&rt, &wake, metrics, 800, 600); |
| 1533 | events.ui.opening = picker; | ||
| 1560 | defer events.deinit(); | 1534 | defer events.deinit(); |
| 1561 | try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); | 1535 | try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); |
| 1562 | picker.level = .session_name; | 1536 | picker.level = .session_name; |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -880,10 +880,7 @@ test "controller preserves exact held keys across modal dismissal and text edges | |||
| 880 | try std.testing.expect(!ui.command_mode and ui.dirty and ui.suppress_text); | 880 | try std.testing.expect(!ui.command_mode and ui.dirty and ui.suppress_text); |
| 881 | try std.testing.expectEqualStrings("", ui.notice); | 881 | try std.testing.expectEqualStrings("", ui.notice); |
| 882 | try std.testing.expectEqual(@as(?u32, 115), ui.consumed_key); | 882 | try std.testing.expectEqual(@as(?u32, 115), ui.consumed_key); |
| 883 | try ui.keyDown(.{ .code = 115, .repeat = true }); | ||
| 884 | try std.testing.expect(!ui.command_mode and ui.suppress_text); | ||
| 885 | ui.keyUp(115); | 883 | ui.keyUp(115); |
| 886 | try std.testing.expect(ui.consumed_key == null and !ui.suppress_text); | ||
| 887 | try ui.keyDown(.{ .code = 102, .prefix = true }); | 884 | try ui.keyDown(.{ .code = 102, .prefix = true }); |
| 888 | try ui.keyDown(.{ .code = 114, .kind = .r }); | 885 | try ui.keyDown(.{ .code = 114, .kind = .r }); |
| 889 | try std.testing.expect(ui.resize_mode and !ui.command_mode); | 886 | try std.testing.expect(ui.resize_mode and !ui.command_mode); |
| @@ -956,7 +953,7 @@ test "quick splits are nonmodal and cancellable; picker directions remain explic | |||
| 956 | try std.testing.expectEqualStrings("TooSmall", ui.notice); | 953 | try std.testing.expectEqualStrings("TooSmall", ui.notice); |
| 957 | } | 954 | } |
| 958 | 955 | ||
| 959 | test "beginEnd records pending request without opening an ending modal" { | 956 | test "beginEnd stays nonmodal and delayed refusal respects command mode" { |
| 960 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | 957 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); |
| 961 | defer rt.deinit(); | 958 | defer rt.deinit(); |
| 962 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | 959 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; |
| @@ -969,7 +966,12 @@ test "beginEnd records pending request without opening an ending modal" { | |||
| 969 | try ui.beginEnd(rt.get(id).?.key, false); | 966 | try ui.beginEnd(rt.get(id).?.key, false); |
| 970 | try std.testing.expect(ui.recovery == null); | 967 | try std.testing.expect(ui.recovery == null); |
| 971 | try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); | 968 | try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); |
| 972 | ui.pending_end = null; | 969 | ui.command_mode = true; |
| 970 | rt.get(id).?.status.ending = .{ .request = ui.pending_end.?.request, .phase = .refused, .others = 1 }; | ||
| 971 | try ui.pollEnd(); | ||
| 972 | try std.testing.expect(ui.recovery == null); | ||
| 973 | try std.testing.expect(ui.pending_end == null); | ||
| 974 | try std.testing.expect(ui.command_mode); | ||
| 973 | } | 975 | } |
| 974 | 976 | ||
| 975 | test "wheel retains fractional deltas and emits whole notches" { | 977 | test "wheel retains fractional deltas and emits whole notches" { |
test/native_fonts.py
| Old | New | ||
|---|---|---|---|
| @@ -327,7 +327,7 @@ def main(): | |||
| 327 | rig = LifecycleRig(args.mux, args.muxg) | 327 | rig = LifecycleRig(args.mux, args.muxg) |
| 328 | try: | 328 | try: |
| 329 | exercise(rig, family=args.family, nerd=args.nerd, output=args.output) | 329 | exercise(rig, family=args.family, nerd=args.nerd, output=args.output) |
| 330 | print('PASS: native fonts;', rig.root, flush=True) | 330 | print('PASS: native fonts', flush=True) |
| 331 | finally: | 331 | finally: |
| 332 | rig.close() | 332 | rig.close() |
| 333 | 333 | ||
test/native_journey.py
| Old | New | ||
|---|---|---|---|
| @@ -15,11 +15,12 @@ import time | |||
| 15 | sys.dont_write_bytecode = True | 15 | sys.dont_write_bytecode = True |
| 16 | from native_fonts import exercise as exercise_fonts, fallback_oracle, font_match | 16 | from native_fonts import exercise as exercise_fonts, fallback_oracle, font_match |
| 17 | from native_lifecycle import start_workspace | 17 | from native_lifecycle import start_workspace |
| 18 | from native_picker import sessions | 18 | from native_picker import sessions, workflow as picker_workflow |
| 19 | from native_resize import (by_id, clamps, drag_and_keys, modal_leakage, | 19 | from native_resize import (by_id, clamps, drag_and_keys, modal_leakage, |
| 20 | pixels_and_batch, pty_sizes) | 20 | pixels_and_batch, pty_sizes) |
| 21 | from native_selection import SelectionRig, copy_shortcut, paste_shortcut | 21 | from native_selection import selection_basics |
| 22 | from native_tiling import DEADLINE, colour_counts, eventually, require | 22 | from native_tiling import DEADLINE, colour_counts, eventually, require |
| 23 | from native_wheel import WheelRig, shell_history | ||
| 23 | 24 | ||
| 24 | 25 | ||
| 25 | def release_binary(rig): | 26 | def release_binary(rig): |
| @@ -61,7 +62,11 @@ def basic_workspace(rig): | |||
| 61 | 'b pane is not beside the original pane') | 62 | 'b pane is not beside the original pane') |
| 62 | require(lower_rect['y'] >= right_rect['y'] + right_rect['h'], | 63 | require(lower_rect['y'] >= right_rect['y'] + right_rect['h'], |
| 63 | 'v pane is not below the focused pane') | 64 | 'v pane is not below the focused pane') |
| 65 | require(abs(left_rect['w'] - right_rect['w']) <= 1 and | ||
| 66 | abs(right_rect['h'] - lower_rect['h']) <= 1, | ||
| 67 | 'new splits did not divide the available space equally') | ||
| 64 | pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']]) | 68 | pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']]) |
| 69 | rig.assert_cli_untouched() | ||
| 65 | 70 | ||
| 66 | rig.focus(right) | 71 | rig.focus(right) |
| 67 | rig.chord('h') | 72 | rig.chord('h') |
| @@ -264,12 +269,15 @@ def independent_failures(rig, refs): | |||
| 264 | 269 | ||
| 265 | def main(): | 270 | def main(): |
| 266 | require(len(sys.argv) == 3, 'usage: native_journey.py RELEASE_MUX RELEASE_MUXG') | 271 | require(len(sys.argv) == 3, 'usage: native_journey.py RELEASE_MUX RELEASE_MUXG') |
| 267 | rig = SelectionRig(*sys.argv[1:]) | 272 | rig = WheelRig(*sys.argv[1:]) |
| 268 | rig.test_label = 'journey' | 273 | rig.test_label = 'journey' |
| 269 | try: | 274 | try: |
| 270 | release_binary(rig) | 275 | release_binary(rig) |
| 271 | refs = basic_workspace(rig) | 276 | refs = basic_workspace(rig) |
| 277 | picker_workflow(rig, refs) | ||
| 272 | quick_panes(rig, refs) | 278 | quick_panes(rig, refs) |
| 279 | selection_basics(rig, refs) | ||
| 280 | shell_history(rig, list(refs)) | ||
| 273 | state = rig.state() | 281 | state = rig.state() |
| 274 | identities = [(p['id'], p['generation']) for p in state['panes']] | 282 | identities = [(p['id'], p['generation']) for p in state['panes']] |
| 275 | drag_and_keys(rig, identities, refs) | 283 | drag_and_keys(rig, identities, refs) |
| @@ -277,9 +285,6 @@ def main(): | |||
| 277 | clamps(rig, identities, refs) | 285 | clamps(rig, identities, refs) |
| 278 | pixels_and_batch(rig, identities, refs) | 286 | pixels_and_batch(rig, identities, refs) |
| 279 | flood_while_responsive(rig, refs) | 287 | flood_while_responsive(rig, refs) |
| 280 | target = list(refs)[1] | ||
| 281 | copy_shortcut(rig, target) | ||
| 282 | paste_shortcut(rig, target) | ||
| 283 | 288 | ||
| 284 | family = font_family() | 289 | family = font_family() |
| 285 | exercise_fonts(rig, refs, family=family) | 290 | exercise_fonts(rig, refs, family=family) |
| @@ -288,7 +293,7 @@ def main(): | |||
| 288 | all(p['phase'] == 'attached' for p in s['panes'])) | 293 | all(p['phase'] == 'attached' for p in s['panes'])) |
| 289 | raw_snapshot_and_terminal_peer(rig, refs) | 294 | raw_snapshot_and_terminal_peer(rig, refs) |
| 290 | independent_failures(rig, refs) | 295 | independent_failures(rig, refs) |
| 291 | print(f'PASS: native journey ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True) | 296 | print(f'PASS: native journey ({rig.checkpoints} checkpoints)', flush=True) |
| 292 | except BaseException: | 297 | except BaseException: |
| 293 | rig.failure_artifacts() | 298 | rig.failure_artifacts() |
| 294 | raise | 299 | raise |
test/native_lifecycle.py
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,9 @@ from native_picker import create, end, region, sessions | |||
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | class LifecycleRig(ResizeRig): | 21 | class LifecycleRig(ResizeRig): |
| 22 | float_size = (960, 600) | ||
| 23 | float_scale = None | ||
| 24 | |||
| 22 | def __init__(self, mux, muxg): | 25 | def __init__(self, mux, muxg): |
| 23 | super().__init__(mux, muxg) | 26 | super().__init__(mux, muxg) |
| 24 | self.saved = Path(self.env['XDG_STATE_HOME']) / 'mux/native-workspace.json' | 27 | self.saved = Path(self.env['XDG_STATE_HOME']) / 'mux/native-workspace.json' |
| @@ -26,46 +29,12 @@ class LifecycleRig(ResizeRig): | |||
| 26 | self.saved.parent.mkdir(exist_ok=True) | 29 | self.saved.parent.mkdir(exist_ok=True) |
| 27 | self.cli_bytes = b'CLI-LAYOUT-UNTOUCHED\n' | 30 | self.cli_bytes = b'CLI-LAYOUT-UNTOUCHED\n' |
| 28 | self.cli_layout.write_bytes(self.cli_bytes) | 31 | self.cli_layout.write_bytes(self.cli_bytes) |
| 29 | self.gui_cwd = None | ||
| 30 | |||
| 31 | def spawn(self, argv, label, env=None, cwd=None): | ||
| 32 | if cwd is None: | ||
| 33 | return super().spawn(argv, label, env) | ||
| 34 | log = (self.root / (label + '.log')).open('wb') | ||
| 35 | self.logs.append(log) | ||
| 36 | proc = subprocess.Popen(argv, env=self.env if env is None else env, | ||
| 37 | cwd=cwd, stdout=log, stderr=subprocess.STDOUT) | ||
| 38 | self.procs.append(proc) | ||
| 39 | return proc | ||
| 40 | 32 | ||
| 41 | def failure_artifacts(self): | 33 | def failure_artifacts(self): |
| 42 | print('Native lifecycle failure artifacts:', self.root, file=sys.stderr) | 34 | print('Native lifecycle failure artifacts:', self.root, file=sys.stderr) |
| 43 | for path in self.root.glob('gui*.log'): | 35 | for path in self.root.glob('gui*.log'): |
| 44 | print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr) | 36 | print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr) |
| 45 | 37 | ||
| 46 | def launch_gui(self, args, label, attached=True): | ||
| 47 | # An empty persistent workspace has no provisional pane. Rig's original | ||
| 48 | # launch predicate assumes one pane, so only process setup is repeated. | ||
| 49 | require(self.gui is None, 'close the previous fixture window before relaunch') | ||
| 50 | fifo = self.root / (label + '.fifo') | ||
| 51 | os.mkfifo(fifo) | ||
| 52 | self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) | ||
| 53 | self.env['MUXG_TEST_FIFO'] = str(fifo) | ||
| 54 | self.gui_log = self.root / (label + '.log') | ||
| 55 | self.gui = self.spawn([self.muxg, *args], label, cwd=self.gui_cwd) | ||
| 56 | state = self.state() | ||
| 57 | if self.env['SDL_VIDEO_DRIVER'] == 'wayland' and os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1': | ||
| 58 | command = (f'[pid={self.gui.pid}] floating enable, border none, ' | ||
| 59 | 'resize set width 960 px height 600 px') | ||
| 60 | result = subprocess.run(['swaymsg', '-r', command], env=self.env, | ||
| 61 | capture_output=True, text=True, check=True, timeout=3) | ||
| 62 | require(all(item['success'] for item in json.loads(result.stdout)), 'could not float test window') | ||
| 63 | state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (960, 600)) | ||
| 64 | if attached: | ||
| 65 | state = self.wait_state(lambda s: bool(s['panes']) and | ||
| 66 | all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 67 | return state | ||
| 68 | |||
| 69 | def assert_cli_untouched(self): | 38 | def assert_cli_untouched(self): |
| 70 | require(self.cli_layout.read_bytes() == self.cli_bytes, | 39 | require(self.cli_layout.read_bytes() == self.cli_bytes, |
| 71 | 'native workspace changed terminal mux layout state') | 40 | 'native workspace changed terminal mux layout state') |
| @@ -199,8 +168,10 @@ def start_workspace(rig, *, quic=False, picker_checks=False): | |||
| 199 | rig.wait_state(lambda s: s['pending'] is None) | 168 | rig.wait_state(lambda s: s['pending'] is None) |
| 200 | rig.host(second_target, 'beside') | 169 | rig.host(second_target, 'beside') |
| 201 | rig.new_session('right') | 170 | rig.new_session('right') |
| 202 | rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) | 171 | before_split = rig.wait_state(lambda s: len(s['panes']) == 2 and |
| 172 | all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 203 | if picker_checks: | 173 | if picker_checks: |
| 174 | rig.mark(before_split['panes'][1]['id'], b, 'right', 'PICKER-RIGHT') | ||
| 204 | rig.open_picker('stacked') | 175 | rig.open_picker('stacked') |
| 205 | armed = rig.picker('hosts') | 176 | armed = rig.picker('hosts') |
| 206 | require(armed['pending']['direction'] == 'stacked', | 177 | require(armed['pending']['direction'] == 'stacked', |
| @@ -212,6 +183,17 @@ def start_workspace(rig, *, quic=False, picker_checks=False): | |||
| 212 | rig.choose('0') | 183 | rig.choose('0') |
| 213 | state = rig.wait_state(lambda s: len(s['panes']) == 3 and | 184 | state = rig.wait_state(lambda s: len(s['panes']) == 3 and |
| 214 | all(p['phase'] == 'attached' for p in s['panes'])) | 185 | all(p['phase'] == 'attached' for p in s['panes'])) |
| 186 | if picker_checks: | ||
| 187 | require(state['panes'][0]['outer'] == before_split['panes'][0]['outer'], | ||
| 188 | 'nested insertion rebalanced the unrelated pane') | ||
| 189 | rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mPICKER-%s\\033[0m\\n' EXISTING") | ||
| 190 | rig.wait_marker(a, '0', 'PICKER-EXISTING') | ||
| 191 | def inserted_pixels(): | ||
| 192 | pixels = rig.last_pixels() | ||
| 193 | green = colour_counts(pixels, state['panes'][1]['content'], 'green') | ||
| 194 | red = colour_counts(pixels, state['panes'][2]['content'], 'red') | ||
| 195 | return green[0] > 30 and red[0] > 30 and green[1] == red[1] == 0 | ||
| 196 | eventually(inserted_pixels, 'nested insertion erased or misplaced session glyphs') | ||
| 215 | refs = {state['panes'][0]['id']: (a, 'left'), state['panes'][1]['id']: (b, 'right'), | 197 | refs = {state['panes'][0]['id']: (a, 'left'), state['panes'][1]['id']: (b, 'right'), |
| 216 | state['panes'][2]['id']: (a, '0')} | 198 | state['panes'][2]['id']: (a, '0')} |
| 217 | rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']] | 199 | rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']] |
| @@ -221,7 +203,6 @@ def start_workspace(rig, *, quic=False, picker_checks=False): | |||
| 221 | expected_ids = set(refs) | 203 | expected_ids = set(refs) |
| 222 | eventually(lambda: {p['id'] for p in rig.saved_json()['tabs'][0]['panes']} == expected_ids, | 204 | eventually(lambda: {p['id'] for p in rig.saved_json()['tabs'][0]['panes']} == expected_ids, |
| 223 | 'saved workspace did not converge on all three fixture panes') | 205 | 'saved workspace did not converge on all three fixture panes') |
| 224 | rig.assert_cli_untouched() | ||
| 225 | return refs | 206 | return refs |
| 226 | 207 | ||
| 227 | 208 | ||
| @@ -343,7 +324,7 @@ def preservation_case(mux, muxg, label, original, unreadable=False): | |||
| 343 | require(rig.saved.read_bytes() == original, label + ' state was overwritten') | 324 | require(rig.saved.read_bytes() == original, label + ' state was overwritten') |
| 344 | require('unsaved' in sessions(sock), 'preservation fallback ended its live session on close') | 325 | require('unsaved' in sessions(sock), 'preservation fallback ended its live session on close') |
| 345 | rig.assert_cli_untouched() | 326 | rig.assert_cli_untouched() |
| 346 | print(f'lifecycle OK: {label} original bytes preserved;', rig.root, flush=True) | 327 | print(f'lifecycle OK: {label} original bytes preserved', flush=True) |
| 347 | except BaseException: | 328 | except BaseException: |
| 348 | rig.failure_artifacts() | 329 | rig.failure_artifacts() |
| 349 | raise | 330 | raise |
| @@ -380,7 +361,7 @@ def relative_target_reopen(mux, muxg): | |||
| 380 | rig.quit() | 361 | rig.quit() |
| 381 | rig.assert_cli_untouched() | 362 | rig.assert_cli_untouched() |
| 382 | require('relative-kept' in sessions(sock), 'relative target session did not survive reopen') | 363 | require('relative-kept' in sessions(sock), 'relative target session did not survive reopen') |
| 383 | print('lifecycle OK: relative socket survives actual changed-cwd reopen;', rig.root, flush=True) | 364 | print('lifecycle OK: relative socket survives actual changed-cwd reopen', flush=True) |
| 384 | except BaseException: | 365 | except BaseException: |
| 385 | rig.failure_artifacts() | 366 | rig.failure_artifacts() |
| 386 | raise | 367 | raise |
| @@ -515,7 +496,6 @@ def recovery_workflow(mux, muxg): | |||
| 515 | rig.assert_cli_untouched() | 496 | rig.assert_cli_untouched() |
| 516 | rig.ok('missing sessions stay missing; in-place recovery cancels old work and a returned host is usable') | 497 | rig.ok('missing sessions stay missing; in-place recovery cancels old work and a returned host is usable') |
| 517 | rig.quit() | 498 | rig.quit() |
| 518 | print('lifecycle recovery artifacts:', rig.root, flush=True) | ||
| 519 | except BaseException: | 499 | except BaseException: |
| 520 | rig.failure_artifacts() | 500 | rig.failure_artifacts() |
| 521 | raise | 501 | raise |
| @@ -659,7 +639,7 @@ def main(): | |||
| 659 | lifecycle_actions(rig, refs) | 639 | lifecycle_actions(rig, refs) |
| 660 | rig.quit() | 640 | rig.quit() |
| 661 | valid_bytes = rig.saved.read_bytes() | 641 | valid_bytes = rig.saved.read_bytes() |
| 662 | print(f'native lifecycle main OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True) | 642 | print(f'native lifecycle main OK ({rig.checkpoints} checkpoints)', flush=True) |
| 663 | except BaseException: | 643 | except BaseException: |
| 664 | rig.failure_artifacts() | 644 | rig.failure_artifacts() |
| 665 | raise | 645 | raise |
test/native_mouse.py
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,7 @@ import sys | |||
| 12 | import time | 12 | import time |
| 13 | 13 | ||
| 14 | sys.dont_write_bytecode = True | 14 | sys.dont_write_bytecode = True |
| 15 | from native_lifecycle import start_persistent | 15 | from native_lifecycle import start_workspace |
| 16 | from native_resize import by_id | 16 | from native_resize import by_id |
| 17 | from native_selection import SelectionRig | 17 | from native_selection import SelectionRig |
| 18 | from native_tiling import eventually, require | 18 | from native_tiling import eventually, require |
| @@ -190,13 +190,13 @@ def main(): | |||
| 190 | require(len(sys.argv) == 3, 'usage: native_mouse.py MUX MUXG') | 190 | require(len(sys.argv) == 3, 'usage: native_mouse.py MUX MUXG') |
| 191 | rig = MouseRig(*sys.argv[1:]) | 191 | rig = MouseRig(*sys.argv[1:]) |
| 192 | try: | 192 | try: |
| 193 | refs = start_persistent(rig) | 193 | refs = start_workspace(rig) |
| 194 | panes = list(refs) | 194 | panes = list(refs) |
| 195 | application_drag(rig, panes[1], panes[2]) | 195 | application_drag(rig, panes[1], panes[2]) |
| 196 | rig.kernel_sizes() | 196 | rig.kernel_sizes() |
| 197 | rig.assert_cli_untouched() | 197 | rig.assert_cli_untouched() |
| 198 | rig.quit() | 198 | rig.quit() |
| 199 | print('Native mouse acceptance passed; artifacts:', rig.root, flush=True) | 199 | print('Native mouse acceptance passed', flush=True) |
| 200 | except BaseException: | 200 | except BaseException: |
| 201 | rig.failure_artifacts() | 201 | rig.failure_artifacts() |
| 202 | raise | 202 | raise |
test/native_picker.py
| Old | New | ||
|---|---|---|---|
| @@ -8,7 +8,7 @@ import sys | |||
| 8 | import threading | 8 | import threading |
| 9 | import time | 9 | import time |
| 10 | 10 | ||
| 11 | from native_tiling import Rig, colour_counts, eventually, require | 11 | from native_tiling import eventually, require |
| 12 | 12 | ||
| 13 | 13 | ||
| 14 | def read_exact(stream, count): | 14 | def read_exact(stream, count): |
| @@ -130,12 +130,15 @@ class SlowHost: | |||
| 130 | "slow-host fixture leaked a worker") | 130 | "slow-host fixture leaked a worker") |
| 131 | 131 | ||
| 132 | 132 | ||
| 133 | def workflow(rig): | 133 | def workflow(rig, refs): |
| 134 | first, _ = rig.daemon("picker-a") | 134 | """Picker editing on the journey's already attached three-pane workspace.""" |
| 135 | second, _ = rig.daemon("picker-b", quic=True) | 135 | left, right, _ = refs |
| 136 | first_target, second_target = "--sock " + first, rig.targets[second] | 136 | first, _ = refs[left] |
| 137 | rig.catalogue([first_target, second_target]) | 137 | second, _ = refs[right] |
| 138 | initial = rig.launch_gui(["--sock", first, "--session", "left"], "gui-picker") | 138 | first_target = "--sock " + first |
| 139 | second_target = rig.targets.get(second, "--sock " + second) | ||
| 140 | initial = rig.state() | ||
| 141 | rig.focus(right) | ||
| 139 | original_layout = [p["outer"] for p in initial["panes"]] | 142 | original_layout = [p["outer"] for p in initial["panes"]] |
| 140 | before = sessions(second) | 143 | before = sessions(second) |
| 141 | rig.host(second_target, "beside") | 144 | rig.host(second_target, "beside") |
| @@ -146,32 +149,12 @@ def workflow(rig): | |||
| 146 | require(sessions(second) == before, "cancelling picker created a session") | 149 | require(sessions(second) == before, "cancelling picker created a session") |
| 147 | require([p["outer"] for p in state["panes"]] == original_layout, "cancel changed layout") | 150 | require([p["outer"] for p in state["panes"]] == original_layout, "cancel changed layout") |
| 148 | require(state["pending"] is not None, "closing picker lost armed direction") | 151 | require(state["pending"] is not None, "closing picker lost armed direction") |
| 149 | require("CANCELLED-NAME" not in rig.dump(first, "left"), "editor text leaked to PTY") | 152 | require(all("CANCELLED-NAME" not in rig.dump(sock, session) |
| 153 | for sock, session in refs.values()), "editor text leaked to PTY") | ||
| 150 | rig.ok("host/editor back and cancel preserve layout, session table and armed split") | 154 | rig.ok("host/editor back and cancel preserve layout, session table and armed split") |
| 151 | 155 | ||
| 152 | rig.host(second_target) | 156 | rig.key("escape") |
| 153 | rig.new_session("right") | 157 | rig.wait_state(lambda s: s["pending"] is None) |
| 154 | state = rig.wait_state(lambda s: len(s["panes"]) == 2 and not s.get("picker") and | ||
| 155 | all(p["phase"] == "attached" for p in s["panes"])) | ||
| 156 | rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mPICKER-%s\\033[0m\\n' RIGHT") | ||
| 157 | rig.wait_marker(second, "right", "PICKER-RIGHT") | ||
| 158 | require("PICKER-RIGHT" not in rig.dump(first, "left"), "new pane input reached old pane") | ||
| 159 | untouched = state["panes"][0]["outer"] | ||
| 160 | rig.host(first_target, "stacked") | ||
| 161 | rig.choose("0") | ||
| 162 | state = rig.wait_state(lambda s: len(s["panes"]) == 3 and not s.get("picker") and | ||
| 163 | all(p["phase"] == "attached" for p in s["panes"])) | ||
| 164 | require(state["panes"][0]["outer"] == untouched, "nested split rebalanced unrelated pane") | ||
| 165 | rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mPICKER-%s\\033[0m\\n' EXISTING") | ||
| 166 | rig.wait_marker(first, "0", "PICKER-EXISTING") | ||
| 167 | rig.wait_state(lambda s: "PICKER-EXISTING" in s["panes"][2]["painted_text"]) | ||
| 168 | pixels = rig.pixels() | ||
| 169 | require(colour_counts(pixels, state["panes"][2]["content"], "red")[0] > 30, | ||
| 170 | "existing session glyphs missing from nested pane framebuffer") | ||
| 171 | green = colour_counts(pixels, state["panes"][1]["content"], "green") | ||
| 172 | require(green[0] > 30 and green[1] == 0, "nested split erased or misplaced its neighbour's glyphs") | ||
| 173 | rig.ok("picker creates over QUIC and joins existing session in nested split with real input and pixels") | ||
| 174 | |||
| 175 | rig.host(first_target, "beside") | 158 | rig.host(first_target, "beside") |
| 176 | rig.choose("New session...") | 159 | rig.choose("New session...") |
| 177 | rig.picker("session_name") | 160 | rig.picker("session_name") |
| @@ -184,8 +167,15 @@ def workflow(rig): | |||
| 184 | require(len(rig.state()["panes"]) == 3, "failed tiny split mutated pane tree") | 167 | require(len(rig.state()["panes"]) == 3, "failed tiny split mutated pane tree") |
| 185 | rig.send("resize:960x600", "key:escape", "key:escape", "key:escape") | 168 | rig.send("resize:960x600", "key:escape", "key:escape", "key:escape") |
| 186 | rig.wait_state(lambda s: s["width"] == 960 and not s.get("picker")) | 169 | rig.wait_state(lambda s: s["width"] == 960 and not s.get("picker")) |
| 170 | rig.key("escape") | ||
| 171 | rig.wait_state(lambda s: s["pending"] is None) | ||
| 187 | rig.ok("window becoming too small during name entry refuses split before creating a remote shell") | 172 | rig.ok("window becoming too small during name entry refuses split before creating a remote shell") |
| 188 | 173 | ||
| 174 | |||
| 175 | def races(rig, refs): | ||
| 176 | first, left_session = refs[next(iter(refs))] | ||
| 177 | second, right_session = refs[list(refs)[1]] | ||
| 178 | first_target = "--sock " + first | ||
| 189 | # Change the server after the user has already entered a fresh name. | 179 | # Change the server after the user has already entered a fresh name. |
| 190 | rig.host(first_target, "beside") | 180 | rig.host(first_target, "beside") |
| 191 | rig.choose("New session...") | 181 | rig.choose("New session...") |
| @@ -211,7 +201,7 @@ def workflow(rig): | |||
| 211 | require("raced-name" not in sessions(first), "vanished selection recreated a shell") | 201 | require("raced-name" not in sessions(first), "vanished selection recreated a shell") |
| 212 | rig.ok("session disappearing after listing is never recreated by selection") | 202 | rig.ok("session disappearing after listing is never recreated by selection") |
| 213 | rig.quit() | 203 | rig.quit() |
| 214 | require("right" in sessions(second) and "left" in sessions(first), "GUI close ended a session") | 204 | require(right_session in sessions(second) and left_session in sessions(first), "GUI close ended a session") |
| 215 | 205 | ||
| 216 | 206 | ||
| 217 | def add_host(rig): | 207 | def add_host(rig): |
| @@ -332,14 +322,15 @@ def old_daemon(rig, binary): | |||
| 332 | 322 | ||
| 333 | 323 | ||
| 334 | def main(): | 324 | def main(): |
| 335 | rig = Rig(*sys.argv[1:3]) | 325 | from native_lifecycle import LifecycleRig, start_workspace |
| 326 | rig = LifecycleRig(*sys.argv[1:3]) | ||
| 336 | try: | 327 | try: |
| 337 | workflow(rig) | 328 | races(rig, start_workspace(rig, quic=True)) |
| 338 | add_host(rig) | 329 | add_host(rig) |
| 339 | slow_and_legacy(rig) | 330 | slow_and_legacy(rig) |
| 340 | if binary := os.environ.get("MUXG_OLD_DAEMON"): | 331 | if binary := os.environ.get("MUXG_OLD_DAEMON"): |
| 341 | old_daemon(rig, binary) | 332 | old_daemon(rig, binary) |
| 342 | print(f"native picker OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}") | 333 | print(f"native picker OK ({rig.checkpoints} checkpoints)") |
| 343 | except BaseException: | 334 | except BaseException: |
| 344 | print("Native picker failure artifacts:", rig.root, file=sys.stderr) | 335 | print("Native picker failure artifacts:", rig.root, file=sys.stderr) |
| 345 | raise | 336 | raise |
test/native_resize.py
| Old | New | ||
|---|---|---|---|
| @@ -1,12 +1,10 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Real SDL divider input, three daemon PTYs, and nonforcing frame oracles.""" | 2 | """Real SDL divider input, three daemon PTYs, and nonforcing frame oracles.""" |
| 3 | import fcntl | 3 | import fcntl |
| 4 | import json | ||
| 5 | import os | 4 | import os |
| 6 | from pathlib import Path | 5 | from pathlib import Path |
| 7 | import shlex | 6 | import shlex |
| 8 | import struct | 7 | import struct |
| 9 | import subprocess | ||
| 10 | import sys | 8 | import sys |
| 11 | import termios | 9 | import termios |
| 12 | 10 | ||
| @@ -15,29 +13,14 @@ from native_tiling import Rig, colour_counts, eventually, require | |||
| 15 | 13 | ||
| 16 | 14 | ||
| 17 | class ResizeRig(Rig): | 15 | class ResizeRig(Rig): |
| 16 | float_size = (1000, 650) | ||
| 17 | float_scale = 2 | ||
| 18 | |||
| 18 | def __init__(self, mux, muxg): | 19 | def __init__(self, mux, muxg): |
| 19 | super().__init__(mux, muxg) | 20 | super().__init__(mux, muxg) |
| 20 | self.env['MUXG_TEST_RETAIN_FRAME'] = '1' | 21 | self.env['MUXG_TEST_RETAIN_FRAME'] = '1' |
| 21 | self.tty_paths = {} | 22 | self.tty_paths = {} |
| 22 | 23 | ||
| 23 | def launch_gui(self, args, label, attached=True): | ||
| 24 | state = super().launch_gui(args, label, attached) | ||
| 25 | if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1': | ||
| 26 | require(self.env['SDL_VIDEO_DRIVER'] == 'wayland', 'Sway floating requires Wayland') | ||
| 27 | # Remove only this fixture's compositor decorations so requested | ||
| 28 | # dimensions describe the SDL client area, as in the offscreen run. | ||
| 29 | command = (f'[pid={self.gui.pid}] floating enable, border none, ' | ||
| 30 | 'resize set width 1000 px height 650 px') | ||
| 31 | result = subprocess.run(['swaymsg', '-r', command], env=self.env, | ||
| 32 | capture_output=True, text=True, check=True, timeout=3) | ||
| 33 | require(all(item['success'] for item in json.loads(result.stdout)), | ||
| 34 | 'could not float this test window') | ||
| 35 | state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (1000, 650)) | ||
| 36 | require(state['width'] == state['logical_width'] * 2 and | ||
| 37 | state['height'] == state['logical_height'] * 2, | ||
| 38 | 'Wayland resize acceptance requires the expected 200% density') | ||
| 39 | return state | ||
| 40 | |||
| 41 | def logical(self, state, x, y): | 24 | def logical(self, state, x, y): |
| 42 | return (x * state['logical_width'] / state['width'], | 25 | return (x * state['logical_width'] / state['width'], |
| 43 | y * state['logical_height'] / state['height']) | 26 | y * state['logical_height'] / state['height']) |
| @@ -131,33 +114,6 @@ def pty_sizes(rig, sessions, identities): | |||
| 131 | require((observed['rows'], observed['cols']) == expected, 'daemon dimension mismatch') | 114 | require((observed['rows'], observed['cols']) == expected, 'daemon dimension mismatch') |
| 132 | 115 | ||
| 133 | 116 | ||
| 134 | def build_nested(rig): | ||
| 135 | a, _ = rig.daemon('resize-left') | ||
| 136 | b, _ = rig.daemon('resize-upper') | ||
| 137 | c, _ = rig.daemon('resize-lower') | ||
| 138 | rig.catalogue(['--sock ' + sock for sock in (a, b, c)]) | ||
| 139 | rig.launch_gui(['--sock', a, '--session', 'left'], 'gui-resize') | ||
| 140 | rig.host('--sock ' + b, direction='beside') | ||
| 141 | rig.new_session('upper') | ||
| 142 | rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 143 | rig.host('--sock ' + c, direction='stacked') | ||
| 144 | rig.new_session('lower') | ||
| 145 | state = rig.wait_state(lambda s: len(s['panes']) == 3 and all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 146 | identities = [(p['id'], p['generation']) for p in state['panes']] | ||
| 147 | rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']] | ||
| 148 | sessions = {identities[0][0]: (a, 'left'), identities[1][0]: (b, 'upper'), | ||
| 149 | identities[2][0]: (c, 'lower')} | ||
| 150 | for pane_id in sessions: | ||
| 151 | rig.focus(pane_id) | ||
| 152 | path = rig.root / f'tty-{pane_id}' | ||
| 153 | rig.shell('tty > ' + shlex.quote(str(path))) | ||
| 154 | eventually(lambda: path.exists() and path.stat().st_size > 0, 'tty path did not arrive') | ||
| 155 | rig.tty_paths[pane_id] = path.read_text().strip() | ||
| 156 | pty_sizes(rig, sessions, identities) | ||
| 157 | rig.ok('three nested panes have stable identities and independently measured PTY sizes') | ||
| 158 | return identities, sessions | ||
| 159 | |||
| 160 | |||
| 161 | def drag_and_keys(rig, identities, sessions): | 117 | def drag_and_keys(rig, identities, sessions): |
| 162 | state = rig.state() | 118 | state = rig.state() |
| 163 | dx = state['cell_w'] * 5 | 119 | dx = state['cell_w'] * 5 |
| @@ -231,7 +187,7 @@ def clamps(rig, identities, sessions): | |||
| 231 | pty_sizes(rig, sessions, identities) | 187 | pty_sizes(rig, sessions, identities) |
| 232 | before_tiny = state | 188 | before_tiny = state |
| 233 | rig.send('resize:8x8') | 189 | rig.send('resize:8x8') |
| 234 | if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1': | 190 | if rig.env['SDL_VIDEO_DRIVER'] == 'wayland': |
| 235 | state = rig.wait_state(lambda s: s['logical_width'] <= 100 and s['logical_height'] <= 100) | 191 | state = rig.wait_state(lambda s: s['logical_width'] <= 100 and s['logical_height'] <= 100) |
| 236 | if (state['logical_width'], state['logical_height']) != (8, 8): | 192 | if (state['logical_width'], state['logical_height']) != (8, 8): |
| 237 | print('resize note: Sway minimum clamped requested 8x8 to ' | 193 | print('resize note: Sway minimum clamped requested 8x8 to ' |
| @@ -301,34 +257,24 @@ def pixels_and_batch(rig, identities, sessions): | |||
| 301 | pty_sizes(rig, sessions, identities) | 257 | pty_sizes(rig, sessions, identities) |
| 302 | rig.ok('batched drag release, window resize and input preserve final PTY size ordering') | 258 | rig.ok('batched drag release, window resize and input preserve final PTY size ordering') |
| 303 | 259 | ||
| 304 | 260 | rig.send('resize:640x400') | |
| 305 | def main(): | 261 | rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (640, 400)) |
| 306 | require(len(sys.argv) == 3, 'usage: native_resize.py MUX MUXG') | 262 | pty_sizes(rig, sessions, identities) |
| 307 | rig = ResizeRig(*sys.argv[1:3]) | 263 | eventually(clean_regions, 'window shrink misplaced coloured pane pixels') |
| 308 | try: | 264 | rig.focus(identities[0][0]) |
| 309 | version = subprocess.run([rig.muxg, '--version'], capture_output=True, text=True, check=True) | 265 | rig.shell("printf '\\033[0m\\033[2J\\033[H'") |
| 310 | require(any(mode in version.stdout + version.stderr for mode in ('ReleaseSafe', 'ReleaseFast')), | 266 | def cleared(): |
| 311 | 'resize acceptance requires a release build') | 267 | state = rig.state() |
| 312 | identities, sessions = build_nested(rig) | 268 | capture = rig.last_pixels() |
| 313 | drag_and_keys(rig, identities, sessions) | 269 | if sum(colour_counts(capture, by_id(state)[identities[0][0]]['content'], 'red')): |
| 314 | modal_leakage(rig, identities) | 270 | return False |
| 315 | clamps(rig, identities, sessions) | 271 | for index, colour in ((1, 'green'), (2, 'blue')): |
| 316 | pixels_and_batch(rig, identities, sessions) | 272 | inside, outside = colour_counts(capture, by_id(state)[identities[index][0]]['content'], colour) |
| 317 | rig.quit() | 273 | if inside < 100 or outside: |
| 318 | for sock, session in sessions.values(): | 274 | return False |
| 319 | require(rig.command('a', 'status', '--sock', sock, '--session', session, | 275 | return True |
| 320 | '--timeout', '1000', check=False).returncode == 0, | 276 | eventually(cleared, 'clear after window shrink left old pixels or erased a neighbour') |
| 321 | 'window close ended a resized session') | 277 | rig.send('resize:800x520') |
| 322 | rig.ok('window close preserves all three resized sessions') | 278 | rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (800, 520)) |
| 323 | print(f'native resize OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True) | 279 | pty_sizes(rig, sessions, identities) |
| 324 | except BaseException: | 280 | rig.ok('window shrink and terminal clear erase old pixels without erasing neighbours') |
| 325 | print('Native resize failure artifacts:', rig.root, file=sys.stderr) | ||
| 326 | for path in rig.root.glob('gui*.log'): | ||
| 327 | print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr) | ||
| 328 | raise | ||
| 329 | finally: | ||
| 330 | rig.close() | ||
| 331 | |||
| 332 | |||
| 333 | if __name__ == '__main__': | ||
| 334 | main() | ||
test/native_scale.py
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,7 @@ import subprocess | |||
| 12 | import sys | 12 | import sys |
| 13 | 13 | ||
| 14 | sys.dont_write_bytecode = True | 14 | sys.dont_write_bytecode = True |
| 15 | from native_resize import (ResizeRig, build_nested, drag_and_keys, pty_sizes, | 15 | from native_resize import (clamps, drag_and_keys, modal_leakage, pty_sizes, |
| 16 | pixels_and_batch, edge, one_divider) | 16 | pixels_and_batch, edge, one_divider) |
| 17 | from native_tiling import require | 17 | from native_tiling import require |
| 18 | 18 | ||
| @@ -24,6 +24,7 @@ def sway(*args): | |||
| 24 | 24 | ||
| 25 | 25 | ||
| 26 | def main(): | 26 | def main(): |
| 27 | from native_lifecycle import LifecycleRig, start_workspace | ||
| 27 | parser = argparse.ArgumentParser(description=__doc__) | 28 | parser = argparse.ArgumentParser(description=__doc__) |
| 28 | parser.add_argument('mux') | 29 | parser.add_argument('mux') |
| 29 | parser.add_argument('muxg') | 30 | parser.add_argument('muxg') |
| @@ -42,16 +43,20 @@ def main(): | |||
| 42 | 'compositor refused scale change') | 43 | 'compositor refused scale change') |
| 43 | 44 | ||
| 44 | os.environ['MUXG_VIDEODRIVER'] = 'wayland' | 45 | os.environ['MUXG_VIDEODRIVER'] = 'wayland' |
| 45 | rig = ResizeRig(args.mux, args.muxg) | 46 | rig = LifecycleRig(args.mux, args.muxg) |
| 47 | rig.float_size, rig.float_scale = (1000, 650), 2 | ||
| 46 | try: | 48 | try: |
| 47 | scale(2) | 49 | scale(2) |
| 48 | identities, sessions = build_nested(rig) | 50 | sessions = start_workspace(rig) |
| 51 | identities = [(p['id'], p['generation']) for p in rig.state()['panes']] | ||
| 49 | command = (f'[pid={rig.gui.pid}] move container to output {args.output}, ' | 52 | command = (f'[pid={rig.gui.pid}] move container to output {args.output}, ' |
| 50 | 'floating enable, border none, resize set width 1000 px height 650 px') | 53 | 'floating enable, border none, resize set width 1000 px height 650 px') |
| 51 | require(all(r['success'] for r in sway(command)), 'could not place owned window') | 54 | require(all(r['success'] for r in sway(command)), 'could not place owned window') |
| 52 | rig.wait_state(lambda s: s['logical_width'] == 1000 and s['logical_height'] == 650 | 55 | rig.wait_state(lambda s: s['logical_width'] == 1000 and s['logical_height'] == 650 |
| 53 | and s['width'] == 2 * s['logical_width']) | 56 | and s['width'] == 2 * s['logical_width']) |
| 54 | drag_and_keys(rig, identities, sessions) | 57 | drag_and_keys(rig, identities, sessions) |
| 58 | modal_leakage(rig, identities) | ||
| 59 | clamps(rig, identities, sessions) | ||
| 55 | pixels_and_batch(rig, identities, sessions) | 60 | pixels_and_batch(rig, identities, sessions) |
| 56 | for value in (1, 1.5, 2): | 61 | for value in (1, 1.5, 2): |
| 57 | state = rig.state() | 62 | state = rig.state() |
| @@ -72,12 +77,12 @@ def main(): | |||
| 72 | (rig.root / 'scale-final.json').write_text(json.dumps(rig.state(), indent=2)) | 77 | (rig.root / 'scale-final.json').write_text(json.dumps(rig.state(), indent=2)) |
| 73 | rig.quit() | 78 | rig.quit() |
| 74 | print('PASS: Wayland 200% -> 100% -> 150% -> 200%; drag cancellation, ' | 79 | print('PASS: Wayland 200% -> 100% -> 150% -> 200%; drag cancellation, ' |
| 75 | 'nested resize, PTYs and pixels;', rig.root, flush=True) | 80 | 'nested resize, PTYs and pixels', flush=True) |
| 76 | finally: | 81 | finally: |
| 77 | try: | 82 | try: |
| 78 | rig.close() | ||
| 79 | finally: | ||
| 80 | scale(original_scale) | 83 | scale(original_scale) |
| 84 | finally: | ||
| 85 | rig.close() | ||
| 81 | 86 | ||
| 82 | 87 | ||
| 83 | if __name__ == '__main__': | 88 | if __name__ == '__main__': |
test/native_selection.py
| Old | New | ||
|---|---|---|---|
| @@ -8,7 +8,7 @@ import sys | |||
| 8 | import time | 8 | import time |
| 9 | 9 | ||
| 10 | sys.dont_write_bytecode = True | 10 | sys.dont_write_bytecode = True |
| 11 | from native_lifecycle import LifecycleRig, start_persistent | 11 | from native_lifecycle import LifecycleRig, start_workspace |
| 12 | from native_resize import by_id | 12 | from native_resize import by_id |
| 13 | from native_theme import pixel | 13 | from native_theme import pixel |
| 14 | from native_tiling import eventually, require | 14 | from native_tiling import eventually, require |
| @@ -36,13 +36,13 @@ class SelectionRig(LifecycleRig): | |||
| 36 | else: | 36 | else: |
| 37 | super().send(line) | 37 | super().send(line) |
| 38 | 38 | ||
| 39 | def close(self): | 39 | def close(self, success=None): |
| 40 | try: | 40 | try: |
| 41 | if self.pointer is not None: | 41 | if self.pointer is not None: |
| 42 | self.pointer.close() | 42 | self.pointer.close() |
| 43 | self.pointer = None | 43 | self.pointer = None |
| 44 | finally: | 44 | finally: |
| 45 | super().close() | 45 | super().close(success=success) |
| 46 | 46 | ||
| 47 | def clipboard(self): | 47 | def clipboard(self): |
| 48 | value = self.artifact('clipboard', '.txt').read_text() | 48 | value = self.artifact('clipboard', '.txt').read_text() |
| @@ -205,7 +205,7 @@ def start_paste_reader(rig, pane): | |||
| 205 | ' with received.open("ab") as out: out.write(data)\n' | 205 | ' with received.open("ab") as out: out.write(data)\n' |
| 206 | 'finally:\n' | 206 | 'finally:\n' |
| 207 | ' termios.tcsetattr(fd, termios.TCSADRAIN, old)\n' | 207 | ' termios.tcsetattr(fd, termios.TCSADRAIN, old)\n' |
| 208 | ' print("\\r\\nPASTE-DONE", flush=True)\n') | 208 | ' print("\\033[?2004l\\r\\nPASTE-DONE", flush=True)\n') |
| 209 | rig.focus(pane) | 209 | rig.focus(pane) |
| 210 | rig.shell('python3 ' + shlex.quote(str(source))) | 210 | rig.shell('python3 ' + shlex.quote(str(source))) |
| 211 | rig.wait_state(lambda state: 'PASTE-READY' in by_id(state)[pane]['painted_text']) | 211 | rig.wait_state(lambda state: 'PASTE-READY' in by_id(state)[pane]['painted_text']) |
| @@ -370,15 +370,13 @@ def active_output(rig, pane): | |||
| 370 | rig.wait_state(lambda state: 'COUNTER-DONE' in by_id(state)[pane]['painted_text']) | 370 | rig.wait_state(lambda state: 'COUNTER-DONE' in by_id(state)[pane]['painted_text']) |
| 371 | 371 | ||
| 372 | 372 | ||
| 373 | def exercise(rig): | 373 | def selection_basics(rig, refs): |
| 374 | refs = start_persistent(rig) | 374 | """Nondestructive clipboard and pointer checks on the shared workspace.""" |
| 375 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) | ||
| 376 | panes = list(refs) | 375 | panes = list(refs) |
| 377 | active_output(rig, panes[1]) | ||
| 378 | wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} | ||
| 379 | target, neighbour = panes[1], panes[2] | 376 | target, neighbour = panes[1], panes[2] |
| 380 | copy_shortcut(rig, target) | 377 | copy_shortcut(rig, target) |
| 381 | paste_shortcut(rig, target) | 378 | paste_shortcut(rig, target) |
| 379 | wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} | ||
| 382 | rig.focus(panes[0]) | 380 | rig.focus(panes[0]) |
| 383 | state = rig.state() | 381 | state = rig.state() |
| 384 | before = {p: cell_background(rig, state, p, 1, 1) for p in panes} | 382 | before = {p: cell_background(rig, state, p, 1, 1) for p in panes} |
| @@ -455,6 +453,15 @@ def exercise(rig): | |||
| 455 | rig.select(target, (0, 1), (4, 1)) | 453 | rig.select(target, (0, 1), (4, 1)) |
| 456 | rig.copied('alpha') | 454 | rig.copied('alpha') |
| 457 | 455 | ||
| 456 | |||
| 457 | def selection_races(rig, refs): | ||
| 458 | """Real paused-daemon replies, scale cancellation, and destructive detach.""" | ||
| 459 | panes = list(refs) | ||
| 460 | target, neighbour = panes[1], panes[2] | ||
| 461 | active_output(rig, target) | ||
| 462 | specimen(rig, target, 'RACES') | ||
| 463 | rig.select(target, (0, 1), (4, 1)) | ||
| 464 | rig.copied('alpha') | ||
| 458 | # Pause only this fixture's real daemon. The queued selection is still | 465 | # Pause only this fixture's real daemon. The queued selection is still |
| 459 | # decoded/extracted by that daemon when resumed, not by a protocol mock. | 466 | # decoded/extracted by that daemon when resumed, not by a protocol mock. |
| 460 | sock = refs[target][0] | 467 | sock = refs[target][0] |
| @@ -521,9 +528,14 @@ def main(): | |||
| 521 | require(len(sys.argv) == 3, 'usage: native_selection.py MUX MUXG') | 528 | require(len(sys.argv) == 3, 'usage: native_selection.py MUX MUXG') |
| 522 | rig = SelectionRig(*sys.argv[1:]) | 529 | rig = SelectionRig(*sys.argv[1:]) |
| 523 | try: | 530 | try: |
| 524 | exercise(rig) | 531 | refs = start_workspace(rig) |
| 532 | # Offscreen basics belong to the journey; Wayland also checks the | ||
| 533 | # compositor clipboard through an independent wl-paste client. | ||
| 534 | if rig.env['SDL_VIDEO_DRIVER'] == 'wayland': | ||
| 535 | selection_basics(rig, refs) | ||
| 536 | selection_races(rig, refs) | ||
| 525 | rig.quit() | 537 | rig.quit() |
| 526 | print('Native selection acceptance passed; artifacts:', rig.root, flush=True) | 538 | print('Native selection acceptance passed', flush=True) |
| 527 | except BaseException: | 539 | except BaseException: |
| 528 | rig.failure_artifacts() | 540 | rig.failure_artifacts() |
| 529 | raise | 541 | raise |
test/native_selection_follow.py
| Old | New | ||
|---|---|---|---|
| @@ -11,7 +11,7 @@ import sys | |||
| 11 | import time | 11 | import time |
| 12 | 12 | ||
| 13 | sys.dont_write_bytecode = True | 13 | sys.dont_write_bytecode = True |
| 14 | from native_lifecycle import start_persistent | 14 | from native_lifecycle import start_workspace |
| 15 | from native_resize import by_id | 15 | from native_resize import by_id |
| 16 | from native_selection import SelectionRig, cell_background | 16 | from native_selection import SelectionRig, cell_background |
| 17 | from native_tiling import eventually, require | 17 | from native_tiling import eventually, require |
| @@ -211,8 +211,7 @@ def invalidation(rig, target, control, neighbour): | |||
| 211 | 211 | ||
| 212 | 212 | ||
| 213 | def exercise(rig): | 213 | def exercise(rig): |
| 214 | refs = start_persistent(rig) | 214 | refs = start_workspace(rig) |
| 215 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) | ||
| 216 | panes = list(refs) | 215 | panes = list(refs) |
| 217 | target, neighbour = panes[1], panes[2] | 216 | target, neighbour = panes[1], panes[2] |
| 218 | shell_primary_scroll(rig, target, neighbour) | 217 | shell_primary_scroll(rig, target, neighbour) |
| @@ -227,7 +226,7 @@ def main(): | |||
| 227 | try: | 226 | try: |
| 228 | exercise(rig) | 227 | exercise(rig) |
| 229 | rig.quit() | 228 | rig.quit() |
| 230 | print('Native selection-follow acceptance passed; artifacts:', rig.root, flush=True) | 229 | print('Native selection-follow acceptance passed', flush=True) |
| 231 | except BaseException: | 230 | except BaseException: |
| 232 | rig.failure_artifacts() | 231 | rig.failure_artifacts() |
| 233 | raise | 232 | raise |
test/native_ssh.py
| Old | New | ||
|---|---|---|---|
| @@ -244,7 +244,7 @@ def main(): | |||
| 244 | ssh.stop() | 244 | ssh.stop() |
| 245 | finally: | 245 | finally: |
| 246 | r.close() | 246 | r.close() |
| 247 | print('Real SSH native checks passed; owned fixtures stopped. Evidence:', r.root, flush=True) | 247 | print('Real SSH native checks passed; owned fixtures stopped.', flush=True) |
| 248 | 248 | ||
| 249 | 249 | ||
| 250 | if __name__ == '__main__': | 250 | if __name__ == '__main__': |
test/native_stress.py
| Old | New | ||
|---|---|---|---|
| @@ -15,9 +15,9 @@ import subprocess | |||
| 15 | import sys | 15 | import sys |
| 16 | import time | 16 | import time |
| 17 | 17 | ||
| 18 | from native_lifecycle import LifecycleRig, start_persistent | 18 | from native_lifecycle import LifecycleRig, start_workspace |
| 19 | from native_resize import by_id | 19 | from native_resize import by_id |
| 20 | from native_tiling import eventually, require | 20 | from native_tiling import eventually, output_flood, require |
| 21 | 21 | ||
| 22 | 22 | ||
| 23 | class StressRig(LifecycleRig): | 23 | class StressRig(LifecycleRig): |
| @@ -81,7 +81,8 @@ def main(): | |||
| 81 | require('ReleaseSafe' in version or 'ReleaseFast' in version, 'stress requires release binaries') | 81 | require('ReleaseSafe' in version or 'ReleaseFast' in version, 'stress requires release binaries') |
| 82 | report['binaries'] = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() | 82 | report['binaries'] = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() |
| 83 | for path in (r.mux, r.muxg)} | 83 | for path in (r.mux, r.muxg)} |
| 84 | refs = start_persistent(r) | 84 | refs = start_workspace(r) |
| 85 | output_flood(r, refs) | ||
| 85 | ids = list(refs) | 86 | ids = list(refs) |
| 86 | flooded, separate, shared = ids | 87 | flooded, separate, shared = ids |
| 87 | report['panes'] = {'flooded': flooded, 'separate_daemon': separate, 'shared_daemon': shared} | 88 | report['panes'] = {'flooded': flooded, 'separate_daemon': separate, 'shared_daemon': shared} |
| @@ -136,7 +137,7 @@ def main(): | |||
| 136 | r.assert_cli_untouched() | 137 | r.assert_cli_untouched() |
| 137 | report['passed'] = True | 138 | report['passed'] = True |
| 138 | print(json.dumps(report, indent=2), flush=True) | 139 | print(json.dumps(report, indent=2), flush=True) |
| 139 | print('native stress OK; report:', report_path, flush=True) | 140 | print('native stress OK', flush=True) |
| 140 | except BaseException: | 141 | except BaseException: |
| 141 | r.failure_artifacts() | 142 | r.failure_artifacts() |
| 142 | raise | 143 | raise |
| @@ -147,9 +148,9 @@ def main(): | |||
| 147 | except ProcessLookupError: | 148 | except ProcessLookupError: |
| 148 | pass | 149 | pass |
| 149 | try: | 150 | try: |
| 150 | r.close() | ||
| 151 | finally: | ||
| 152 | report_path.write_text(json.dumps(report, indent=2) + '\n') | 151 | report_path.write_text(json.dumps(report, indent=2) + '\n') |
| 152 | finally: | ||
| 153 | r.close() | ||
| 153 | 154 | ||
| 154 | 155 | ||
| 155 | if __name__ == '__main__': | 156 | if __name__ == '__main__': |
test/native_theme.py
| Old | New | ||
|---|---|---|---|
| @@ -4,7 +4,7 @@ import shlex | |||
| 4 | import sys | 4 | import sys |
| 5 | 5 | ||
| 6 | sys.dont_write_bytecode = True | 6 | sys.dont_write_bytecode = True |
| 7 | from native_resize import ResizeRig, build_nested, by_id, pty_sizes | 7 | from native_resize import by_id, pty_sizes |
| 8 | from native_tiling import eventually, require | 8 | from native_tiling import eventually, require |
| 9 | 9 | ||
| 10 | 10 | ||
| @@ -101,11 +101,14 @@ def modal_samples(rig, kind): | |||
| 101 | 101 | ||
| 102 | 102 | ||
| 103 | def main(): | 103 | def main(): |
| 104 | from native_lifecycle import LifecycleRig, start_workspace | ||
| 104 | require(len(sys.argv) == 3, 'usage: native_theme.py MUX MUXG') | 105 | require(len(sys.argv) == 3, 'usage: native_theme.py MUX MUXG') |
| 105 | rig = ResizeRig(*sys.argv[1:]) | 106 | rig = LifecycleRig(*sys.argv[1:]) |
| 107 | rig.float_size, rig.float_scale = (1000, 650), 2 | ||
| 106 | rig.env['MUXG_TEST_THEME'] = 'trial' | 108 | rig.env['MUXG_TEST_THEME'] = 'trial' |
| 107 | try: | 109 | try: |
| 108 | identities, sessions = build_nested(rig) | 110 | sessions = start_workspace(rig) |
| 111 | identities = [(p['id'], p['generation']) for p in rig.state()['panes']] | ||
| 109 | for pane_id, (sock, session) in sessions.items(): | 112 | for pane_id, (sock, session) in sessions.items(): |
| 110 | paint_specimen(rig, pane_id, sock, session) | 113 | paint_specimen(rig, pane_id, sock, session) |
| 111 | for focused in sessions: | 114 | for focused in sessions: |
| @@ -155,7 +158,7 @@ def main(): | |||
| 155 | 'detaching removed the daemon session') | 158 | 'detaching removed the daemon session') |
| 156 | rig.ok('empty workspace and Add pane are themed; detached sessions survive') | 159 | rig.ok('empty workspace and Add pane are themed; detached sessions survive') |
| 157 | rig.quit() | 160 | rig.quit() |
| 158 | print('PASS: native appearance;', rig.root, flush=True) | 161 | print('PASS: native appearance', flush=True) |
| 159 | finally: | 162 | finally: |
| 160 | rig.close() | 163 | rig.close() |
| 161 | 164 | ||
test/native_theme_config.py
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,7 @@ import sys | |||
| 10 | 10 | ||
| 11 | sys.dont_write_bytecode = True | 11 | sys.dont_write_bytecode = True |
| 12 | from native_fonts import write_config | 12 | from native_fonts import write_config |
| 13 | from native_lifecycle import LifecycleRig, start_persistent | 13 | from native_lifecycle import LifecycleRig, start_workspace |
| 14 | from native_resize import by_id | 14 | from native_resize import by_id |
| 15 | from native_theme import check_samples, edge_sample | 15 | from native_theme import check_samples, edge_sample |
| 16 | from native_tiling import eventually, require | 16 | from native_tiling import eventually, require |
| @@ -131,11 +131,10 @@ def main(): | |||
| 131 | outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs'])) | 131 | outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs'])) |
| 132 | original_scale = next(o['scale'] for o in outputs if o['name'] == args.output) | 132 | original_scale = next(o['scale'] for o in outputs if o['name'] == args.output) |
| 133 | scale(2) | 133 | scale(2) |
| 134 | refs = start_persistent(rig) | 134 | refs = start_workspace(rig) |
| 135 | state = rig.state() | 135 | state = rig.state() |
| 136 | check_samples(rig, [edge_sample('default background', p['content'], '101010') | 136 | check_samples(rig, [edge_sample('default background', p['content'], '101010') |
| 137 | for p in state['panes']], 'fresh no-config legacy appearance') | 137 | for p in state['panes']], 'fresh no-config legacy appearance') |
| 138 | rig.drag('stacked', dy=state['cell_h']*4) | ||
| 139 | ids = shell_ids(rig, refs) | 138 | ids = shell_ids(rig, refs) |
| 140 | cfg = write_config(rig, '') | 139 | cfg = write_config(rig, '') |
| 141 | themes = cfg.parent / 'themes'; themes.mkdir() | 140 | themes = cfg.parent / 'themes'; themes.mkdir() |
| @@ -236,10 +235,13 @@ def main(): | |||
| 236 | rig.assert_cli_untouched() | 235 | rig.assert_cli_untouched() |
| 237 | rig.ok('HOME lookup, unsupported warnings, fatal malformed values/paths, unchanged workspace and help verified') | 236 | rig.ok('HOME lookup, unsupported warnings, fatal malformed values/paths, unchanged workspace and help verified') |
| 238 | (rig.root / 'theme-config-result.json').write_text(json.dumps({'checks': rig.checkpoints, 'shell_ids': ids}, indent=2)) | 237 | (rig.root / 'theme-config-result.json').write_text(json.dumps({'checks': rig.checkpoints, 'shell_ids': ids}, indent=2)) |
| 239 | print('PASS: native theme config;', rig.root, flush=True) | 238 | print('PASS: native theme config', flush=True) |
| 240 | finally: | 239 | finally: |
| 241 | rig.close() | 240 | try: |
| 242 | if original_scale is not None: scale(original_scale) | 241 | if original_scale is not None: |
| 242 | scale(original_scale) | ||
| 243 | finally: | ||
| 244 | rig.close() | ||
| 243 | 245 | ||
| 244 | 246 | ||
| 245 | if __name__ == '__main__': | 247 | if __name__ == '__main__': |
test/native_tiling.py
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Two independent daemons through the native client's real SDL event paths. | 2 | """Shared real-SDL fixture and focused unslept output-flood acceptance. |
| 3 | 3 | ||
| 4 | The state hook describes geometry; daemon grids, PTY dimensions, and framebuffer | 4 | The state hook describes geometry; daemon grids, PTY dimensions, and framebuffer |
| 5 | pixels independently establish that input and rendering use that geometry. | 5 | pixels independently establish that input and rendering use that geometry. |
| @@ -9,6 +9,7 @@ import os | |||
| 9 | from pathlib import Path | 9 | from pathlib import Path |
| 10 | import re | 10 | import re |
| 11 | import shlex | 11 | import shlex |
| 12 | import shutil | ||
| 12 | import signal | 13 | import signal |
| 13 | import socket | 14 | import socket |
| 14 | import subprocess | 15 | import subprocess |
| @@ -37,9 +38,13 @@ def eventually(probe, message, seconds=DEADLINE): | |||
| 37 | 38 | ||
| 38 | 39 | ||
| 39 | class Rig: | 40 | class Rig: |
| 41 | float_size = (960, 600) | ||
| 42 | float_scale = None | ||
| 43 | |||
| 40 | def __init__(self, mux, muxg): | 44 | def __init__(self, mux, muxg): |
| 41 | self.mux, self.muxg = str(Path(mux).resolve()), str(Path(muxg).resolve()) | 45 | self.mux, self.muxg = str(Path(mux).resolve()), str(Path(muxg).resolve()) |
| 42 | self.root = Path(tempfile.mkdtemp(prefix="muxg-tiling-")) | 46 | self.root = Path(tempfile.mkdtemp(prefix="muxg-tiling-")) |
| 47 | self._owned_root = self.root | ||
| 43 | self.env = os.environ.copy() | 48 | self.env = os.environ.copy() |
| 44 | # Preserve the compositor address before isolating runtime state. | 49 | # Preserve the compositor address before isolating runtime state. |
| 45 | self.env["WAYLAND_DISPLAY"] = str(Path(self.env.get("XDG_RUNTIME_DIR", "/tmp")) / | 50 | self.env["WAYLAND_DISPLAY"] = str(Path(self.env.get("XDG_RUNTIME_DIR", "/tmp")) / |
| @@ -54,16 +59,17 @@ class Rig: | |||
| 54 | self.procs, self.logs, self.daemons = [], [], [] | 59 | self.procs, self.logs, self.daemons = [], [], [] |
| 55 | self.targets = {} | 60 | self.targets = {} |
| 56 | self.gui = None | 61 | self.gui = None |
| 62 | self.gui_cwd = None | ||
| 57 | self.fd = None | 63 | self.fd = None |
| 58 | self.serial = 0 | 64 | self.serial = 0 |
| 59 | self.checkpoints = 0 | 65 | self.checkpoints = 0 |
| 60 | self.test_label = "tiling" | 66 | self.test_label = "tiling" |
| 61 | 67 | ||
| 62 | def spawn(self, argv, label, env=None): | 68 | def spawn(self, argv, label, env=None, cwd=None): |
| 63 | log = (self.root / (label + ".log")).open("wb") | 69 | log = (self.root / (label + ".log")).open("wb") |
| 64 | self.logs.append(log) | 70 | self.logs.append(log) |
| 65 | proc = subprocess.Popen(argv, env=self.env if env is None else env, | 71 | proc = subprocess.Popen(argv, env=self.env if env is None else env, |
| 66 | stdout=log, stderr=subprocess.STDOUT) | 72 | cwd=cwd, stdout=log, stderr=subprocess.STDOUT) |
| 67 | self.procs.append(proc) | 73 | self.procs.append(proc) |
| 68 | return proc | 74 | return proc |
| 69 | 75 | ||
| @@ -110,14 +116,39 @@ class Rig: | |||
| 110 | return self.launch_gui(["--sock", first, "--session", "left"], label) | 116 | return self.launch_gui(["--sock", first, "--session", "left"], label) |
| 111 | 117 | ||
| 112 | def launch_gui(self, args, label, attached=True): | 118 | def launch_gui(self, args, label, attached=True): |
| 119 | require(self.gui is None, "close the previous fixture window before relaunch") | ||
| 113 | fifo = self.root / (label + ".fifo") | 120 | fifo = self.root / (label + ".fifo") |
| 114 | os.mkfifo(fifo) | 121 | os.mkfifo(fifo) |
| 115 | self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) | 122 | self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) |
| 116 | self.env["MUXG_TEST_FIFO"] = str(fifo) | 123 | self.env["MUXG_TEST_FIFO"] = str(fifo) |
| 117 | self.gui_log = self.root / (label + ".log") | 124 | self.gui_log = self.root / (label + ".log") |
| 118 | self.gui = self.spawn([self.muxg, *args], label) | 125 | self.gui = self.spawn([self.muxg, *args], label, cwd=self.gui_cwd) |
| 119 | return self.wait_state(lambda s: len(s["panes"]) == 1 and | 126 | state = self.state() |
| 120 | (not attached or s["panes"][0]["phase"] == "attached")) | 127 | if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1' and ( |
| 128 | self.env['SDL_VIDEO_DRIVER'] == 'wayland' or self.float_scale is not None): | ||
| 129 | require(self.env['SDL_VIDEO_DRIVER'] == 'wayland', 'Sway floating requires Wayland') | ||
| 130 | def mapped(node): | ||
| 131 | return node.get('pid') == self.gui.pid or any( | ||
| 132 | mapped(child) for child in node.get('nodes', []) + node.get('floating_nodes', [])) | ||
| 133 | eventually(lambda: mapped(json.loads(subprocess.check_output( | ||
| 134 | ['swaymsg', '-r', '-t', 'get_tree'], env=self.env, timeout=3))), | ||
| 135 | 'owned test window did not map before floating') | ||
| 136 | width, height = self.float_size | ||
| 137 | command = (f'[pid={self.gui.pid}] floating enable, border none, ' | ||
| 138 | f'resize set width {width} px height {height} px') | ||
| 139 | result = subprocess.run(['swaymsg', '-r', command], env=self.env, | ||
| 140 | capture_output=True, text=True, check=True, timeout=3) | ||
| 141 | require(all(item['success'] for item in json.loads(result.stdout)), | ||
| 142 | 'could not float this test window') | ||
| 143 | state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == self.float_size) | ||
| 144 | if self.float_scale is not None: | ||
| 145 | require(state['width'] == state['logical_width'] * self.float_scale and | ||
| 146 | state['height'] == state['logical_height'] * self.float_scale, | ||
| 147 | 'Wayland resize acceptance requires the expected density') | ||
| 148 | if attached: | ||
| 149 | state = self.wait_state(lambda s: bool(s["panes"]) and | ||
| 150 | all(p["phase"] == "attached" for p in s["panes"])) | ||
| 151 | return state | ||
| 121 | 152 | ||
| 122 | def send(self, *lines): | 153 | def send(self, *lines): |
| 123 | require(self.gui.poll() is None, "GUI exited unexpectedly") | 154 | require(self.gui.poll() is None, "GUI exited unexpectedly") |
| @@ -237,7 +268,9 @@ class Rig: | |||
| 237 | self.checkpoints += 1 | 268 | self.checkpoints += 1 |
| 238 | print(f"{self.test_label} OK ({self.checkpoints}): {message}", flush=True) | 269 | print(f"{self.test_label} OK ({self.checkpoints}): {message}", flush=True) |
| 239 | 270 | ||
| 240 | def close(self): | 271 | def close(self, success=None): |
| 272 | if success is None: | ||
| 273 | success = sys.exc_info()[0] is None | ||
| 241 | if self.gui is not None and self.gui.poll() is None: | 274 | if self.gui is not None and self.gui.poll() is None: |
| 242 | self.gui.terminate() | 275 | self.gui.terminate() |
| 243 | for sock, proc in self.daemons: | 276 | for sock, proc in self.daemons: |
| @@ -258,6 +291,12 @@ class Rig: | |||
| 258 | os.close(self.fd) | 291 | os.close(self.fd) |
| 259 | for log in self.logs: | 292 | for log in self.logs: |
| 260 | log.close() | 293 | log.close() |
| 294 | if success and os.environ.get("MUXG_TEST_KEEP_ARTIFACTS") != "1": | ||
| 295 | require(self.root == self._owned_root and not self.root.is_symlink(), | ||
| 296 | "refusing to remove a foreign artifact root") | ||
| 297 | shutil.rmtree(self._owned_root) | ||
| 298 | else: | ||
| 299 | print("Native test artifacts retained:", self.root, flush=True) | ||
| 261 | 300 | ||
| 262 | 301 | ||
| 263 | def rect_contains(rect, x, y): | 302 | def rect_contains(rect, x, y): |
| @@ -284,185 +323,35 @@ def colour_counts(capture, rect, colour): | |||
| 284 | return inside, outside | 323 | return inside, outside |
| 285 | 324 | ||
| 286 | 325 | ||
| 287 | def check_geometry(rig, state, first, second, direction): | 326 | def output_flood(rig, refs): |
| 288 | a, b = state["panes"] | 327 | """Unslept 256 KiB writes stress fairness independently of the paced journey.""" |
| 289 | ar, br = a["outer"], b["outer"] | 328 | left, right, _ = refs |
| 290 | size_axis, pos_axis = ("w", "x") if direction == "beside" else ("h", "y") | 329 | # A bounded flood with an external stop signal. During the frame-count |
| 291 | require(abs(ar[size_axis] - br[size_axis]) <= 1, "split did not divide the armed pane equally") | 330 | # interval only nonforcing state observations are allowed, never captures. |
| 292 | require(br[pos_axis] >= ar[pos_axis] + ar[size_axis], "panes overlap") | 331 | stop = rig.root / "stop-flood" |
| 293 | for pane, sock, session in ((a, first, "left"), (b, second, "right")): | 332 | progress = rig.root / "flood-progress" |
| 294 | content = pane["content"] | 333 | rig.focus(left) |
| 295 | cols = content["w"] // state["cell_w"] | 334 | flood = (f"i=0; while [ $i -lt 4096 ] && [ ! -e {shlex.quote(str(stop))} ]; do " |
| 296 | rows = content["h"] // state["cell_h"] | 335 | "head -c 262144 /dev/zero | tr '\\000' X; " |
| 297 | require(cols >= 2 and rows >= 1, "invalid terminal footprint") | 336 | f"i=$((i+1)); echo $i > {shlex.quote(str(progress))}; done; echo FLOOD-DONE") |
| 298 | require((pane["cols"], pane["rows"]) == (cols, rows), "GUI cell sizing differs from physical content") | 337 | rig.shell(flood) |
| 299 | eventually(lambda: (lambda s: (s["cols"], s["rows"]) == (cols, rows))(rig.status(sock, session)), | 338 | eventually(lambda: progress.exists() and progress.stat().st_size > 0, "flood did not start") |
| 300 | "daemon did not receive its pane's physical size") | 339 | count1, _ = rig.frames() |
| 301 | 340 | rig.focus(right) | |
| 302 | 341 | started = time.monotonic() | |
| 303 | def normal_scenario(rig, direction): | 342 | rig.shell("printf '\\033[38;2;0;0;255mRESPONSIVE-%s\\033[0m\\n' RIGHT") |
| 304 | first, _ = rig.daemon("first-" + direction) | 343 | rig.wait_marker(*refs[right], "RESPONSIVE-RIGHT") |
| 305 | second, second_proc = rig.daemon("second-" + direction, quic=direction == "stacked") | 344 | # State observes the last completed frame and must not itself request a |
| 306 | if direction == "beside": | 345 | # repaint. This detects B staying visually stale while A keeps painting. |
| 307 | for flags in (("--next-target", "box"), ("--next-session", "right")): | 346 | rig.wait_state(lambda s: "RESPONSIVE-RIGHT" in next(p for p in s["panes"] if p["id"] == right)["painted_text"]) |
| 308 | result = subprocess.run([rig.muxg, "--sock", first, *flags], env=rig.env, | 347 | latency = time.monotonic() - started |
| 309 | capture_output=True, timeout=3) | 348 | time.sleep(0.3) |
| 310 | require(result.returncode == 2, "retired staged-target arguments were accepted") | 349 | require("FLOOD-DONE" not in rig.dump(*refs[left]), "flood ended before concurrent check") |
| 311 | rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") | 350 | count2, p99 = rig.frames() |
| 312 | before = rig.start_gui(first, second, "gui-" + direction) | 351 | require(colour_counts(rig.pixels(), next(p for p in rig.state()["panes"] if p["id"] == right)["content"], "blue")[0] > 30, |
| 313 | rig.open_picker(direction) | 352 | "new responsive marker was not rendered in the other pane") |
| 314 | armed = rig.picker("hosts") | 353 | stop.touch() |
| 315 | require(armed["pending"]["direction"] == direction, | 354 | rig.wait_marker(*refs[left], "FLOOD-DONE") |
| 316 | "picker has the wrong split direction") | 355 | require(count2 > count1, "no frames painted while output flooded") |
| 317 | require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], | 356 | require(latency < DEADLINE and p99 < 20000, f"flood isolation exceeded budget: {latency}s / {p99}us") |
| 318 | "arming split changed geometry") | 357 | rig.ok(f"flood leaves other pane responsive ({latency*1000:.0f} ms input-to-frame, {p99} us frame p99)") |
| 319 | stats = rig.command("d", "stats", "--sock", second).stdout | ||
| 320 | require(re.search(r"\battaches=0\b", stats), | ||
| 321 | "picker target attached before insertion: " + stats) | ||
| 322 | # Daemons may start a default session themselves. The staged named session | ||
| 323 | # must still be absent, and this observer query cannot create one. | ||
| 324 | require(rig.command("a", "status", "--sock", second, "--session", "right", | ||
| 325 | "--timeout", "200", check=False).returncode != 0, | ||
| 326 | "staged named session exists before insertion") | ||
| 327 | rig.key("escape") | ||
| 328 | cancelled = rig.wait_state(lambda s: s["picker"] is None) | ||
| 329 | require(len(cancelled["panes"]) == 1 and cancelled["pending"] == armed["pending"], | ||
| 330 | "closing host picker changed panes or forgot its armed split") | ||
| 331 | rig.key("escape") | ||
| 332 | rig.wait_state(lambda s: s["pending"] is None) | ||
| 333 | rig.open_picker(direction) | ||
| 334 | rig.picker("hosts") | ||
| 335 | rig.choose(rig.targets.get(second, "--sock " + second)) | ||
| 336 | rig.picker("sessions") | ||
| 337 | rig.new_session("right") | ||
| 338 | state = rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"])) | ||
| 339 | check_geometry(rig, state, first, second, direction) | ||
| 340 | transport = "Unix + QUIC" if direction == "stacked" else "two Unix sockets" | ||
| 341 | rig.ok(direction + " split over " + transport + "; both PTYs have exact pane sizes") | ||
| 342 | |||
| 343 | rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mRIGHT-%s\\033[0m\\n' MARK") | ||
| 344 | rig.wait_marker(second, "right", "RIGHT-MARK") | ||
| 345 | require("RIGHT-MARK" not in rig.dump(first, "left"), "right input leaked into left pane") | ||
| 346 | rig.chord("h" if direction == "beside" else "k") | ||
| 347 | rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mLEFT-%s\\033[0m\\n' MARK") | ||
| 348 | rig.wait_marker(first, "left", "LEFT-MARK") | ||
| 349 | require("LEFT-MARK" not in rig.dump(second, "right"), "left input leaked into right pane") | ||
| 350 | state = rig.state() | ||
| 351 | def correct_pixels(): | ||
| 352 | pixels = rig.pixels() | ||
| 353 | red = colour_counts(pixels, state["panes"][0]["content"], "red") | ||
| 354 | green = colour_counts(pixels, state["panes"][1]["content"], "green") | ||
| 355 | return red[0] > 30 and green[0] > 30 and red[1] == green[1] == 0 | ||
| 356 | eventually(correct_pixels, "coloured glyphs missing or spill into another pane") | ||
| 357 | rig.ok("focus routes input independently and framebuffer regions contain only their own coloured glyphs") | ||
| 358 | |||
| 359 | rig.send("resize:640x400") | ||
| 360 | state = rig.wait_state(lambda s: (s["width"], s["height"]) == (640, 400)) | ||
| 361 | check_geometry(rig, state, first, second, direction) | ||
| 362 | eventually(correct_pixels, "resized framebuffer lost or misplaced pane content") | ||
| 363 | # Clear red content after shrinking; the other pane's green text must survive. | ||
| 364 | rig.shell("printf '\\033[2J\\033[H'") | ||
| 365 | def cleared(): | ||
| 366 | pixels = rig.pixels() | ||
| 367 | red = colour_counts(pixels, state["panes"][0]["content"], "red") | ||
| 368 | green = colour_counts(pixels, state["panes"][1]["content"], "green") | ||
| 369 | return sum(red) == 0 and green[0] > 30 and green[1] == 0 | ||
| 370 | eventually(cleared, "clear/shrink left old pixels or erased a neighbour") | ||
| 371 | # Click uses logical coordinates; offscreen is 100%, Wayland checked separately. | ||
| 372 | content = state["panes"][1]["content"] | ||
| 373 | rig.send(f"click:{content['x'] + content['w']//2},{content['y'] + content['h']//2}") | ||
| 374 | rig.shell("printf 'CLICK-%s\\n' RIGHT") | ||
| 375 | rig.wait_marker(second, "right", "CLICK-RIGHT") | ||
| 376 | require("CLICK-RIGHT" not in rig.dump(first, "left"), "click focus routed to wrong pane") | ||
| 377 | rig.ok("window resize, neighbour-safe clearing, and mouse focus work") | ||
| 378 | |||
| 379 | if direction == "beside": | ||
| 380 | rig.shell("stty -echo -icanon -isig min 1 time 0; printf 'RAW-%s\\n' READY; " | ||
| 381 | "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE") | ||
| 382 | rig.wait_marker(second, "right", "RAW-READY") | ||
| 383 | rig.send("key:prefix", "key:prefix", "text:A") | ||
| 384 | rig.wait_marker(second, "right", "RAW-DONE") | ||
| 385 | require("1c 41" in rig.dump(second, "right"), "prefix passthrough did not send one 0x1c then A") | ||
| 386 | rig.ok("double prefix sends the terminal encoding exactly once; ordinary input follows") | ||
| 387 | # A bounded flood with an external stop signal. During the frame-count | ||
| 388 | # interval only nonforcing state observations are allowed, never captures. | ||
| 389 | stop = rig.root / "stop-flood" | ||
| 390 | progress = rig.root / "flood-progress" | ||
| 391 | rig.chord("h") | ||
| 392 | flood = (f"i=0; while [ $i -lt 4096 ] && [ ! -e {shlex.quote(str(stop))} ]; do " | ||
| 393 | "head -c 262144 /dev/zero | tr '\\000' X; " | ||
| 394 | f"i=$((i+1)); echo $i > {shlex.quote(str(progress))}; done; echo FLOOD-DONE") | ||
| 395 | rig.shell(flood) | ||
| 396 | eventually(lambda: progress.exists() and progress.stat().st_size > 0, "flood did not start") | ||
| 397 | count1, _ = rig.frames() | ||
| 398 | rig.chord("l") | ||
| 399 | started = time.monotonic() | ||
| 400 | rig.shell("printf '\\033[38;2;0;0;255mRESPONSIVE-%s\\033[0m\\n' RIGHT") | ||
| 401 | rig.wait_marker(second, "right", "RESPONSIVE-RIGHT") | ||
| 402 | # State observes the last completed frame and must not itself request a | ||
| 403 | # repaint. This detects B staying visually stale while A keeps painting. | ||
| 404 | rig.wait_state(lambda s: "RESPONSIVE-RIGHT" in s["panes"][1]["painted_text"]) | ||
| 405 | latency = time.monotonic() - started | ||
| 406 | time.sleep(0.3) | ||
| 407 | require("FLOOD-DONE" not in rig.dump(first, "left"), "flood ended before concurrent check") | ||
| 408 | count2, p99 = rig.frames() | ||
| 409 | require(colour_counts(rig.pixels(), state["panes"][1]["content"], "blue")[0] > 30, | ||
| 410 | "new responsive marker was not rendered in the other pane") | ||
| 411 | stop.touch() | ||
| 412 | rig.wait_marker(first, "left", "FLOOD-DONE") | ||
| 413 | require(count2 > count1, "no frames painted while output flooded") | ||
| 414 | require(latency < DEADLINE and p99 < 20000, f"flood isolation exceeded budget: {latency}s / {p99}us") | ||
| 415 | rig.ok(f"flood leaves other pane responsive ({latency*1000:.0f} ms input-to-frame, {p99} us frame p99)") | ||
| 416 | |||
| 417 | # Disconnect one daemon while the other continues accepting input. | ||
| 418 | rig.stop_daemon(second, second_proc) | ||
| 419 | rig.wait_state(lambda s: s["panes"][1]["phase"] != "attached") | ||
| 420 | rig.chord("h") | ||
| 421 | rig.shell("printf 'SURVIVOR-%s\\n' OK") | ||
| 422 | rig.wait_marker(first, "left", "SURVIVOR-OK") | ||
| 423 | rig.ok("a disconnected daemon cannot take down the other pane") | ||
| 424 | else: | ||
| 425 | rig.shell("exit 7") | ||
| 426 | rig.wait_state(lambda s: s["panes"][1]["phase"] == "exited" and s["panes"][1]["exit_code"] == 7) | ||
| 427 | rig.chord("k") | ||
| 428 | rig.shell("printf 'AFTER-EXIT-%s\\n' OK") | ||
| 429 | rig.wait_marker(first, "left", "AFTER-EXIT-OK") | ||
| 430 | rig.ok("shell exit is retained in its pane while its neighbour remains usable") | ||
| 431 | rig.quit() | ||
| 432 | require(("SURVIVOR-OK" if direction == "beside" else "AFTER-EXIT-OK") in rig.dump(first, "left"), | ||
| 433 | "window close ended or replaced the surviving session") | ||
| 434 | rig.ok("closing the window preserves the surviving daemon session") | ||
| 435 | |||
| 436 | |||
| 437 | def unavailable_scenario(rig): | ||
| 438 | missing = str(rig.root / "missing.sock") | ||
| 439 | rig.launch_gui(["--sock", missing, "--session", "offline"], "gui-unavailable", attached=False) | ||
| 440 | rig.wait_state(lambda s: s["panes"][0]["phase"] in ("dial_failed", "failed", "reconnecting")) | ||
| 441 | rig.send("resize:8x8") | ||
| 442 | state = rig.wait_state(lambda s: s["width"] == 8 and s["height"] == 8) | ||
| 443 | require(len(state["panes"]) == 1 and all(p["cols"] > 0 and p["rows"] > 0 for p in state["panes"]), | ||
| 444 | "tiny window destroyed panes or sent invalid sizes") | ||
| 445 | rig.quit() | ||
| 446 | rig.ok("initially unreachable target retains its pane; tiny window and shutdown remain safe") | ||
| 447 | |||
| 448 | |||
| 449 | def main(): | ||
| 450 | rig = Rig(*sys.argv[1:3]) | ||
| 451 | try: | ||
| 452 | version = subprocess.check_output([rig.muxg, "--version"], text=True) | ||
| 453 | require("ReleaseSafe" in version or "ReleaseFast" in version, "native tiling requires a release build") | ||
| 454 | for direction in ("beside", "stacked"): | ||
| 455 | normal_scenario(rig, direction) | ||
| 456 | unavailable_scenario(rig) | ||
| 457 | print(f"native tiling OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}") | ||
| 458 | except BaseException: | ||
| 459 | print("Native tiling failure artifacts:", rig.root, file=sys.stderr) | ||
| 460 | for path in rig.root.glob("gui*.log"): | ||
| 461 | print(path.name + ":\n" + path.read_text()[-2000:], file=sys.stderr) | ||
| 462 | raise | ||
| 463 | finally: | ||
| 464 | rig.close() | ||
| 465 | |||
| 466 | |||
| 467 | if __name__ == "__main__": | ||
| 468 | main() | ||
test/native_tmux_mouse.py
| Old | New | ||
|---|---|---|---|
| @@ -153,7 +153,7 @@ def run(mux, muxg): | |||
| 153 | 'Shift muxg drag changed tmux selection or buffer') | 153 | 'Shift muxg drag changed tmux selection or buffer') |
| 154 | rig.ok('Shift muxg drag remains local and leaves shared tmux buffer unchanged') | 154 | rig.ok('Shift muxg drag remains local and leaves shared tmux buffer unchanged') |
| 155 | rig.quit() | 155 | rig.quit() |
| 156 | print('Native tmux mouse acceptance passed; artifacts:', rig.root, flush=True) | 156 | print('Native tmux mouse acceptance passed', flush=True) |
| 157 | except BaseException: | 157 | except BaseException: |
| 158 | rig.failure_artifacts() | 158 | rig.failure_artifacts() |
| 159 | raise | 159 | raise |
test/native_wheel.py
| Old | New | ||
|---|---|---|---|
| @@ -8,7 +8,7 @@ import sys | |||
| 8 | import time | 8 | import time |
| 9 | 9 | ||
| 10 | sys.dont_write_bytecode = True | 10 | sys.dont_write_bytecode = True |
| 11 | from native_lifecycle import start_persistent | 11 | from native_lifecycle import start_workspace |
| 12 | from native_resize import by_id, one_divider | 12 | from native_resize import by_id, one_divider |
| 13 | from native_selection import SelectionRig, cell_background | 13 | from native_selection import SelectionRig, cell_background |
| 14 | from native_tiling import eventually, require | 14 | from native_tiling import eventually, require |
| @@ -296,15 +296,16 @@ def application_wheel(rig, panes): | |||
| 296 | def main(): | 296 | def main(): |
| 297 | rig = WheelRig(*sys.argv[1:3]) | 297 | rig = WheelRig(*sys.argv[1:3]) |
| 298 | try: | 298 | try: |
| 299 | refs = start_persistent(rig) | 299 | refs = start_workspace(rig) |
| 300 | panes = list(refs) | 300 | panes = list(refs) |
| 301 | shell_history(rig, panes) | 301 | if rig.env['SDL_VIDEO_DRIVER'] == 'wayland': |
| 302 | shell_history(rig, panes) | ||
| 302 | application_wheel(rig, panes) | 303 | application_wheel(rig, panes) |
| 303 | rig.kernel_sizes() | 304 | rig.kernel_sizes() |
| 304 | rig.assert_cli_untouched() | 305 | rig.assert_cli_untouched() |
| 305 | rig.quit() | 306 | rig.quit() |
| 306 | rig.ok('three real PTYs retain geometry, persistent identities and terminal layout state') | 307 | rig.ok('three real PTYs retain geometry, persistent identities and terminal layout state') |
| 307 | print('Wheel artifacts:', rig.root) | 308 | print('Native application wheel acceptance passed', flush=True) |
| 308 | except Exception: | 309 | except Exception: |
| 309 | rig.failure_artifacts() | 310 | rig.failure_artifacts() |
| 310 | raise | 311 | raise |