Files
willtools-demo/WillTools.extension/tests/test_ba_web.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

281 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""`tools/ba_web.py` end to end -- no Revit, no subprocess.
The server is started IN-PROCESS (`ba_web.build_context` + `ba_web.
make_server`, `serve_forever()` on a background thread) the way
`tools/tests/test_pa_web.py` starts PA's -- same `port 0`, same temp job
root, same urllib client, so this runs everywhere `python -m pytest` already
does: no CPython subprocess launch, no scraping `BA_WEB_URL=` off a pipe
(that half is the Revit gatherer's own job, not a host test).
The reads dict is the `rba5_16x22` fixture (a legal reads file: a PARTIAL
job -- a fixture job is a partial job, ba-web-spec.md section 6) plus its
own `job_root` pointing at the temp dir, so the resolution pa_web defines
(`--reads`' `job_root` field wins) is exercised too. What this suite checks,
per the ba-web-spec brief and spec section 8:
* GET /api/spec -> 200, JSON with the five beam pages and `geometry`
keys for the drawings;
* POST /api/save -> ok, and `<mark>.values.json` exists under
`<job_root>/beamanalysis/`;
* POST /api/run with the spec's own values -> ok true with a
`result_url`, the three run files (`<mark>.job.json`, `<mark>.ba.json`,
`<mark>.html`) exist, and the URL serves real HTML;
* POST /api/log -> ok and `<mark>.client.log` has one line;
* GET /app.js -> 200, served from PA's static directory (the shared
single copy -- the fallback half of spec section 5);
* GET /nope.js -> 404 (in neither static directory);
* POST /api/close -> ok and the server thread ends within 5 s.
No Revit in any of it -- ba_web.py and ba_pagespec.py are host CPython, and
the `conftest.py` in this folder makes the `vibe` stub pa_web imports
harmless off-Revit.
"""
from __future__ import annotations
import json
import os
import sys
import threading
import pytest
from urllib.request import Request, urlopen
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
TOOLS = os.path.join(ROOT, 'tools')
BEAMCALC_SRC = os.path.join(ROOT, 'beamcalc', 'src')
for p in (TOOLS, BEAMCALC_SRC):
if p not in sys.path:
sys.path.insert(0, p)
import ba_pagespec # noqa: E402
import ba_web # noqa: E402
RBA5_FIXTURE = os.path.join(ROOT, 'beamcalc', 'fixtures', 'rba5_16x22.json')
PA_STATIC = os.path.join(TOOLS, 'pa_web', 'static')
_EXPECTED_PAGE_IDS = ['extents', 'materials', 'loads', 'reinforcement',
'handling']
_EXPECTED_GEOMETRY_KEYS = ['extents.elevation', 'loads.elevation',
'reinforcement.section',
'reinforcement.elevation']
class _Running(object):
"""Runs `server.serve_forever()` on a daemon thread for the `with` body,
then shuts it down and joins -- copied from `tools/tests/test_pa_web.py`
(the __exit__ shutdown must be a safe, idempotent no-op after a test
that already closed the server via /api/close)."""
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, body) -- a 4xx/5xx comes back as (code, '') instead of a
raised `urllib.error.HTTPError`, so a test can assert the status."""
try:
with urlopen(url, timeout=15) as resp:
return resp.getcode(), resp.read().decode('utf-8')
except Exception as exc:
code = getattr(exc, 'code', None)
if code is not None:
return code, ''
raise
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 _load_fixture():
with open(RBA5_FIXTURE, 'r', encoding='utf-8') as fh:
return json.load(fh)
def _start_server(tmp_path):
"""A server whose reads are the rba5 fixture plus `job_root` = the temp
dir, on port 0, already serving on a background thread."""
reads = _load_fixture()
reads['job_root'] = str(tmp_path)
ctx = ba_web.build_context(reads_path=None, job_root_override=str(tmp_path))
# A reads-path of None must not clobber the fixture's own job_root /
# mark the way a real --reads file would; drop the reads in through the
# context directly, the shape build_context(reads_path=...) would give.
ctx.reads = reads
if reads.get('mark'):
ctx.mark = ba_web._slug(reads['mark'])
server = ba_web.make_server(ctx, port=0)
return server
# ------------------------------------------------------------------ tests
def test_spec_then_save_then_run_then_close(tmp_path):
"""The whole GET spec / save / run / log / close cycle against one server
on the RBA5 reads."""
server = _start_server(tmp_path)
ctx = server.ctx
ba_dir = os.path.join(str(tmp_path), 'beamanalysis')
with _Running(server) as running:
base = running.base_url
# -------------------------------------------------------- /api/spec
status, body = _get(base + '/api/spec')
assert status == 200
spec = json.loads(body)
assert spec['schema'] == 1
assert spec['title'] == 'Beam 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('geometry'), dict)
for key in _EXPECTED_GEOMETRY_KEYS:
assert key in spec['geometry'], (
'spec geometry is missing {0!r}'.format(key))
# the reads' mark survives the round trip
assert spec['mark'] == 'RBA5.0'
# -------------------------------------------------------- /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(ba_dir, 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']
# --------------------------------------------------------- /api/run
status, result = _post(base + '/api/run', {'values': spec['values']})
assert status == 200
assert result.get('ok') is True, result.get('problems')
result_url = result.get('result_url')
assert result_url and result_url.startswith('/results/')
result_name = result_url[len('/results/'):]
assert result_name == ctx.mark + '.html'
for name in (ctx.mark + '.job.json', ctx.mark + '.ba.json',
ctx.mark + '.html'):
assert os.path.isfile(os.path.join(ba_dir, name)), name
status, html = _get(base + result_url)
assert status == 200
assert '<html' in html
# --------------------------------------------------------- /api/log
status, logged = _post(base + '/api/log', {'msg': 'x'})
assert status == 200
assert logged.get('ok') is True
log_path = os.path.join(ba_dir, ctx.mark + '.client.log')
assert os.path.isfile(log_path)
with open(log_path, 'r', encoding='utf-8') as fh:
lines = fh.read().splitlines()
assert len(lines) == 1 and lines[0]
# -------------------------------------------------------- /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_static_served_from_beam_then_pa_directories(tmp_path):
"""Static assets come from `tools/ba_web/static/` first (index.html is
the beam one, title changed), and `tools/pa_web/static/` second -- so
`app.js` is PA's single shared copy and a path in neither directory is a
404 (ba-web-spec.md section 5)."""
server = _start_server(tmp_path)
with _Running(server) as running:
base = running.base_url
status, html = _get(base + '/')
assert status == 200
assert '<title>Beam Analysis</title>' in html
# the beam index loads ba_drawings.js AFTER draw.js and BEFORE app.js
assert html.index('draw.js') < html.index('ba_drawings.js') < \
html.index('app.js')
status, js = _get(base + '/app.js')
assert status == 200
assert js
# it really is PA's file (single copy, not duplicated for the beam).
# newline='' so the comparison is against the bytes on disk: the
# server reads 'rb', and a text-mode read folds CRLF to LF, which on
# a Windows checkout with core.autocrlf=true made the two differ on
# every line (issue #18; .gitattributes now pins these files to LF).
with open(os.path.join(PA_STATIC, 'app.js'), 'r',
encoding='utf-8', newline='') as fh:
assert fh.read() == js
status, _body = _get(base + '/nope.js')
assert status == 404
_post(base + '/api/close', {})
running.thread.join(timeout=5)
# ---------------------------------------------------------------------------
# /api/run names WHOSE fault a refusal is -- the same rule as
# tools/tests/test_pa_web.py (issue #19): 'input' is a value the engine
# refuses (the user's to fix), 'error' is the solve or a write raising (a
# bug). The shared page renders the two differently.
def test_run_refusal_for_a_refused_value_is_kind_input(tmp_path, monkeypatch):
ctx = ba_web.build_context(reads_path=None,
job_root_override=str(tmp_path))
def refuse(values):
raise ValueError('span_ft must be positive')
monkeypatch.setattr(ba_web.ba_pagespec, 'job_from_values', refuse)
result = ba_web.run_analysis(ctx, {})
assert result['ok'] is False
assert result['kind'] == 'input'
assert result['problems'][0]['text'] == 'span_ft must be positive'
def test_run_refusal_for_a_solve_crash_is_kind_error(tmp_path, monkeypatch):
ctx = ba_web.build_context(reads_path=None,
job_root_override=str(tmp_path))
monkeypatch.setattr(ba_web.ba_pagespec, 'job_from_values',
lambda values: {})
monkeypatch.setattr(ba_web.BM, 'from_job', lambda job: object())
def boom(beam):
raise RuntimeError('solver exploded')
monkeypatch.setattr(ba_web.BC_SOLVE, 'analyse', boom)
result = ba_web.run_analysis(ctx, {})
assert result['ok'] is False
assert result['kind'] == 'error'
assert result['problems'] == [
{'key': '', 'text': 'RuntimeError: solver exploded'}]