# -*- 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 '' 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