| 1 | #!/usr/bin/env python3 |
| 2 | """Check source coverage across deterministic mixed-Unicode layout cases.""" |
| 3 | import argparse |
| 4 | import json |
| 5 | from pathlib import Path |
| 6 | import random |
| 7 | import subprocess |
| 8 | |
| 9 | |
| 10 | def cases(): |
| 11 | rng = random.Random(20260906) |
| 12 | tokens = ['a', ' ', 'é', 'é', 'שלום', 'العربية', '👩🏽💻', '👩👩👧👦', '🇨🇦', '\u00a0', 'ffi', '\u200d'] |
| 13 | result = [] |
| 14 | for index in range(500): |
| 15 | text = ''.join(rng.choice(tokens) for _ in range(rng.randrange(35))) |
| 16 | result.append({'id': str(index), 'width': rng.uniform(1, 250), |
| 17 | 'runs': [{'text': text, 'font': 'Arial', 'size': rng.uniform(7, 25), |
| 18 | 'bold': False, 'italic': False}]}) |
| 19 | return result |
| 20 | |
| 21 | |
| 22 | def check(source, output): |
| 23 | if len(source) != len(output['cases']): |
| 24 | raise ValueError('Probe did not return every case') |
| 25 | count = 0 |
| 26 | for case, result in zip(source, output['cases']): |
| 27 | if case['id'] != result['id']: |
| 28 | raise ValueError('Probe returned different case identities') |
| 29 | raw = ''.join(run['text'] for run in case['runs']).encode('utf-16-le') |
| 30 | end = 0 |
| 31 | for line in result['lines']: |
| 32 | if line['start_utf16'] != end or not end <= line['end_utf16'] <= len(raw) // 2: |
| 33 | raise ValueError(f"{case['id']}: noncontiguous or out-of-bounds source ranges") |
| 34 | start, end = line['start_utf16'], line['end_utf16'] |
| 35 | if raw[2 * start:2 * end].decode('utf-16-le') != line['text']: |
| 36 | raise ValueError(f"{case['id']}: line text differs from its source range") |
| 37 | count += 1 |
| 38 | if end * 2 != len(raw): |
| 39 | raise ValueError(f"{case['id']}: layout does not cover all source text") |
| 40 | return count |
| 41 | |
| 42 | |
| 43 | if __name__ == '__main__': |
| 44 | parser = argparse.ArgumentParser(description=__doc__) |
| 45 | parser.add_argument('output', type=Path) |
| 46 | parser.add_argument('probe', nargs=argparse.REMAINDER) |
| 47 | args = parser.parse_args() |
| 48 | if not args.probe: |
| 49 | parser.error('Provide a probe command after the output directory') |
| 50 | args.output.mkdir(parents=True, exist_ok=False) |
| 51 | source = cases() |
| 52 | path = args.output / 'cases.json' |
| 53 | path.write_text(json.dumps(source, ensure_ascii=False, indent=2) + '\n') |
| 54 | command = args.probe + [str(path)] |
| 55 | (args.output / 'command.json').write_text(json.dumps(command) + '\n') |
| 56 | with (args.output / 'output.json').open('w') as stdout, (args.output / 'stderr.txt').open('w') as stderr: |
| 57 | result = subprocess.run(command, stdout=stdout, stderr=stderr) |
| 58 | summary = {'seed': 20260906, 'cases': len(source), 'exit': result.returncode} |
| 59 | if result.returncode == 0: |
| 60 | try: |
| 61 | summary['lines_checked'] = check(source, json.loads((args.output / 'output.json').read_text())) |
| 62 | except (ValueError, KeyError) as error: |
| 63 | summary['error'] = str(error) |
| 64 | else: |
| 65 | summary['error'] = 'Probe rejected a generated case; see stderr.txt' |
| 66 | (args.output / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n') |
| 67 | print(json.dumps(summary)) |
| 68 | raise SystemExit(1 if 'error' in summary else 0) |