# -*- 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 `.values.json` exists under `/beamanalysis/`; * POST /api/run with the spec's own values -> ok true with a `result_url`, the three run files (`.job.json`, `.ba.json`, `.html`) exist, and the URL serves real HTML; * POST /api/log -> ok and `.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 'Beam Analysis' 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'}]