Implement the reviewed UI icon system and durable review workflow (#853)
Thanks @Nathan-Dane.
This commit is contained in:
1 parent
c599238653
commit
64966bab88
374 files changed
+8187
-2221
No files matched your search
@@ -14,14 +14,14 @@
|
||||
"10": {"area":2809.248846,"bodies":1,"edges":39,"error":null,"faces":16,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut box","fillet","hole"],"unsupported":0,"volume":6696.329905},
|
||||
"11": {"area":7858.300481,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","rectangular pattern","fillet"],"unsupported":0,"volume":34605.048259},
|
||||
"12": {"area":4668.664174,"bodies":2,"edges":59,"error":null,"faces":25,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","chamfer","join cylinder","join box","cut cylinder","circular pattern"],"unsupported":3,"volume":11874.471611},
|
||||
"13": {"area":2101.521015,"bodies":1,"edges":103,"error":null,"faces":41,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2396.939548},
|
||||
"13": {"area":2101.521015,"bodies":1,"edges":101,"error":null,"faces":40,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2396.939548},
|
||||
"14": {"area":10553.267409,"bodies":1,"edges":43,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","fillet"],"unsupported":0,"volume":10369.008764},
|
||||
"15": {"area":2312.730168,"bodies":1,"edges":39,"error":null,"faces":15,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","cut box","rectangular pattern","cut sphere","cut cylinder","cut sphere","join cylinder"],"unsupported":0,"volume":5728.661237},
|
||||
"16": {"area":8872.873884,"bodies":3,"edges":49,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":3,"steps":["box","join cylinder","circular pattern","hole"],"unsupported":0,"volume":44953.403148},
|
||||
"17": {"area":6022.921702,"bodies":1,"edges":48,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","shell","cut cylinder"],"unsupported":0,"volume":2981.269959},
|
||||
"18": {"area":5524.583817,"bodies":4,"edges":98,"error":"boolean Cut: the operation failed: no result","faces":44,"outcome":"failed","rejected":0,"shells":4,"steps":["extruded profile","join cylinder","circular pattern","cut cylinder","hole","cut cylinder","hole","circular pattern"],"unsupported":0,"volume":21568.016672},
|
||||
"19": {"area":3440.869757,"bodies":1,"edges":42,"error":null,"faces":16,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":5909.174021},
|
||||
"20": {"area":5727.101189,"bodies":1,"edges":116,"error":null,"faces":45,"outcome":"ok","rejected":1,"shells":1,"steps":["extruded profile","fillet","cut box","cut cylinder","hole","circular pattern","cut box"],"unsupported":0,"volume":17142.727051},
|
||||
"20": {"area":5726.92956,"bodies":1,"edges":116,"error":null,"faces":45,"outcome":"ok","rejected":1,"shells":1,"steps":["extruded profile","fillet","cut box","cut cylinder","hole","circular pattern","cut box"],"unsupported":0,"volume":17142.747554},
|
||||
"21": {"area":5814.791707,"bodies":1,"edges":56,"error":null,"faces":22,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","join box","hole","fillet","hole","cut box"],"unsupported":1,"volume":19153.734422},
|
||||
"22": {"area":4634.449436,"bodies":2,"edges":138,"error":null,"faces":54,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","join box","join box","join box","rectangular pattern","cut cylinder"],"unsupported":0,"volume":7855.479914},
|
||||
"23": {"area":1795.696192,"bodies":1,"edges":16,"error":null,"faces":7,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":3198.694598},
|
||||
@@ -72,7 +72,7 @@
|
||||
"68": {"area":7338.675126,"bodies":1,"edges":36,"error":null,"faces":14,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","hole","hole","rectangular pattern","rectangular pattern","fillet","circular pattern"],"unsupported":0,"volume":29780.741772},
|
||||
"69": {"area":6451.122767,"bodies":1,"edges":60,"error":"offset: a corner whose faces don't meet the same way after t","faces":27,"outcome":"failed","rejected":0,"shells":3,"steps":["revolved section","join box","shell","shell"],"unsupported":0,"volume":4761.477515},
|
||||
"70": {"area":3682.669157,"bodies":1,"edges":24,"error":"lumps: shell boundaries intersect or touch; cavity ownership","faces":12,"outcome":"failed","rejected":0,"shells":2,"steps":["extruded profile","join box","hole"],"unsupported":1,"volume":9155.027162},
|
||||
"71": {"area":3980.574721,"bodies":1,"edges":52,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","cut cylinder","hole","join cylinder"],"unsupported":0,"volume":15749.927796},
|
||||
"71": {"area":3980.574721,"bodies":1,"edges":50,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","cut cylinder","hole","join cylinder"],"unsupported":0,"volume":15749.927796},
|
||||
"72": {"area":5770.270336,"bodies":2,"edges":44,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":2,"steps":["box","join cylinder","hole","join box","join box","cut box","cut cylinder","cut cylinder"],"unsupported":0,"volume":16081.801492},
|
||||
"73": {"area":6868.162117,"bodies":1,"edges":40,"error":"offset: a corner whose faces don't meet the same way after t","faces":18,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","join box","shell"],"unsupported":1,"volume":36461.939381},
|
||||
"74": {"area":9294.57311,"bodies":1,"edges":18,"error":null,"faces":9,"outcome":"ok","rejected":0,"shells":1,"steps":["box","hole"],"unsupported":0,"volume":55866.183074},
|
||||
@@ -95,7 +95,7 @@
|
||||
"91": {"area":3958.046518,"bodies":1,"edges":50,"error":null,"faces":20,"outcome":"ok","rejected":1,"shells":1,"steps":["revolved section","cut cylinder","fillet"],"unsupported":0,"volume":9721.244548},
|
||||
"92": {"area":2855.139217,"bodies":1,"edges":30,"error":null,"faces":12,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer","cut sphere"],"unsupported":0,"volume":9814.30292},
|
||||
"93": {"area":7266.404017,"bodies":7,"edges":111,"error":null,"faces":51,"outcome":"ok","rejected":0,"shells":7,"steps":["extruded profile","fillet","chamfer","join box","circular pattern","rectangular pattern","cut cylinder"],"unsupported":0,"volume":12734.393171},
|
||||
"94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856},
|
||||
"94": {"area":3480.70333,"bodies":1,"edges":63,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856},
|
||||
"95": {"area":2425.131626,"bodies":1,"edges":19,"error":"boolean Cut: the operation failed: no result; the operation ","faces":9,"outcome":"failed","rejected":0,"shells":1,"steps":["revolved section","hole","fillet","cut cylinder","cut sphere"],"unsupported":0,"volume":7106.301922}
|
||||
},
|
||||
"notes": [
|
||||
@@ -129,8 +129,10 @@
|
||||
{"reason":"Seed 31: #1581 no longer maps a pick on an earlier round's seam to some other nearby edge; the second fillet's selection includes the smooth seam at [2, 27.5, 13.5] beside the first round, which is now refused (no corner to round) instead of silently re-rounding a different edge at 1 mm, so the run goes on to a hole. Seed 83: #1316 was recorded before its branch took in #1495; with #1495's shell (an open face next to a concave edge no longer cuts the wall past it) the shelled part keeps more wall (6217 to 6339 mm3), its edges differ, and the 2.5 mm fillet of the first run is not picked; a later fillet is refused as not supported yet (an end face parallel to the edge)","seeds":[31,83]},
|
||||
{"reason":"the 2D route sews a cut whose outline has a point a hair from a segment's end (text-recut): seed 53's shell (step 3, a 2 mm shell of an extruded L with an arc side, opened at the notch's inner face) builds its cavity by the 2D route in 77 ms, where it took the B-rep path (2.4 s) and gave a part 336 mm³ lighter, which a box can't then be intersected with (no result). Its section at mid height is 338.27 mm² (a distance-rule raster of the profile gives 343.7, rounding the cavity's concave corners); the generator then builds the step 4 fillet it refused before, so the run goes on another way (16954.55 mm³, 48 faces)","seeds":[53]},
|
||||
{"reason":"#726 (#1687): a full revolve of a profile off the axis comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs. Replaying each new design's commands on main and on this branch: seeds 45, 77 and 94 end the same; in seed 21 a box joined onto the ring's flat top now fuses with it, where main kept two touching shells that a later hole split into two bodies.","seeds":[21,45,77,94]},
|
||||
{"reason":"#726 (#1718): a partial revolve made as a new body comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs (seed 24's shell now picks the centroid of a whole 270 deg annulus, which lies in the bore, so it is refused and the run goes on). Replaying each new design's commands on main and on this branch: 12 seeds end the same; in seeds 13 and 26 a box joined onto a flat revolved face now fuses with it (main kept two touching shells that a later step split into two bodies), seed 15's last cylinder join, which main refuses, now builds, and seed 78's cut box no longer leaves 87.5 mm² of extra area (the end cap's); seed 63 refuses the same fillet on both and ends with nearly the same body. Seed 9 now ends ok instead of failing, but its new design ends the same on main. No seed gains a failure.","seeds":[9,10,13,15,17,19,22,24,25,26,30,63,75,76,78,90,91]}
|
||||
,
|
||||
{"reason":"#726 (#1718): a partial revolve made as a new body comes out with its planar faces whole, so the revolved section has fewer faces and edges. The fuzz generator picks edges and faces by index, so from their second step these seeds build different designs (seed 24's shell now picks the centroid of a whole 270 deg annulus, which lies in the bore, so it is refused and the run goes on). Replaying each new design's commands on main and on this branch: 12 seeds end the same; in seeds 13 and 26 a box joined onto a flat revolved face now fuses with it (main kept two touching shells that a later step split into two bodies), seed 15's last cylinder join, which main refuses, now builds, and seed 78's cut box no longer leaves 87.5 mm² of extra area (the end cap's); seed 63 refuses the same fillet on both and ends with nearly the same body. Seed 9 now ends ok instead of failing, but its new design ends the same on main. No seed gains a failure.","seeds":[9,10,13,15,17,19,22,24,25,26,30,63,75,76,78,90,91]},
|
||||
{"reason":"Current main replay: seed 55 now meshes every face and passes validity; edge-consistent measuring meshes (#1753) change its measured volume from 24741.239331 to 24740.927259 mm3. Seed 71 joins with one fewer redundant face and two fewer edges (19/50); its five steps, successful outcome, volume and area are unchanged within the existing 1e-6 tolerances. Both fixed command sequences were replayed with validity checks after every step.","seeds":[55,71]},
|
||||
{"reason":"Seed 20: trace conical cutters against earlier conical cuts at finer unshifted tolerances before coarse copies can leave unusable junctions. The circular pattern and final box cut both build; replayed in the app with no feature errors, unchanged 45 faces/116 edges/one shell, no tool nudge, and volume/spatial guards retained. The finer intersection curves change measured volume by 0.020503 mm3 and area by 0.171629 mm2.","seeds":[20]},
|
||||
{"reason":"After integrating #1718, replay the regenerated revolved-section recipes on the combined branch: seed 13 has 40 faces/101 edges (one face/two edges fewer), seed 94 has 27 faces/63 edges (one face/two edges more). Both complete fixed command sequences pass validity and volume-direction checks after every step, and two independent golden runs per seed agree exactly. Steps, outcomes, body and shell counts are unchanged; area and volume match within existing 1e-6 relative tolerances (seed 13 volume differs by 0.000002 mm3). Only topology counts are updated. The finer unshifted conical-cut route and its geometry guards remain in place.","seeds":[13,94]},
|
||||
{"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Exercise the real HTTP export endpoint without touching the working review or queue."""
|
||||
import json
|
||||
import csv
|
||||
import pathlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class IconExportTest(unittest.TestCase):
|
||||
def test_inventory_includes_nested_and_helper_icon_uses(self):
|
||||
source = pathlib.Path(__file__).resolve().parents[2] / 'assets/icons/ui/design/tools'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
design = root / 'assets/icons/ui/design'
|
||||
tools = design / 'tools'
|
||||
tools.mkdir(parents=True)
|
||||
for name in ('icon_status.py', 'review_state.py', 'icon_palette.py'):
|
||||
shutil.copy(source / name, tools / name)
|
||||
shutil.copytree(source / 'review', tools / 'review')
|
||||
(design / 'palette.toml').write_text('[roles.line]\nlight="#000000"\ndark="#ffffff"\n')
|
||||
for name in ('aliases.toml',):
|
||||
(design / name).write_text('')
|
||||
svg = design / 'svg/model'
|
||||
svg.mkdir(parents=True)
|
||||
for name in ('agent_chat', 'pattern_flip', 'normal', 'infinite', 'trace'):
|
||||
(svg / (name + '.svg')).write_text('<svg viewBox="0 0 64 64"></svg>')
|
||||
ui = root / 'crates/ui-egui/src'
|
||||
(ui / 'agent_chat').mkdir(parents=True)
|
||||
(ui / 'agent_chat/panel.rs').write_text('icons::paint(p, r, "agent_chat", ink, fill, a);')
|
||||
(ui / 'dialogs.rs').write_text('pattern_toggle(ui, alignment_label, "Flip direction", "pattern_flip", reversed);')
|
||||
(ui / 'sketch_palette.rs').write_text('''
|
||||
for (i, (label, icon, tip)) in [
|
||||
("—", "normal", "Normal"),
|
||||
("↔", "infinite", "Infinite"),
|
||||
].iter().enumerate() { icons::paint(p, r, icon, ink, fill, a); }
|
||||
''')
|
||||
engine = root / 'crates/engine/src/cmd'
|
||||
engine.mkdir(parents=True)
|
||||
# A long builder must not lose its icon after the first fourteen lines.
|
||||
(engine / 'motion.rs').write_text('CommandSpec::new("motion.trace", "Trace", trace)\n'
|
||||
+ '\n' * 16 + '.icon("trace").params("doc");')
|
||||
subprocess.run([sys.executable, '-B', str(tools / 'icon_status.py'), '--csv'],
|
||||
check=True, capture_output=True, text=True)
|
||||
with open(root / 'target/icon-inventory.csv') as f:
|
||||
rows = {r['icon']: r for r in csv.DictReader(f)}
|
||||
self.assertEqual(set(rows), {'agent_chat', 'pattern_flip', 'normal', 'infinite', 'trace'})
|
||||
self.assertIn('agent_chat/panel.rs', rows['agent_chat']['used_by_ui'])
|
||||
self.assertIn('dialogs.rs', rows['pattern_flip']['used_by_ui'])
|
||||
for name in ('normal', 'infinite'):
|
||||
self.assertIn('sketch_palette.rs', rows[name]['used_by_ui'])
|
||||
self.assertEqual(rows['trace']['example_command'], 'motion.trace')
|
||||
|
||||
def test_export_snapshots_only_pending_notes_for_codex(self):
|
||||
source = pathlib.Path(__file__).resolve().parents[2] / 'assets/icons/ui/design/tools'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
design = root / 'assets/icons/ui/design'
|
||||
tools = design / 'tools'
|
||||
tools.mkdir(parents=True)
|
||||
for name in ('icon_status.py', 'review_state.py', 'icon_palette.py'):
|
||||
shutil.copy(source / name, tools / name)
|
||||
shutil.copytree(source / 'review', tools / 'review')
|
||||
for name in ('palette.toml', 'aliases.toml'):
|
||||
(design / name).write_text('')
|
||||
(design / 'palette.toml').write_text('[roles.line]\nlight="#000000"\ndark="#ffffff"\n')
|
||||
svg = design / 'svg/model'
|
||||
svg.mkdir(parents=True)
|
||||
for name in ('box', 'joint'):
|
||||
(svg / (name + '.svg')).write_text('<svg viewBox="0 0 64 64"></svg>')
|
||||
# An accepted icon's note must never be exported, even if it still has text.
|
||||
original = {'final': {'box': ''}, 'notes': {'box': 'Keep it', 'joint': 'Round the corners\nUse related icons'}}
|
||||
(design / 'review.json').write_text(json.dumps(original))
|
||||
with socket.socket() as port_socket:
|
||||
port_socket.bind(('127.0.0.1', 0))
|
||||
port = port_socket.getsockname()[1]
|
||||
proc = subprocess.Popen([sys.executable, str(tools / 'icon_status.py'), '--serve', str(port)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
try:
|
||||
url = 'http://127.0.0.1:%d' % port
|
||||
for attempt in range(100):
|
||||
try:
|
||||
with urllib.request.urlopen(url + '/api/review', timeout=1) as response:
|
||||
base = json.load(response)
|
||||
break
|
||||
except urllib.error.URLError:
|
||||
if proc.poll() is not None:
|
||||
self.fail(proc.stderr.read().decode())
|
||||
time.sleep(0.02)
|
||||
else:
|
||||
self.fail('review server did not start')
|
||||
|
||||
def post(endpoint, state):
|
||||
data = json.dumps({'base': base, **state}).encode()
|
||||
with urllib.request.urlopen(urllib.request.Request(url + endpoint, data=data), timeout=2) as response:
|
||||
return json.load(response)
|
||||
|
||||
state = {'final': base['final'], 'notes': base['notes']}
|
||||
inbox = root / 'target/codex-icon-review/inbox'
|
||||
post('/api/review', state)
|
||||
self.assertFalse(inbox.exists(), 'autosave must not request redraws')
|
||||
result = post('/api/export', state)
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['sent'], 1)
|
||||
requests = list(inbox.glob('*.json'))
|
||||
self.assertEqual(len(requests), 1)
|
||||
request = json.loads(requests[0].read_text())
|
||||
self.assertEqual(request['id'], result['request_id'])
|
||||
self.assertEqual(request['notes'], {'joint': original['notes']['joint']})
|
||||
self.assertFalse(list(inbox.glob('*.tmp')))
|
||||
self.assertFalse((root / 'target/review-requests.log').exists())
|
||||
with urllib.request.urlopen(url, timeout=2) as response:
|
||||
page = response.read().decode()
|
||||
self.assertIn('local review inbox', page)
|
||||
self.assertNotIn('Claude', page)
|
||||
self.assertIn('location.reload()', page)
|
||||
# Existing tabs also reload when the server's page implementation changes.
|
||||
with open(tools / 'icon_status.py', 'a') as f:
|
||||
f.write('\n# page update\n')
|
||||
with urllib.request.urlopen(url + '/api/review', timeout=2) as response:
|
||||
self.assertNotEqual(json.load(response)['rev'], base['rev'])
|
||||
# A note resolved on disk cannot be resurrected by an unchanged stale page.
|
||||
saved = json.loads((design / 'review.json').read_text())
|
||||
saved['notes'] = {'box': 'Keep it'}
|
||||
(design / 'review.json').write_text(json.dumps(saved))
|
||||
empty = post('/api/export', state)
|
||||
self.assertEqual(empty['sent'], 0)
|
||||
self.assertEqual(len(list(inbox.glob('*.json'))), 1)
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
proc.stderr.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,331 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user