a73x

test/native_fonts.py

Ref:   Size: 15.9 KiB   History

#!/usr/bin/env python3
"""Config, restart, font pixels and optional Nerd Font/Wayland acceptance.

Pass --family with an installed fixed-pitch family. --nerd additionally checks
private-use icons against missing-glyph output. --output names only an owned
headless Sway output; no desktop output may be used.
"""
import argparse
import ctypes
import ctypes.util
import hashlib
import json
import math
import os
from pathlib import Path
import shlex
import subprocess
import sys

sys.dont_write_bytecode = True
from native_lifecycle import LifecycleRig, start_workspace
from native_resize import by_id
from native_theme import pixel
from native_tiling import eventually, require

ICONS = '\uf07b \uf120 \ue0b0'
ASCII = 'MWil01_@# abcXYZ'
FALLBACK_CANDIDATES = (0x0416, 0x1F0A0, 0x1D400, 0x1FB00, 0x4DC0, 0x10780, 0x1CC00)


def config_path(rig):
    return Path(rig.env['XDG_CONFIG_HOME']) / 'mux/config'


def write_config(rig, text):
    path = config_path(rig)
    path.parent.mkdir(exist_ok=True)
    path.write_text(text)
    return path


def paint(rig, refs, nerd=False):
    for pane_id in refs:
        rig.focus(pane_id)
        specimen = ('\\033[0m\\033[?25l\\033[2J\\033[H' + ASCII +
                    '\\033[3;1H' + (ICONS if nerd else 'Font selection') +
                    '\\033[5;1H\\033[1mBold\\033[0m \\033[3mItalic\\033[0m' +
                    '\\033[7;1HFONT-READY')
        rig.shell("export PS1=''; printf '%b' " + shlex.quote(specimen))
        rig.wait_state(lambda s: 'FONT-READY' in by_id(s)[pane_id]['painted_text'])
    return rig.state()


def crop(capture, rect):
    width, height, raw = capture
    x, y, w, h = (int(rect[k]) for k in ('x', 'y', 'w', 'h'))
    require(x >= 0 and y >= 0 and x + w <= width and y + h <= height, 'bad crop')
    return b''.join(raw[((y + r) * width + x) * 3:((y + r) * width + x + w) * 3]
                    for r in range(h))


def signature(rig):
    state = rig.state()
    capture = rig.last_pixels()
    # Compare actual ASCII rasters, excluding cursor and pane header.
    signatures = []
    for pane in state['panes']:
        rect = pane['content'] | {'w': len(ASCII) * state['cell_w'], 'h': state['cell_h']}
        signatures.append(hashlib.sha256(crop(capture, rect)).hexdigest())
    require(len(set(signatures)) == 1, 'same text differs across off-origin panes')
    return state['cell_w'], state['cell_h'], signatures[0]


def font_match(family):
    result = subprocess.check_output(
        ['fc-match', '-f', '%{family[0]}\n%{file}\n%{index}\n%{spacing}\n', family],
        text=True).splitlines()
    return {'family': result[0], 'file': result[1], 'index': int(result[2]), 'spacing': result[3]}


def font_glyphs(family, codepoints):
    result = font_match(family)
    lib = ctypes.CDLL(ctypes.util.find_library('freetype'))
    lib.FT_Init_FreeType.argtypes = [ctypes.POINTER(ctypes.c_void_p)]
    lib.FT_New_Face.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.POINTER(ctypes.c_void_p)]
    lib.FT_Get_Char_Index.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
    lib.FT_Get_Char_Index.restype = ctypes.c_uint
    lib.FT_Done_Face.argtypes = [ctypes.c_void_p]
    lib.FT_Done_FreeType.argtypes = [ctypes.c_void_p]
    library, face = ctypes.c_void_p(), ctypes.c_void_p()
    require(lib.FT_Init_FreeType(ctypes.byref(library)) == 0, 'FreeType oracle init')
    try:
        require(lib.FT_New_Face(library, os.fsencode(result['file']), result['index'], ctypes.byref(face)) == 0,
                'FreeType oracle face')
        try:
            result['glyph_indices'] = [lib.FT_Get_Char_Index(face, cp) for cp in codepoints]
            return result
        finally:
            lib.FT_Done_Face(face)
    finally:
        lib.FT_Done_FreeType(library)


