summaryrefslogtreecommitdiff
path: root/test/look_placement.py
blob: 8a31157fba2eb2e59fc0c0bf5778ffb8397caac0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#!/usr/bin/env python3
"""Check narrow-window first-document Look placement in owned SDL/TTY sessions.

python3 -B test/look_placement.py zig-out/column-review/bin/pardes-gui
python3 -B test/look_placement.py zig-out/column-review/bin/pardes --tty

Starts the classic three-terminal layout, then opens the first document from
the lower-left or right terminal. Screen-grid geometry must split only that
terminal, immediately below it, and a repeated Look must reuse the file pane.
Use --output DIR to retain grid observations and SDL GPU screenshots.
"""
import argparse
from contextlib import nullcontext
from pathlib import Path
import tempfile

from fs import session
from ui_review import capture, panes, require, wait_for


def geometry(client, markers):
    frame = client.screen()
    width = frame['cols']
    lines = [''.join(cell[0] for cell in frame['cells'][row * width:(row + 1) * width])
             for row in range(frame['rows'])]
    positions = {}
    for serial, marker in markers.items():
        hits = [(row, line.index(marker)) for row, line in enumerate(lines) if marker in line]
        if len(hits) != 1:
            return None
        row, offset = hits[0]
        column_x = 0 if offset < width // 2 else width // 2
        positions[serial] = (column_x, row)
    rectangles = {}
    for serial, (column_x, row) in positions.items():
        following = [other_y for other_x, other_y in positions.values()
                     if other_x == column_x and other_y > row]
        end = min(following, default=frame['rows'])
        rectangles[serial] = (column_x, row, width // 2, end - row)
    return frame, rectangles


def rectangle_text(frame, rect):
    x, y, width, height = rect
    return tuple(tuple(cell[0] for cell in frame['cells'][row * frame['cols'] + x:
                                                       row * frame['cols'] + x + width])
                 for row in range(y, y + height))


def run_case(args, root, source_index):
    root.mkdir(parents=True, exist_ok=True, mode=0o700)
    root.chmod(0o700)
    config = root / 'config/pardes'
    config.mkdir(parents=True)
    (config / 'init').write_text('Shell /bin/sh\nTheme forge\n')
    target = root / 'opened.txt'
    contents = b'First document opened from this terminal.\n' * 40
    target.write_bytes(contents)
    name = 'look-placement'
    inherited = {} if args.tty else {
        'PARDES_TEST': '1', 'PARDES_TEST_COLS': '150', 'PARDES_TEST_ROWS': '48',
        'PARDES_TEST_CAPTURE_DIR': str(root), 'SDL_VIDEODRIVER': 'x11',
    }
    # No document argument: the regression concerns the very first document.
    launch = ['-c', 'stty cols 150 rows 48; exec "$@"', name,
              str(args.binary.resolve()), '--9p=' + name, '-n', '3']
    with session('/bin/sh', root, name, socket_name=name, tty=True,
                 launch=launch, inherited=inherited) as (client, _):
        serials = list(panes(client))
        require(len(serials) == 3, 'classic fixture did not boot three terminals')
        markers = {}
        for index, serial in enumerate(serials):
            pane = f'/pane/{serial}'
            require('pty' in client.list(pane), 'fixture contains an existing document')
            marker = f'LP_{index}'
            markers[serial] = marker
            client.write(pane + '/tag', b'', truncate=True)
            client.write(pane + '/tag', f' {marker} Tty Del Collapse'.encode())
            # Establish non-pristine shells and a stable prompt before measuring.
            client.write(pane + '/pty/data', f"printf 'ready-{index}\\n'\r".encode())
            require(wait_for(lambda: f'\nready-{index}\n'.encode() in
                             client.read(pane + '/body').replace(b'\r', b'')),
                    'terminal did not execute readiness command')
        before_frame, before = require(wait_for(lambda: geometry(client, markers)),
                                       'terminal tags not visible in fixture')
        source = serials[source_index]
        source_rect = before[source]
        require(source_rect[3] >= 8, 'source terminal too short for a local split')
        require(before[serials[0]][:2] == (0, 2) and before[serials[1]][0] == 0 and
                before[serials[1]][1] > 2 and before[serials[2]][:2] == (75, 2),
                f'unexpected initial classic layout: {before}')
        client.write(f'/pane/{source}/look', f'{target}\n'.encode())
        opened = require(wait_for(lambda: next((serial for serial in panes(client)
                                               if serial not in serials), None)),
                         'Look did not open the first document')
        markers[opened] = 'opened.txt'
        after_frame, after = require(wait_for(lambda: geometry(client, markers)),
                                     'Look pane tags not visible')
        require(len(panes(client)) == 4, 'Look removed a terminal or added extra panes')
        x, y, width, height = source_rect
        require(after[source][:3] == (x, y, width),
                f'Look moved its originating terminal: {source_rect} -> {after[source]}')
        require(after[opened][:3] == (x, y + after[source][3], width) and
                after[source][3] + after[opened][3] == height,
                f'Look did not split directly below its caller: {after}')
        for serial in serials:
            if serial == source:
                continue
            require(after[serial] == before[serial],
                    f'Look resized or moved unrelated terminal {serial}: {before} -> {after}')
            require(rectangle_text(before_frame, before[serial]) ==
                    rectangle_text(after_frame, after[serial]),
                    f'Look changed unrelated terminal {serial} rendered text')
        require(client.read(f'/pane/{opened}/body') == contents,
                'opened document contents differ')
        client.write(f'/pane/{source}/look', f'{target}:2\n'.encode())
        require(list(panes(client)) == serials + [opened], 'repeated Look created a duplicate pane')
        require(wait_for(lambda: geometry(client, markers)[1] == after),
                'repeated Look changed layout')
        require(target.read_bytes() == contents, 'Look modified the fixture on disk')
        if args.output:
            capture(client, root, 'look-below-caller', not args.tty)
        print(f'{"TTY" if args.tty else "SDL"} source {"lower-left" if source_index == 1 else "right"}: '
              'local split, unchanged other terminals, first-file contents and re-Look reuse passed', flush=True)


def run(args):
    # Cache-rooted fixtures avoid relying on /tmp quota in developer sessions.
    cache = Path.home() / '.cache'
    cache.mkdir(exist_ok=True)
    context = nullcontext(str(args.output.resolve())) if args.output else tempfile.TemporaryDirectory(
        prefix='lp-', dir=cache)
    with context as directory:
        for source_index, label in [(1, 'left'), (2, 'right')]:
            run_case(args, Path(directory) / label, source_index)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('binary', type=Path)
    parser.add_argument('--tty', action='store_true')
    parser.add_argument('--output', type=Path)
    run(parser.parse_args())