Files
OneCAD/tests/tools/test_icon_review.py

332 lines
20 KiB
Python

"""Long-term discovery and acceptance behavior of the repository-owned review app."""
import contextlib
import json
import re
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import tomllib
import unittest
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
TOOLS = Path(__file__).resolve().parents[2] / 'assets/icons/ui/design/tools'
SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="20" fill="currentColor"/></svg>'
@contextlib.contextmanager
def review_fixture():
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
design = root / 'assets/icons/ui/design'
shutil.copytree(TOOLS, design / 'tools')
(design / 'svg/model').mkdir(parents=True)
(design / 'svg/model/box.svg').write_text(SVG)
shutil.copy2(TOOLS.parent / 'palette.toml', design / 'palette.toml')
(design / 'references.toml').write_text('[category.model]\nlabel="Model"\nrule="Standard projection"\nicons=["box"]\n')
(design / 'aliases.toml').write_text('[alias]\nbox_alias="box"\n')
(design / 'review.json').write_text(json.dumps({'final': {'box': ''}, 'notes': {}}))
with socket.socket() as available:
available.bind(('127.0.0.1', 0))
port = available.getsockname()[1]
process = subprocess.Popen([sys.executable, '-B', str(design / 'tools/icon_status.py'), '--serve', str(port)],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
url = f'http://127.0.0.1:{port}'
def get(path):
with urllib.request.urlopen(url + path, timeout=3) as response:
data = response.read().decode()
return json.loads(data) if path == '/api/review' else data
def post(state, final=None, notes=None, endpoint='/api/review', rev=None):
payload = state if endpoint == '/api/palette' else {
'base': state, 'final': state['final'] if final is None else final,
'notes': state['notes'] if notes is None else notes, 'rev': rev or state['rev']}
try:
with urllib.request.urlopen(urllib.request.Request(url + endpoint, data=json.dumps(payload).encode()), timeout=3) as response:
return json.load(response)
except urllib.error.HTTPError as error:
error.close()
raise
try:
for attempt in range(100):
try:
get('/api/review'); break
except urllib.error.URLError:
if process.poll() is not None: raise RuntimeError(process.stderr.read().decode())
time.sleep(.02)
else: raise RuntimeError('server did not start')
yield root, design, get, post
finally:
process.terminate(); process.wait(timeout=5); process.stderr.close()
class IconReviewTest(unittest.TestCase):
def test_inline_clips_and_masks_are_scoped_to_each_preview_and_dashes_use_guides(self):
with review_fixture() as (root, design, get, post):
for name in ('environment_map', 'new', 'draft', 'bounding_solid', 'extend'):
shutil.copy2(TOOLS.parent / f'svg/model/{name}.svg', design / f'svg/model/{name}.svg')
with (design / 'aliases.toml').open('a') as aliases:
aliases.write('surface_env={master="environment_map",accent="surface"}\n')
page = get('/')
fragments = re.findall(r'<svg\b.*?</svg>', page, re.S)
seen = set()
for svg in fragments:
ids = re.findall(r'\bid="([^"]+)"', svg)
self.assertTrue(seen.isdisjoint(ids), 'all theme/vision/alias previews need unique ids')
seen.update(ids)
for ref in re.findall(r'url\(#([^)]+)\)', svg): self.assertIn(ref, ids, 'clip or mask must belong to this SVG')
self.assertGreater(len(seen), 10)
for svg in fragments:
tree = ET.fromstring(svg)
mask_nodes = {id(node) for mask in tree.iter('{http://www.w3.org/2000/svg}mask') for node in mask.iter()}
for node in tree.iter():
if not node.get('stroke-dasharray'): continue
stroke = node.get('stroke', 'none')
if stroke == 'none': continue
if id(node) in mask_nodes and stroke == 'black': continue
self.assertTrue(any(role in stroke for role in ('roles-guide-', 'active-3-')), stroke)
self.assertNotIn('var(--icon-palette-roles-gap-', svg)
self.assertIn('review-environment_map-off-dark-full-disc-env', page)
def card(icon): return page.split('data-id="' + icon + '"', 1)[1].split('<div class="meta">', 1)[0]
self.assertIn('stroke="var(--icon-palette-roles-active-3-', card('bounding_solid'))
self.assertIn('stroke="var(--icon-palette-accents-sketch-active-3-', card('extend'))
self.assertIn('stroke="var(--icon-palette-roles-guide-', card('draft'))
def test_live_palette_tokens_save_only_display_colours_and_reject_stale_or_invalid_changes(self):
with review_fixture() as (root, design, get, post):
palette_path = design / 'palette.toml'
original = palette_path.read_text()
palette = tomllib.loads(original)
state = json.loads(get('/api/palette'))
tokens = {token['id']: token for token in state['tokens']}
self.assertIn('roles.ink', tokens)
self.assertIn('accents.surface.active-2', tokens)
self.assertIn('variants.red-green.sketch.active-3', tokens)
self.assertNotIn('accents.construction.guide', tokens, 'guides have one shared colour')
self.assertNotIn('roles.ink.source', tokens)
self.assertTrue(tokens['roles.gap']['knockout'])
accepted = (design / 'review.json').read_bytes()
artwork = (design / 'svg/model/box.svg').read_bytes()
def save(revision, changes):
return post({'revision': revision, 'changes': changes}, endpoint='/api/palette')
replacement = '#123456' if tokens['roles.ink']['dark'] != '#123456' else '#654321'
paints = {'light': tokens['roles.ink']['light'], 'dark': replacement}
changes = {'roles.ink': paints,
'accents.surface.active-2': {'light': tokens['accents.surface.active-2']['light'], 'dark': '#bc789a'},
'variants.red-green.sketch.active-3': {'light': tokens['variants.red-green.sketch.active-3']['light'], 'dark': '#778899'}}
saved = save(state['revision'], changes)
self.assertTrue(saved['ok'])
expected = tomllib.loads(original)
for key, colours in changes.items():
target = expected
for part in key.split('.'): target = target[part]
target.update(colours)
self.assertEqual(tomllib.loads(palette_path.read_text()), expected)
self.assertIn('# Icon colour roles.', palette_path.read_text(), 'comments remain intact')
self.assertEqual(tomllib.loads(palette_path.read_text())['roles']['ink']['source'], palette['roles']['ink']['source'])
for revision, changes in [
(state['revision'], {'roles.ink': paints}),
(saved['revision'], {'roles.ink.source': paints}),
(saved['revision'], {'accents.construction.guide': paints}),
(saved['revision'], {'roles.gap': paints}),
(saved['revision'], {'roles.ink': {'light': 'url(evil)', 'dark': replacement}}),
]:
before = palette_path.read_bytes()
with self.assertRaises(urllib.error.HTTPError): save(revision, changes)
self.assertEqual(palette_path.read_bytes(), before)
page = get('/')
self.assertIn('var(--icon-palette-roles-line-light,', page)
self.assertIn('--icon-palette-roles-ink-dark:' + replacement, page)
self.assertIn('id="palette-tokens"', page)
self.assertIn('input.oninput', page)
self.assertIn('body.palette-preview .card.final .art{opacity:1}', page)
self.assertEqual((design / 'review.json').read_bytes(), accepted)
self.assertEqual((design / 'svg/model/box.svg').read_bytes(), artwork)
self.assertFalse(list(design.glob('*.tmp')))
def test_knockouts_mask_only_preceding_layers_and_preserve_later_foreground(self):
with review_fixture() as (root, design, get, post):
source = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><clipPath id="left"><path d="M0 0L32 0L32 64L0 64Z"/></clipPath></defs><circle id="base" cx="32" cy="32" r="28" fill="#8e9faf"/><g clip-path="url(#left)"><circle cx="32" cy="32" r="12" fill="#ffffff"/></g><circle id="foreground" cx="32" cy="32" r="3" fill="#236ead"/></svg>'''
path = design / 'svg/model/cutout.svg'
path.write_text(source)
accepted = (design / 'review.json').read_bytes()
page = get('/')
fragments = [svg for svg in re.findall(r'<svg\b.*?</svg>', page, re.S) if '-base"' in svg]
self.assertGreater(len(fragments), 3)
ns = '{http://www.w3.org/2000/svg}'
for svg in fragments:
tree = ET.fromstring(svg)
mask = tree.find(ns + 'defs/' + ns + 'mask')
self.assertIsNotNone(mask)
self.assertEqual(mask.get('maskUnits'), 'userSpaceOnUse')
self.assertEqual(mask[0].get('fill'), 'white')
self.assertIn('-left)', mask[1].get('clip-path'))
self.assertEqual(mask[1][0].get('fill'), 'black')
preceding, foreground = list(tree)[-2:]
self.assertEqual(preceding.get('mask'), 'url(#' + mask.get('id') + ')')
self.assertTrue(preceding[0].get('id').endswith('-base'))
self.assertTrue(foreground.get('id').endswith('-foreground'))
self.assertIsNone(foreground.get('mask'))
self.assertNotIn('roles-gap-', svg)
self.assertEqual(path.read_text(), source)
self.assertEqual((design / 'review.json').read_bytes(), accepted)
def test_semantic_colours_reload_from_one_palette_without_changing_acceptance(self):
with review_fixture() as (root, design, get, post):
palette = design / 'palette.toml'
colours = tomllib.loads(palette.read_text())
source = colours['roles']['active-3'].get('source', colours['roles']['active-3']['light'])
art = SVG.replace('currentColor', source)
for name in ('line', 'c_fix'):
(design / f'svg/model/{name}.svg').write_text(art)
with (design / 'aliases.toml').open('a') as aliases:
aliases.write('ground="c_fix"\nsurface_line={master="line",accent="surface"}\n')
state, accepted = get('/api/review'), (design / 'review.json').read_bytes()
def card(page, icon):
return page.split('data-id="' + icon + '"', 1)[1].split('<div class="meta">', 1)[0]
page = get('/')
self.assertIn(colours['accents']['sketch']['active-3']['light'], card(page, 'line'))
self.assertIn(colours['variants']['red-green']['sketch']['active-3']['dark'], card(page, 'line'),
'red/green vision shares the application ramp')
self.assertIn(colours['accents']['constraint']['active-3']['light'], card(page, 'c_fix'))
self.assertIn(colours['roles']['active-3']['light'], card(page, 'ground'),
'a shared master does not transfer its meaning to an alias')
self.assertIn(colours['accents']['surface']['active-3']['light'], card(page, 'surface_line'),
'explicit alias accent wins over the Sketch master')
def set_light(section, colour):
lines, current = palette.read_text().splitlines(), ''
for index, line in enumerate(lines):
if line.strip().startswith('['):
current = line.strip().split(']', 1)[0] + ']'
elif current == '[' + section + ']' and line.split('=', 1)[0].strip() == 'light':
lines[index] = 'light = ' + json.dumps(colour)
palette.write_text('\n'.join(lines) + '\n')
return
self.fail('missing palette section ' + section)
replacement = '#123456' if colours['accents']['sketch']['active-3']['light'] != '#123456' else '#654321'
set_light('accents.sketch.active-3', replacement)
self.assertNotEqual(state['rev'], get('/api/review')['rev'])
self.assertIn(replacement, card(get('/'), 'line'), 'a running server reads the updated palette')
base = '#345678' if colours['roles']['active-3']['light'] != '#345678' else '#876543'
set_light('roles.active-3', base)
self.assertIn(base, card(get('/'), 'ground'), 'source identifiers survive changing the base display palette too')
self.assertEqual((design / 'review.json').read_bytes(), accepted)
self.assertEqual((design / 'svg/model/line.svg').read_text(), art)
def test_new_unused_masters_and_alias_targets_are_never_hidden(self):
with review_fixture() as (root, design, get, post):
original = get('/api/review')
page = get('/')
self.assertIn('data-id="box"', page, 'unused alias target must still have a card')
self.assertIn('data-id="box_alias"', page)
(design / 'svg/model/brand_new.svg').write_text(SVG)
current = get('/api/review')
self.assertNotEqual(original['rev'], current['rev'])
self.assertNotIn('brand_new', current['final'])
page = get('/')
self.assertIn('data-id="brand_new"', page)
self.assertIn("let filter = 'open', tab = ''", page)
self.assertNotIn("const VK='icon-view'", page)
self.assertIn('cards.length - finals', page, 'notes remain a subset of Open')
post(current, final=current['final'] + ['brand_new'])
self.assertIn('brand_new', get('/api/review')['final'])
# A new opening does not inherit a previous session's hidden tab/query.
self.assertIn('data-f="open" class="on"', get('/'))
def test_changed_art_reopens_and_requires_deliberate_current_acceptance(self):
with review_fixture() as (root, design, get, post):
initial = get('/api/review')
self.assertIn('box', initial['final'])
old_hash = json.loads((design / 'review.json').read_text())['final']['box']
(design / 'svg/model/box.svg').write_text(SVG.replace('r="20"', 'r="18"'))
changed = get('/api/review')
self.assertNotIn('box', changed['final'])
self.assertIn('changed since acceptance', get('/'))
self.assertEqual(old_hash, json.loads((design / 'review.json').read_text())['final']['box'])
(design / 'svg/model/box.svg').write_text(SVG.replace('r="20"', 'r="17"'))
with self.assertRaises(urllib.error.HTTPError) as error:
post(changed, final=['box'])
self.assertEqual(error.exception.code, 400)
post(get('/api/review'), final=['box'])
self.assertIn('box', get('/api/review')['final'])
self.assertNotEqual(old_hash, json.loads((design / 'review.json').read_text())['final']['box'])
def test_missing_art_is_visible_but_cannot_be_accepted(self):
with review_fixture() as (root, design, get, post):
commands = root / 'crates/engine/src/cmd'
commands.mkdir(parents=True)
(commands / 'example.rs').write_text('CommandSpec::new("example.action", "Example", run).icon("missing");')
page = get('/')
self.assertIn('data-id="missing"', page)
self.assertIn('aria-label="Accept missing as final" disabled', page)
with self.assertRaises(urllib.error.HTTPError):
post(get('/api/review'), final=['box', 'missing'])
def test_final_alias_protects_shared_art_and_newer_notes_survive_stale_save(self):
with review_fixture() as (root, design, get, post):
state = get('/api/review')
post(state, final=['box', 'box_alias'])
state = get('/api/review')
post(state, final=['box_alias'], notes={'box': 'Unreviewed master note'})
state = get('/api/review')
self.assertEqual(post(state, endpoint='/api/export')['sent'], 0)
# A stale page changes another note; it must not resurrect replaced feedback.
saved = json.loads((design / 'review.json').read_text())
saved['notes']['box'] = 'Newer feedback'
(design / 'review.json').write_text(json.dumps(saved))
post(state, notes={**state['notes'], 'box_alias': 'Another note'})
self.assertEqual(get('/api/review')['notes']['box'], 'Newer feedback')
self.assertFalse(list(design.glob('*.tmp')))
def test_export_resolution_preserves_notes_added_since_the_snapshot(self):
with review_fixture() as (root, design, get, post):
(design / 'svg/model/fresh.svg').write_text(SVG)
state = get('/api/review')
post(state, notes={'fresh': 'Newer feedback'})
script = """
import sys
sys.path.insert(0,sys.argv[1])
import review_state
request={'notes':{'fresh':'Exported feedback'}}
assert review_state.resolve_export(request, {'fresh'}) == []
assert review_state.load()['notes']['fresh'] == 'Newer feedback'
request={'notes':{'fresh':'Newer feedback'}}
assert review_state.resolve_export(request, {'fresh'}) == ['fresh']
assert review_state.load()['notes'] == {}
"""
subprocess.run([sys.executable, '-B', '-c', script, str(design / 'tools')], check=True)
def test_style_reference_page_uses_repository_bases(self):
with review_fixture() as (root, design, get, post):
page = get('/guide')
self.assertIn('Canonical grey cube', page)
self.assertIn('Canonical grey slab', page)
self.assertIn('Standard projection', page)
self.assertIn('STYLE_GUIDE.md', page)
def test_scaffold_refuses_overwrite_and_starts_unaccepted(self):
with review_fixture() as (root, design, get, post):
# The fixture's palette is intentionally minimal; the scaffold uses
# the real helper constants and emits an editable starter SVG.
(root / 'ATTRIBUTION.md').write_text('# Attribution\n')
command = [sys.executable, '-B', str(design / 'tools/author/new_icon.py')]
first = subprocess.run(command + ['fresh_shape', '--base', 'cube'], capture_output=True, text=True)
self.assertEqual(first.returncode, 0, first.stderr)
artwork = design / 'svg/model/fresh_shape.svg'
self.assertTrue(artwork.exists())
self.assertNotIn('fresh_shape', get('/api/review')['final'])
self.assertIn('fresh_shape.svg', (root / 'ATTRIBUTION.md').read_text())
before = artwork.read_bytes()
again = subprocess.run(command + ['fresh_shape'], capture_output=True, text=True)
self.assertNotEqual(again.returncode, 0)
self.assertEqual(artwork.read_bytes(), before)
alias = subprocess.run(command + ['box_alias'], capture_output=True, text=True)
self.assertNotEqual(alias.returncode, 0)
if __name__ == '__main__': unittest.main()