def font_has_icons(family):
    result = font_glyphs(family, [*(ord(c) for c in ICONS[::2]), 0x10ffff])
    indices = result['glyph_indices']
    require(all(indices[:3]) and len(set(indices[:3])) == 3,
            'selected font lacks distinct Nerd Font icons')
    require(indices[3] == 0, 'missing-glyph control unexpectedly exists')
    return {'file': result['file'], 'glyph_indices': indices[:3]}


def fallback_oracle(primary, fallback='Adwaita Mono'):
    match = font_match(fallback)
    if match['family'].casefold() != fallback.casefold() or match['spacing'] not in ('100', '110'):
        return None
    primary_result = font_glyphs(primary, FALLBACK_CANDIDATES)
    fallback_result = font_glyphs(fallback, FALLBACK_CANDIDATES)
    for cp, primary_glyph, fallback_glyph in zip(
            FALLBACK_CANDIDATES, primary_result['glyph_indices'], fallback_result['glyph_indices']):
        if primary_glyph == 0 and fallback_glyph != 0:
            return {'family': fallback, 'codepoint': f'U+{cp:04X}', 'text': chr(cp),
                    'primary_file': primary_result['file'], 'fallback_file': fallback_result['file'],
                    'fallback_glyph': fallback_glyph}
    return None


def glyph_signature(rig, pane_id, text):
    rig.focus(pane_id)
    specimen = '\\033[?25l\\033[2J\\033[H' + text + '\\033[3;1HFALLBACK-READY'
    rig.shell("printf '%b' " + shlex.quote(specimen))
    rig.wait_state(lambda s: 'FALLBACK-READY' in by_id(s)[pane_id]['painted_text'])
    state = rig.state()
    pane = by_id(state)[pane_id]['content']
    pixels = crop(rig.last_pixels(), {'x': pane['x'], 'y': pane['y'],
                                      'w': state['cell_w'] * 2, 'h': state['cell_h']})
    return hashlib.sha256(pixels).hexdigest(), any(value != 16 for value in pixels)


def check_icons(rig, refs):
    for pane_id in refs:
        rig.focus(pane_id)
        rig.shell("printf '%b' " + shlex.quote('\\033[3;1H' + ICONS + ' \U0010ffff'))
        rig.wait_state(lambda s: ICONS in by_id(s)[pane_id]['painted_text'])
    state = rig.state()
    capture = rig.last_pixels()
    for pane in state['panes']:
        cw, ch = state['cell_w'], state['cell_h']
        content = pane['content']
        shapes = [crop(capture, {'x': content['x'] + col * cw, 'y': content['y'] + 2 * ch,
                                 'w': cw, 'h': ch}) for col in (0, 2, 4, 6)]
        require(len(set(shapes)) == 4, 'icons are missing, duplicated or replaced by .notdef')
        require(all(any(v != 16 for v in shape) for shape in shapes[:3]), 'blank icon')


