a73x

test/native_resize.py

Ref:   Size: 13.6 KiB   History

#!/usr/bin/env python3
"""Real SDL divider input, three daemon PTYs, and nonforcing frame oracles."""
import fcntl
import os
from pathlib import Path
import shlex
import struct
import sys
import termios

sys.dont_write_bytecode = True
from native_tiling import Rig, colour_counts, eventually, require


class ResizeRig(Rig):
    float_size = (1000, 650)
    float_scale = 2

    def __init__(self, mux, muxg):
        super().__init__(mux, muxg)
        self.env['MUXG_TEST_RETAIN_FRAME'] = '1'
        self.tty_paths = {}

    def logical(self, state, x, y):
        return (x * state['logical_width'] / state['width'],
                y * state['logical_height'] / state['height'])

    def point(self, state, x, y):
        x, y = self.logical(state, x, y)
        return f'{x:.4f},{y:.4f}'

    def focus(self, pane_id):
        state = self.state()
        pane = by_id(state)[pane_id]
        rect = pane['header']
        self.send('click:' + self.point(state, rect['x'] + rect['w'] / 2,
                                       rect['y'] + rect['h'] / 2))
        self.wait_state(lambda s: s['focus'] == pane_id)

    def drag(self, direction, dx=0, dy=0, pad=0):
        state = self.state()
        divider = one_divider(state, direction)
        rect = divider['rect']
        x, y = rect['x'] + rect['w'] / 2, rect['y'] + rect['h'] / 2
        # Avoid the nested divider intersection. Pad probes the visual rail's
        # surrounding logical-pixel grab area without changing the target delta.
        if direction == 'beside':
            y = rect['y'] + rect['h'] / 4
            x += pad * state['width'] / state['logical_width']
        else:
            x = rect['x'] + rect['w'] * 3 / 4
            y += pad * state['height'] / state['logical_height']
        self.send('mousedown:' + self.point(state, x, y),
                  'mousemove:' + self.point(state, x + dx, y + dy),
                  'mouseup:' + self.point(state, x + dx, y + dy))
        return state

    def last_pixels(self):
        raw = self.artifact('capture-last', '.ppm').read_bytes()
        magic, dims, maximum, pixels = raw.split(b'\n', 3)
        require(magic == b'P6' and maximum == b'255', 'invalid last-frame capture')
        width, height = map(int, dims.split())
        require(len(pixels) == width * height * 3, 'truncated last-frame capture')
        return width, height, pixels


def by_id(state):
    return {pane['id']: pane for pane in state['panes']}


def one_divider(state, direction):
    found = [d for d in state['dividers'] if d['direction'] == direction]
    require(len(found) == 1, 'expected one ' + direction + ' divider')
    return found[0]


def edge(state, direction):
    return one_divider(state, direction)['rect']['x' if direction == 'beside' else 'y']


def unchanged_ids(state, identities):
    require([(p['id'], p['generation']) for p in state['panes']] == identities,
            'resizing replaced, reordered or reattached a pane')
    require(all(p['cols'] >= 2 and p['rows'] >= 2 for p in state['panes']),
            'resizing violated a terminal minimum')


def pty_sizes(rig, sessions, identities):
    state = rig.state()
    unchanged_ids(state, identities)
    require([(d['id'], d['direction']) for d in state['dividers']] == rig.divider_ids,
            'resizing replaced or reordered divider identities')
    for pane_id, (sock, session) in sessions.items():
        pane = by_id(state)[pane_id]
        expected = (pane['content']['h'] // state['cell_h'],
                    pane['content']['w'] // state['cell_w'])
        require(expected == (pane['rows'], pane['cols']), 'geometry and cell claim disagree')
        actual = None
        def kernel_size():
            nonlocal actual
            fd = os.open(rig.tty_paths[pane_id], os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
            try:
                actual = struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))[:2]
                return actual == expected
            finally:
                os.close(fd)
        try:
            eventually(kernel_size, f'{session} kernel PTY size did not become {expected}')
        except AssertionError as error:
            raise AssertionError(f'{error}; last observed {actual}') from error
        # Both observations are read-only. Neither sends terminal input or
        # attaches at a new size, which could mask a missing resize notification.
        observed = rig.status(sock, session)
        require((observed['rows'], observed['cols']) == expected, 'daemon dimension mismatch')


