25592fa6c2
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.
281 lines
11 KiB
Python
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'}]
|