def invalid_configs(rig, family):
    cases = [('font-size = nan\n', '1'), ('# first\nfont-size = 0\n', '2'),
             ('font-size = 193\n', '1'), ('font-famly = monospace\n', '1'),
             ('font-family = "unterminated\n', '1'), ('font-size 12\n', '1')]
    saved = rig.saved.read_bytes()
    for i, (text, line) in enumerate(cases):
        path = write_config(rig, text)
        env = rig.env.copy()
        env.pop('MUXG_TEST_FIFO', None)
        result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
        require(result.returncode == 2, f'bad config {i} did not fail promptly')
        require(str(path) + ':' + line in result.stderr, f'missing file:line diagnostic: {result.stderr}')
        require(rig.saved.read_bytes() == saved, 'bad config rewrote saved workspace')
    write_config(rig, 'font-family = MuxDefinitelyMissingFamily012345\n')
    result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
    require(result.returncode == 2 and 'MuxDefinitelyMissingFamily012345' in result.stderr,
            'missing family silently substituted or lacked actionable diagnostic')
    write_config(rig, 'font-family = "' + family + '"\nfont-family = MuxMissingFallback012345\n')
    result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
    require(result.returncode == 2 and 'MuxMissingFallback012345' in result.stderr,
            'missing secondary family was skipped or misdiagnosed')
    match = subprocess.check_output(['fc-match', '-f', '%{family[0]}\n%{spacing}', 'sans-serif'], text=True).split('\n')
    if match[1] in ('', '0'):
        write_config(rig, 'font-family = ' + match[0] + '\n')
        result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
        require(result.returncode == 2 and match[0] in result.stderr,
                'installed proportional font was accepted as monospace')
    write_config(rig, '')
    for flags in (['--font-px', '16', '--font-size', '12'], ['--font-px', '0'], ['--font-size', 'inf']):
        result = subprocess.run([rig.muxg, *flags], env=env, capture_output=True, text=True, timeout=5)
        require(result.returncode == 2, f'invalid CLI accepted: {flags}')
    write_config(rig, 'bad config\n')
    result = subprocess.run([rig.muxg, '--help'], env=env, capture_output=True, text=True, timeout=5)
    require(result.returncode == 0, '--help was blocked by invalid config')
    rig.ok('invalid config/CLI fail promptly with file:line diagnostics; help still works')