def drag_and_keys(rig, identities, sessions):
    state = rig.state()
    dx = state['cell_w'] * 5
    before = rig.drag('beside', dx=dx, pad=2)
    state = rig.wait_state(lambda s: abs(edge(s, 'beside') - edge(before, 'beside') - dx) <= 1)
    require(edge(state, 'stacked') == edge(before, 'stacked'), 'outer drag moved inner horizontal rail')
    old, new = by_id(before), by_id(state)
    for pane_id in (identities[1][0], identities[2][0]):
        require(abs(new[pane_id]['outer']['x'] - old[pane_id]['outer']['x'] - dx) <= 1,
                'outer drag did not move both descendants')
    rig.send('mousemove:0,0')
    require(edge(rig.state(), 'beside') == edge(state, 'beside'),
            'pointer motion after release continued resizing')
    dy = -state['cell_h'] * 3
    before = rig.drag('stacked', dy=dy, pad=2)
    state = rig.wait_state(lambda s: abs(edge(s, 'stacked') - edge(before, 'stacked') - dy) <= 1)
    require(by_id(state)[identities[0][0]]['outer'] == by_id(before)[identities[0][0]]['outer'],
            'nested horizontal drag changed the independent left pane')
    pty_sizes(rig, sessions, identities)
    rig.ok('real divider drags resize descendants on both axes, including the wider grab area')

    rig.focus(identities[2][0])
    before = rig.state()
    rig.chord('r')
    rig.wait_state(lambda s: s['resize_mode'])
    rig.key('left')
    state = rig.wait_state(lambda s: abs(edge(s, 'beside') - edge(before, 'beside') + s['cell_w']) <= 1)
    require(edge(state, 'stacked') == edge(before, 'stacked'), 'horizontal key chose wrong ancestor')
    before = state
    rig.key('j')
    state = rig.wait_state(lambda s: abs(edge(s, 'stacked') - edge(before, 'stacked') - s['cell_h']) <= 1)
    require(edge(state, 'beside') == edge(before, 'beside'), 'vertical key moved outer divider')
    rig.key('escape')
    rig.wait_state(lambda s: not s['resize_mode'])
    pty_sizes(rig, sessions, identities)
    rig.ok('keyboard resize chooses the nearest matching-axis divider and moves it one cell')


def modal_leakage(rig, identities):
    pane_id = identities[2][0]
    for exit_key in ('enter', 'escape'):
        rig.focus(pane_id)
        root = rig.root / ('modal-' + exit_key)
        ready, received, done = [Path(str(root) + suffix) for suffix in ('.ready', '.bytes', '.done')]
        command = ('stty -echo -icanon min 1 time 0; : > ' + shlex.quote(str(ready)) +
                   '; dd bs=1 count=1 2>/dev/null | od -An -tx1 > ' + shlex.quote(str(received)) +
                   '; stty sane; : > ' + shlex.quote(str(done)))
        rig.shell(command)
        eventually(ready.exists, 'raw terminal fixture did not arm')
        rig.send('key:prefix', 'key:r', 'text:r', 'key:l', 'text:l',
                 'key:j', 'text:j', 'key:left', 'key:up', 'text:MODAL-LEAK',
                 'key:' + exit_key, 'text:Q')
        eventually(done.exists, 'terminal did not receive post-resize sentinel')
        require(received.read_text().split() == ['51'], 'resize mode leaked input before sentinel Q')
        rig.wait_state(lambda s: not s['resize_mode'])
    rig.ok('resize keys and associated text stay modal; Enter and Esc release ordinary input')


def clamps(rig, identities, sessions):
    rig.drag('beside', dx=10000)
    rig.wait_state(lambda s: all(by_id(s)[pane_id]['cols'] == 2
                                for pane_id in (identities[1][0], identities[2][0])))
    rig.drag('stacked', dy=10000)
    rig.wait_state(lambda s: by_id(s)[identities[2][0]]['rows'] == 2)
    pty_sizes(rig, sessions, identities)
    rig.drag('beside', dx=-10000)
    rig.wait_state(lambda s: by_id(s)[identities[0][0]]['cols'] == 2)
    rig.drag('stacked', dy=-10000)
    state = rig.wait_state(lambda s: by_id(s)[identities[1][0]]['rows'] == 2)
    unchanged_ids(state, identities)
    pty_sizes(rig, sessions, identities)
    before_tiny = state
    rig.send('resize:8x8')
    if rig.env['SDL_VIDEO_DRIVER'] == 'wayland':
        state = rig.wait_state(lambda s: s['logical_width'] <= 100 and s['logical_height'] <= 100)
        if (state['logical_width'], state['logical_height']) != (8, 8):
            print('resize note: Sway minimum clamped requested 8x8 to '
                  f"{state['logical_width']}x{state['logical_height']}; exact 8x8 covered offscreen", flush=True)
    else:
        state = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (8, 8))
    unchanged_ids(state, identities)
    pty_sizes(rig, sessions, identities)
    rig.send('key:prefix', 'key:r', 'key:l', 'key:j', 'key:escape')
    require([p['outer'] for p in rig.state()['panes']] == [p['outer'] for p in state['panes']],
            'explicit resizing changed an undersized tree')
    restored_size = (before_tiny['logical_width'], before_tiny['logical_height'])
    rig.send(f'resize:{restored_size[0]}x{restored_size[1]}')
    restored = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == restored_size)
    require([p['outer'] for p in restored['panes']] == [p['outer'] for p in before_tiny['panes']],
            'tiny-window clipping rewrote split proportions')
    # Restore usable widths/heights through the same real drag path.
    state = rig.state()
    rig.drag('beside', dx=state['width'] * .45 - edge(state, 'beside'))
    state = rig.wait_state(lambda s: by_id(s)[identities[0][0]]['cols'] > 20)
    rig.drag('stacked', dy=state['height'] * .5 - edge(state, 'stacked'))
    rig.wait_state(lambda s: by_id(s)[identities[1][0]]['rows'] > 5)
    pty_sizes(rig, sessions, identities)
    rig.ok('minimum clamps and tiny-window clipping preserve every identity and nonzero PTY')


