WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`tools/pa_web.py` end to end -- no Revit, no subprocess.
|
||||
|
||||
The server is started IN-PROCESS (`pa_web.build_context` + `pa_web.
|
||||
make_server`, `serve_forever()` on a background thread) so this runs
|
||||
everywhere `python -m pytest` already does: no CPython subprocess launch, no
|
||||
scraping `PA_WEB_URL=` off a pipe (that half is the Revit button's own job --
|
||||
see the button script's `_launch_pa_web`, covered by the manual dry run and
|
||||
the live bridge smoke, not a host test).
|
||||
|
||||
Two runs, same exercise:
|
||||
|
||||
* BLANK path (no `--reads`) -- must always pass. This is the "no Revit at
|
||||
all" contract `docs/pa-web-spec.md` and the button's own architecture
|
||||
note both promise, and the one CI can always exercise.
|
||||
* `_scratch/pa_reads_wpa655.json`, when present -- a real model's reads
|
||||
(gitignored agent scratch space, per CLAUDE.md's "No client or job
|
||||
information in the repo" -- it is not tracked). SKIPPED, never failed,
|
||||
when the file is not on this checkout.
|
||||
|
||||
Job root is a pytest `tmp_path` in both cases -- never the real
|
||||
`%LOCALAPPDATA%\\WillTools\\panelanalysis` a live run would use, and never a
|
||||
path inside this checkout (CLAUDE.md's "tests must not write into the working
|
||||
directory").
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TOOLS = os.path.abspath(os.path.join(HERE, os.pardir))
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
import pa_web # noqa: E402
|
||||
|
||||
REPO = os.path.abspath(os.path.join(TOOLS, os.pardir))
|
||||
WPA655_READS = os.path.join(REPO, '_scratch', 'pa_reads_wpa655.json')
|
||||
|
||||
_EXPECTED_PAGE_IDS = ['design_criteria', 'concrete_extents',
|
||||
'structural_model', 'handling', 'reinforcement']
|
||||
|
||||
|
||||
class _Running(object):
|
||||
"""Runs `server.serve_forever()` on a daemon thread for the `with` body,
|
||||
then shuts it down and joins -- a bare fixture would need the same
|
||||
teardown twice (one test exercises `/api/close` itself, which already
|
||||
shuts the server down; the `__exit__` call after that must be a safe,
|
||||
idempotent no-op, not a hang)."""
|
||||
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
self.thread = threading.Thread(target=server.serve_forever)
|
||||
self.thread.daemon = True
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
try:
|
||||
self.server.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
return 'http://127.0.0.1:{0}'.format(self.server.server_address[1])
|
||||
|
||||
|
||||
def _get(url):
|
||||
"""(status, parsed_json_or_None) -- a non-JSON body (the results HTML,
|
||||
a static asset) comes back as `None`, not a parse error; a caller that
|
||||
needs the raw text uses `_get_text` instead."""
|
||||
with urlopen(url, timeout=10) as resp:
|
||||
status = resp.getcode()
|
||||
ctype = resp.headers.get('Content-Type') or ''
|
||||
body = resp.read().decode('utf-8')
|
||||
if 'json' not in ctype:
|
||||
return status, None
|
||||
return status, (json.loads(body) if body else None)
|
||||
|
||||
|
||||
def _get_text(url):
|
||||
with urlopen(url, timeout=10) as resp:
|
||||
return resp.getcode(), resp.read().decode('utf-8')
|
||||
|
||||
|
||||
def _post(url, body):
|
||||
data = json.dumps(body).encode('utf-8')
|
||||
req = Request(url, data=data,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST')
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
return resp.getcode(), json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
|
||||
def _real_field_keys():
|
||||
return set(f['key'] for f in pa_web.F.FIELDS if f.get('key'))
|
||||
|
||||
|
||||
def _exercise(tmp_path, reads_path):
|
||||
"""Runs the whole GET spec / save / run / close cycle against one
|
||||
server instance and returns nothing -- every assertion lives here so
|
||||
the blank and WPA655 tests below cannot drift apart."""
|
||||
ctx = pa_web.build_context(reads_path=reads_path,
|
||||
job_root_override=str(tmp_path))
|
||||
server = pa_web.make_server(ctx, port=0)
|
||||
|
||||
with _Running(server) as running:
|
||||
base = running.base_url
|
||||
|
||||
# -------------------------------------------------------- /api/spec
|
||||
status, spec = _get(base + '/api/spec')
|
||||
assert status == 200
|
||||
assert spec['schema'] == 1
|
||||
assert spec['title'] == 'Panel Analysis'
|
||||
assert [p['id'] for p in spec['pages']] == _EXPECTED_PAGE_IDS
|
||||
assert isinstance(spec.get('values'), dict) and spec['values']
|
||||
assert isinstance(spec.get('basis'), dict)
|
||||
assert isinstance(spec.get('defaults_why'), dict)
|
||||
assert isinstance(spec.get('choices'), dict)
|
||||
assert isinstance(spec.get('tables'), dict) and spec['tables']
|
||||
assert isinstance(spec.get('geometry'), dict) and spec['geometry']
|
||||
assert isinstance(spec.get('problems'), list)
|
||||
|
||||
# -------------------------------------------------------- /api/save
|
||||
status, saved = _post(base + '/api/save', {'values': spec['values']})
|
||||
assert status == 200
|
||||
assert saved.get('ok') is True
|
||||
values_path = os.path.join(str(tmp_path), 'panelanalysis',
|
||||
ctx.mark + '.values.json')
|
||||
assert os.path.isfile(values_path)
|
||||
with open(values_path, 'r', encoding='utf-8') as fh:
|
||||
on_disk = json.load(fh)
|
||||
assert on_disk.get('values') == spec['values']
|
||||
|
||||
# A saved edit is picked back up by the next /api/spec (the "saved
|
||||
# user edits merged over the prefill" contract).
|
||||
status, spec2 = _get(base + '/api/spec')
|
||||
assert status == 200
|
||||
assert spec2['values'].get('length_ft') == spec['values'].get(
|
||||
'length_ft')
|
||||
|
||||
# --------------------------------------------------------- /api/run
|
||||
status, result = _post(base + '/api/run', {'values': spec['values']})
|
||||
assert status == 200
|
||||
assert isinstance(result, dict) and 'ok' in result
|
||||
if result['ok']:
|
||||
assert result['result_url'].startswith('/results/')
|
||||
result_name = result['result_url'][len('/results/'):]
|
||||
result_path = os.path.join(str(tmp_path), 'panelanalysis',
|
||||
result_name)
|
||||
assert os.path.isfile(result_path)
|
||||
pa_json_path = os.path.join(str(tmp_path), 'panelanalysis',
|
||||
ctx.mark + '.pa.json')
|
||||
assert os.path.isfile(pa_json_path)
|
||||
status, html = _get_text(base + result['result_url'])
|
||||
assert status == 200
|
||||
assert '<title>' in html
|
||||
else:
|
||||
problems = result.get('problems')
|
||||
assert isinstance(problems, list) and problems
|
||||
real_keys = _real_field_keys()
|
||||
for p in problems:
|
||||
assert set(p.keys()) >= {'key', 'text'}
|
||||
assert isinstance(p['text'], str) and p['text']
|
||||
# A non-empty key must name a real FIELDS key -- '' (could
|
||||
# not be mapped) is the only other allowed value.
|
||||
assert p['key'] == '' or p['key'] in real_keys
|
||||
|
||||
# ------------------------------------------------------- /api/close
|
||||
status, closed = _post(base + '/api/close', {})
|
||||
assert status == 200
|
||||
assert closed.get('ok') is True
|
||||
running.thread.join(timeout=5)
|
||||
assert not running.thread.is_alive()
|
||||
|
||||
|
||||
def test_pa_web_blank_path(tmp_path):
|
||||
_exercise(tmp_path, reads_path=None)
|
||||
|
||||
|
||||
def test_pa_web_wpa655_reads(tmp_path):
|
||||
if not os.path.isfile(WPA655_READS):
|
||||
pytest.skip('no _scratch/pa_reads_wpa655.json on this checkout -- '
|
||||
'gitignored agent scratch space, not always present')
|
||||
_exercise(tmp_path, reads_path=WPA655_READS)
|
||||
|
||||
|
||||
def _get_headers(url):
|
||||
with urlopen(url, timeout=10) as resp:
|
||||
return resp.getcode(), dict(resp.headers)
|
||||
|
||||
|
||||
def test_static_and_json_responses_carry_utf8_charset(tmp_path):
|
||||
"""Item 1 of the 2026-09-05 review: `index.html` gets a `<meta
|
||||
charset="utf-8">` and every text-ish response (`text/html`,
|
||||
`application/javascript`, `text/css`, JSON) carries an explicit
|
||||
`charset=utf-8` -- a browser with no declared charset guesses cp1252/
|
||||
latin-1 and every non-ASCII glyph (the title's en dash, the row X
|
||||
buttons, the section-arrow triangles) renders as mojibake."""
|
||||
ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path))
|
||||
server = pa_web.make_server(ctx, port=0)
|
||||
with _Running(server) as running:
|
||||
base = running.base_url
|
||||
status, headers = _get_headers(base + '/')
|
||||
assert status == 200
|
||||
assert headers['Content-Type'] == 'text/html; charset=utf-8'
|
||||
status, headers = _get_headers(base + '/app.js')
|
||||
assert status == 200
|
||||
assert 'charset=utf-8' in headers['Content-Type']
|
||||
status, headers = _get_headers(base + '/willtools.css')
|
||||
assert status == 200
|
||||
assert 'charset=utf-8' in headers['Content-Type']
|
||||
status, headers = _get_headers(base + '/api/spec')
|
||||
assert status == 200
|
||||
assert headers['Content-Type'] == 'application/json; charset=utf-8'
|
||||
_post(base + '/api/close', {})
|
||||
running.thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_index_html_declares_utf8_meta_charset():
|
||||
static_dir = os.path.join(TOOLS, 'pa_web', 'static')
|
||||
with open(os.path.join(static_dir, 'index.html'), 'r',
|
||||
encoding='utf-8') as fh:
|
||||
head = fh.read(2000)
|
||||
assert '<meta charset="utf-8">' in head
|
||||
|
||||
|
||||
def test_api_geometry_matches_the_spec_geometry(tmp_path):
|
||||
"""`POST /api/geometry` (item 3's live-redraw fix) must return the SAME
|
||||
`geometry` dict `/api/spec` computes off the identical `values` -- the
|
||||
client-side redraw path (`app.js`'s `scheduleGeometryRefresh`) depends
|
||||
on the two never disagreeing."""
|
||||
ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path))
|
||||
server = pa_web.make_server(ctx, port=0)
|
||||
with _Running(server) as running:
|
||||
base = running.base_url
|
||||
status, spec = _get(base + '/api/spec')
|
||||
assert status == 200
|
||||
status, geom_result = _post(base + '/api/geometry',
|
||||
{'values': spec['values']})
|
||||
assert status == 200
|
||||
assert geom_result.get('geometry') == spec['geometry']
|
||||
_post(base + '/api/close', {})
|
||||
running.thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_api_geometry_reflects_an_opening_row_edit(tmp_path):
|
||||
"""Adding an `opening` row through the client's own key shape must
|
||||
change the outline geometry `/api/geometry` returns for it -- the
|
||||
server-side half of "add/remove rows must redraw the outline live"."""
|
||||
ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path))
|
||||
server = pa_web.make_server(ctx, port=0)
|
||||
with _Running(server) as running:
|
||||
base = running.base_url
|
||||
status, spec = _get(base + '/api/spec')
|
||||
assert status == 200
|
||||
values = dict(spec['values'])
|
||||
values['length_ft'] = '24'
|
||||
values['width_ft'] = '8'
|
||||
status, before = _post(base + '/api/geometry', {'values': values})
|
||||
assert status == 200
|
||||
values['opening.1.present'] = 'yes'
|
||||
values['opening.1.type'] = 'Opening'
|
||||
values['opening.1.x_ft'] = '2'
|
||||
values['opening.1.length_ft'] = '3'
|
||||
values['opening.1.y_ft'] = '1'
|
||||
values['opening.1.width_ft'] = '2'
|
||||
status, after = _post(base + '/api/geometry', {'values': values})
|
||||
assert status == 200
|
||||
assert after['geometry'] != before['geometry']
|
||||
_post(base + '/api/close', {})
|
||||
running.thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_unknown_endpoint_is_404_not_a_crash(tmp_path):
|
||||
ctx = pa_web.build_context(reads_path=None,
|
||||
job_root_override=str(tmp_path))
|
||||
server = pa_web.make_server(ctx, port=0)
|
||||
with _Running(server) as running:
|
||||
base = running.base_url
|
||||
try:
|
||||
_get(base + '/api/nope')
|
||||
except Exception as exc:
|
||||
assert '404' in str(exc)
|
||||
# the server must still be answering afterwards
|
||||
status, spec = _get(base + '/api/spec')
|
||||
assert status == 200
|
||||
assert spec['schema'] == 1
|
||||
_post(base + '/api/close', {})
|
||||
running.thread.join(timeout=5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/run names WHOSE fault a refusal is (issue #19)
|
||||
#
|
||||
# A validation refusal ("the piece is still missing f'c") and a solve crash
|
||||
# used to come back in one shape -- `{"ok": false, "problems": [...]}` -- and
|
||||
# the page rendered both through one red banner, so a data gap read as a
|
||||
# broken tool and a fixed dependency bug was mistaken for a new one. The
|
||||
# reply now carries `kind`: 'input' (the user's to fill, nothing broke) or
|
||||
# 'error' (a bug, traceback on stderr). Two different answers, never one
|
||||
# shape.
|
||||
|
||||
|
||||
def test_run_refusal_for_missing_input_is_kind_input(tmp_path):
|
||||
ctx = pa_web.build_context(reads_path=None,
|
||||
job_root_override=str(tmp_path))
|
||||
result = pa_web.run_analysis(ctx, {})
|
||||
assert result['ok'] is False
|
||||
assert result['kind'] == 'input'
|
||||
assert result['problems']
|
||||
# every problem is a plain validate() sentence, none an exception
|
||||
for p in result['problems']:
|
||||
assert not p['text'].endswith('Error')
|
||||
assert 'Traceback' not in p['text']
|
||||
|
||||
|
||||
def test_run_refusal_for_a_solve_crash_is_kind_error(tmp_path, monkeypatch):
|
||||
ctx = pa_web.build_context(reads_path=None,
|
||||
job_root_override=str(tmp_path))
|
||||
monkeypatch.setattr(pa_web.F, 'validate', lambda values: ([], {}))
|
||||
|
||||
def boom(parsed):
|
||||
raise RuntimeError('deck exploded')
|
||||
monkeypatch.setattr(pa_web.F, 'build_deck', boom)
|
||||
|
||||
result = pa_web.run_analysis(ctx, {})
|
||||
assert result['ok'] is False
|
||||
assert result['kind'] == 'error'
|
||||
assert result['problems'] == [
|
||||
{'key': '', 'text': 'RuntimeError: deck exploded'}]
|
||||
|
||||
|
||||
def test_run_kind_reaches_the_client_over_http(tmp_path):
|
||||
ctx = pa_web.build_context(reads_path=None,
|
||||
job_root_override=str(tmp_path))
|
||||
server = pa_web.make_server(ctx, port=0)
|
||||
with _Running(server) as running:
|
||||
status, result = _post(running.base_url + '/api/run',
|
||||
{'values': {}})
|
||||
assert status == 200
|
||||
assert result['ok'] is False
|
||||
assert result['kind'] == 'input'
|
||||
|
||||
|
||||
def test_page_renders_input_and_crash_banners_differently():
|
||||
"""The page half of the same rule: two banner faces, and the bare
|
||||
`.banner` (an unlabelled failure) stays the loud one."""
|
||||
static = os.path.join(TOOLS, 'pa_web', 'static')
|
||||
with open(os.path.join(static, 'app.js'), 'r', encoding='utf-8') as fh:
|
||||
js = fh.read()
|
||||
with open(os.path.join(static, 'willtools.css'), 'r',
|
||||
encoding='utf-8') as fh:
|
||||
css = fh.read()
|
||||
assert 'banner--input' in js and 'banner--crash' in js
|
||||
assert '.banner.banner--input' in css and '.banner.banner--crash' in css
|
||||
# the reply's `kind` is what the page branches on
|
||||
assert 'showProblems(data.problems || [], data.kind)' in js
|
||||
# an unlabelled kind renders as a crash, never as a calm input note
|
||||
assert "kind === 'input' ? 'banner--input' : 'banner--crash'" in js
|
||||
Reference in New Issue
Block a user