def exercise(rig, refs=None, *, family='monospace', nerd=False, output=None):
    """Run font/config acceptance, optionally continuing an existing workspace."""
    rig.env.pop('MUXG_TEST_THEME', None)
    starting_checks = rig.checkpoints
    report = {'family': family}
    fallback = fallback_oracle(family)
    fallback_family = fallback['family'] if fallback else family
    original_scale = None

    def scale(value):
        response = subprocess.run(['swaymsg', '-r', 'output', output, 'scale', str(value)],
                                  capture_output=True, text=True, check=True)
        require(all(x['success'] for x in json.loads(response.stdout)), 'scale request failed')

    try:
        if output:
            require(refs is None, 'font scale acceptance must own its initial window')
            require(output.startswith('HEADLESS-') and rig.env['SDL_VIDEO_DRIVER'] == 'wayland',
                    'use only owned headless Wayland output')
            outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs']))
            original_scale = next(o['scale'] for o in outputs if o['name'] == output)
            scale(2)
        if refs is None:
            refs = start_workspace(rig)
        paint(rig, refs)
        baseline = signature(rig)
        state = rig.state()
        pane = state['panes'][0]['content']
        require(pixel(rig.last_pixels(), pane['x'] + pane['w'] - 2, pane['y'] + 2) == '101010',
                'missing config did not preserve legacy background')
        # The kernel shell PID and tty path establish that restarts retain shells.
        shell_files = {}
        for pane_id in refs:
            rig.focus(pane_id)
            path = rig.root / f'shell-{pane_id}'
            rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
            eventually(lambda: path.exists() and path.stat().st_size, 'shell PID not written')
            shell_files[pane_id] = (path, path.read_text())
        config = ('font-family = "' + family + '"\nfont-family = "' +
                  fallback_family + '"\nfont-size = 12.4\n')
        write_config(rig, config)
        paint(rig, refs)
        require(signature(rig) == baseline, 'config changed the already running client')
        rig.quit()
        invalid_configs(rig, family)
        write_config(rig, config)
        rig.launch_gui([], 'gui-config-font')
        paint(rig, refs, nerd)
        configured = signature(rig)
        rig.kernel_sizes()
        for pane_id, (path, pid) in shell_files.items():
            rig.focus(pane_id)
            path.unlink()
            rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
            eventually(lambda: path.exists() and path.stat().st_size, 'reopened shell PID not written')
            require(path.read_text() == pid and Path(rig.tty_paths[pane_id]).exists(), 'restart replaced shell')
        if nerd:
            report['font_oracle'] = font_has_icons(family)
            check_icons(rig, refs)
        rig.ok('config applies on restart; three panes retain shells and correct kernel PTY sizes')
        rig.quit()
        # Explicit CLI families replace the complete conflicting config chain.
        write_config(rig, 'font-family = monospace\nfont-family = MuxIgnoredFallback012345\nfont-size = 18\n')
        cli_families = ['--font-family', family, '--font-family', fallback_family]
        rig.launch_gui([*cli_families, '--font-size', '12.4'], 'gui-cli-font')
        paint(rig, refs, nerd)
        require(signature(rig) == configured, 'CLI did not override both config font settings')
        fallback_render = glyph_signature(rig, next(iter(refs)), fallback['text']) if fallback else None
        rig.quit()
        if fallback:
            rig.launch_gui(['--font-family', family, '--font-size', '12.4'],
                           'gui-cli-primary-font')
            primary_render = glyph_signature(rig, next(iter(refs)), fallback['text'])
            require(fallback_render[1], 'fallback glyph rendered blank')
            require(fallback_render[0] != primary_render[0],
                    'configured fallback matched the primary missing-glyph raster')
            fallback.update(fallback_raster=fallback_render[0], primary_raster=primary_render[0])
            report['fallback_oracle'] = fallback
            rig.ok('Adwaita fallback glyph differs from the primary .notdef raster')
            rig.quit()
        # Record the independently calculated raster size; fractional rounding is also unit-tested.
        display_scale = 2 if output else 1
        base_dpi = 72 if sys.platform == 'darwin' else 96
        pixels = math.floor(12.4 * base_dpi / 72 * display_scale + .5)
        # A .5 base pixel value at 200% cannot be expressed by --font-px;
        # compare 12pt with 16px separately; the scale round trip checks retained rasters.
        rig.launch_gui(['--font-family', family, '--font-size', '12'], 'gui-twelve-points')
        paint(rig, refs)
        points12 = signature(rig)
        rig.quit()
        rig.launch_gui(['--font-family', family, '--font-px', str(base_dpi // 6)], 'gui-pixel-override')
        paint(rig, refs)
        require(signature(rig) == points12, 'point units or legacy pixel override differ')
        rig.quit()
        # An empty XDG variable resolves through an isolated HOME, not the real one.
        home = rig.root / 'font-home'
        fallback = home / '.config/mux/config'
        fallback.parent.mkdir(parents=True)
        fallback.write_text(config)
        rig.env['HOME'] = str(home)
        rig.env['XDG_CONFIG_HOME'] = ''
        rig.launch_gui([], 'gui-home-fallback')
        paint(rig, refs, nerd)
        require(signature(rig) == configured, 'HOME fallback did not load same config')
        rig.ok('CLI precedence, point units, --font-px compatibility and HOME fallback verified in pixels')
        if output:
            for value in (1, 1.5, 2):
                scale(value)
                rig.wait_state(lambda s: s['width'] == value * s['logical_width'])
                paint(rig, refs, nerd)
                rig.kernel_sizes()
                if nerd:
                    check_icons(rig, refs)
            require(signature(rig) == configured, 'family/size changed after DPI round trip')
            rig.ok('200% -> 100% -> 150% -> 200% retains family, icons and independent PTY sizes')
        rig.assert_cli_untouched()
        report.update(baseline=baseline, configured=configured, expected_raster_pixels=pixels,
                      checks=rig.checkpoints - starting_checks)
        (rig.root / 'fonts-result.json').write_text(json.dumps(report, indent=2))
        rig.quit()
        return report
    finally:
        if original_scale is not None:
            scale(original_scale)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('mux')
    parser.add_argument('muxg')
    parser.add_argument('--family', default='monospace')
    parser.add_argument('--nerd', action='store_true')
    parser.add_argument('--output')
    args = parser.parse_args()
    rig = LifecycleRig(args.mux, args.muxg)
    try:
        exercise(rig, family=args.family, nerd=args.nerd, output=args.output)
        print('PASS: native fonts', flush=True)
    finally:
        rig.close()


if __name__ == '__main__':
    main()