def pixels_and_batch(rig, identities, sessions):
    for index, (pane_id, (sock, session)) in enumerate(sessions.items()):
        rig.focus(pane_id)
        colour = ('255;0;0', '0;255;0', '0;0;255')[index]
        marker = 'COLOUR-' + session
        rig.shell("printf '\\033[?25l\\033[48;2;" + colour + "m\\033[2J\\033[HCOLOUR-%s\\n' " + shlex.quote(session))
        rig.wait_marker(sock, session, marker)
        rig.wait_state(lambda s: marker in by_id(s)[pane_id]['painted_text'])
    def clean_regions():
        state = rig.state()
        capture = rig.last_pixels()
        for index, (pane_id, _) in enumerate(identities):
            inside, outside = colour_counts(capture, by_id(state)[pane_id]['content'], ('red', 'green', 'blue')[index])
            if inside < 100 or outside != 0:
                return False
        return True
    eventually(clean_regions, 'initial nonforcing framebuffer colours are misplaced')
    frames, _ = rig.frames()
    rig.drag('beside', dx=-rig.state()['cell_w'] * 7)
    eventually(lambda: rig.frames()[0] > frames, 'divider drag did not cause a completed frame')
    eventually(clean_regions, 'old coloured pixels survived the divider moving, or crossed neighbours')
    rig.ok('nonforcing framebuffer reads show old pixels erased after divider motion')

    # One FIFO write places release, window resize, and terminal input into the
    # normal SDL queue together. The focused terminal asks its OS for the size
    # that accompanied this command, not a later observer-triggered correction.
    rig.focus(identities[2][0])
    state = rig.state()
    rail = one_divider(state, 'stacked')['rect']
    x, y = rail['x'] + rail['w'] * .75, rail['y'] + rail['h'] / 2
    output = rig.root / 'batched-pty-size'
    rig.send('mousedown:' + rig.point(state, x, y),
             'mousemove:' + rig.point(state, x, y + state['cell_h']),
             'mouseup:' + rig.point(state, x, y + state['cell_h']),
             'resize:800x520', 'text:stty size > ' + shlex.quote(str(output)), 'key:enter')
    eventually(lambda: output.exists() and output.stat().st_size > 0, 'batched terminal input did not run')
    state = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (800, 520))
    focused = by_id(state)[identities[2][0]]
    require(tuple(map(int, output.read_text().split())) == (focused['rows'], focused['cols']),
            'batched resize/input ran against stale PTY dimensions')
    pty_sizes(rig, sessions, identities)
    rig.ok('batched drag release, window resize and input preserve final PTY size ordering')

    rig.send('resize:640x400')
    rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (640, 400))
    pty_sizes(rig, sessions, identities)
    eventually(clean_regions, 'window shrink misplaced coloured pane pixels')
    rig.focus(identities[0][0])
    rig.shell("printf '\\033[0m\\033[2J\\033[H'")
    def cleared():
        state = rig.state()
        capture = rig.last_pixels()
        if sum(colour_counts(capture, by_id(state)[identities[0][0]]['content'], 'red')):
            return False
        for index, colour in ((1, 'green'), (2, 'blue')):
            inside, outside = colour_counts(capture, by_id(state)[identities[index][0]]['content'], colour)
            if inside < 100 or outside:
                return False
        return True
    eventually(cleared, 'clear after window shrink left old pixels or erased a neighbour')
    rig.send('resize:800x520')
    rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (800, 520))
    pty_sizes(rig, sessions, identities)
    rig.ok('window shrink and terminal clear erase old pixels without erasing neighbours')