WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not 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
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`tools/panelanalysis.py::build_analysis_page` -- the READ-ONLY results
|
||||
page for the `panelanalysis/1` schema (2026-09, results-page redesign).
|
||||
|
||||
Guards the redesign's own hard rules, on two decks:
|
||||
|
||||
MINIMAL `schema.new_deck()` as-is -- two dunnage supports, two
|
||||
straight strands, no in-place restraints/wind at all (the
|
||||
Stage-0 default). Exercises the "not judged" path for
|
||||
in-place strength/service.
|
||||
IN-PLACE the same deck with lateral restraints and a manual wind zone
|
||||
added, so the in-place strength/service models actually
|
||||
solve and their own SVG envelope diagrams
|
||||
(`panelanalysis._inplace_diagram`) render.
|
||||
|
||||
This does not repeat `test_panelanalysis_schema.py`'s coverage of the
|
||||
engine/schema layer -- it only checks what the WRITTEN PAGE looks like:
|
||||
- never an `<input>` or `<select>` (the input side lives in the 前端 now)
|
||||
- never a DECK / NOT READ / COMPUTED badge (`CHIPS`' own vocabulary)
|
||||
- the Summary card is present (`class="verdict"` + `class="stageboxes"`)
|
||||
- no decimal-feet number anywhere (every length is feet-inches)
|
||||
- the page is well under the ~1 MB budget and opens standalone (its own
|
||||
`<style>` carries the embedded CSS -- no `<link rel="stylesheet">`)
|
||||
|
||||
Import pattern mirrors `test_panelanalysis_schema.py`: `tools/` on
|
||||
`sys.path`, `deckcheck` imported first for its `vibe` stub side effect.
|
||||
"""
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
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 deckcheck # noqa: E402,F401 -- registers the `vibe` stub
|
||||
|
||||
import panelanalysis # noqa: E402
|
||||
import panelanalysis_schema as schema # noqa: E402
|
||||
|
||||
|
||||
DECIMAL_FT = re.compile(r'\d+\.\d+\s*ft\b')
|
||||
|
||||
|
||||
def minimal_deck():
|
||||
return schema.new_deck()
|
||||
|
||||
|
||||
def in_place_deck():
|
||||
"""`new_deck()`, wired for the in-place strength/service models to
|
||||
solve: three lateral restraints (so `schema.restraints()` sees >= 2)
|
||||
and one manual wind zone -- the same shape `WPA655.pa.json` (the deck
|
||||
this redesign was built and screenshotted against) carries.
|
||||
"""
|
||||
deck = schema.new_deck(length_ft=37.5833, width_ft=8.375)
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 2.0, 'type': 'Pin', 'gravity': True, 'thermal': False},
|
||||
{'x_ft': 15.0, 'type': 'Floor tie', 'gravity': True,
|
||||
'thermal': False},
|
||||
{'x_ft': 22.0, 'type': 'Floor tie', 'gravity': True,
|
||||
'thermal': False},
|
||||
]
|
||||
deck['loading']['wind'] = {
|
||||
'mode': 'manual',
|
||||
'zones': [{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': deck[
|
||||
'concrete_extents']['length_ft'], 'pressure_psf': 30.0,
|
||||
'suction_psf': 35.0}],
|
||||
}
|
||||
return deck
|
||||
|
||||
|
||||
def _check_common_invariants(html):
|
||||
assert '<input' not in html
|
||||
assert '<select' not in html
|
||||
for badge in ('>DECK<', '>NOT READ<', '>COMPUTED<', '>SPEC<',
|
||||
'>KNOWLEDGE<', '>PLANNED<', '>NOT COMPUTED<'):
|
||||
assert badge not in html, badge
|
||||
assert 'class="chip' not in html
|
||||
assert '<link rel="stylesheet"' not in html
|
||||
assert 'class="verdict"' in html
|
||||
assert 'class="stageboxes"' in html
|
||||
assert 'class="stagebox' in html
|
||||
bad = DECIMAL_FT.findall(html)
|
||||
assert not bad, 'decimal-feet numbers found: {0}'.format(bad)
|
||||
assert len(html.encode('utf-8')) < 1024 * 1024
|
||||
|
||||
|
||||
def test_minimal_deck_page_has_no_inputs_or_badges_and_a_summary_card():
|
||||
html = panelanalysis.build_analysis_page(minimal_deck())
|
||||
_check_common_invariants(html)
|
||||
# Nothing wires the in-place model on this deck -- both its models are
|
||||
# NOT JUDGED, named as such, never a fabricated zero.
|
||||
assert 'not judged' in html.lower()
|
||||
|
||||
|
||||
def test_in_place_deck_solves_and_draws_its_own_envelope_diagrams():
|
||||
deck = in_place_deck()
|
||||
html = panelanalysis.build_analysis_page(deck)
|
||||
_check_common_invariants(html)
|
||||
# The in-place models actually ran on this deck -- PASS/WARN/FAIL, not
|
||||
# "not judged", for In-Place Strength and In-Place Service.
|
||||
assert 'In-Place Strength' in html
|
||||
assert 'In-Place Service' in html
|
||||
# This file's own SVG diagrams for the governing in-place combination
|
||||
# (`panelanalysis._inplace_diagram`) rendered -- their titles are
|
||||
# unique to them (the handling stages' reused `R.diagram_plot` figures
|
||||
# never say "governing combination").
|
||||
assert 'governing combination' in html.lower()
|
||||
|
||||
|
||||
def test_handling_stages_each_get_their_own_section_and_tag():
|
||||
html = panelanalysis.build_analysis_page(minimal_deck())
|
||||
for stage in ('Stripping', 'Transportation', 'Erection'):
|
||||
assert 'Handling — {0}'.format(stage) in html or \
|
||||
'Handling — {0}'.format(stage) in html
|
||||
|
||||
|
||||
def test_page_is_deterministic_given_the_same_deck_modulo_timestamp():
|
||||
"""Two builds a second apart differ only in the 'generated HH:MM' line
|
||||
-- nothing else on a read-only report should vary run to run."""
|
||||
deck = in_place_deck()
|
||||
h1 = panelanalysis.build_analysis_page(copy.deepcopy(deck))
|
||||
h2 = panelanalysis.build_analysis_page(copy.deepcopy(deck))
|
||||
strip = lambda s: re.sub(r'generated \d{4}-\d\d-\d\d \d\d:\d\d',
|
||||
'generated X', s)
|
||||
assert strip(h1) == strip(h2)
|
||||
@@ -0,0 +1,257 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`tools/panelanalysis_schema.py` -- the manual wind zone contract, the
|
||||
'Free' restraint filter, and `reinforcement.mild_bars` (PANELANALYSIS-4).
|
||||
|
||||
The engine side only. `WillTools.extension/tests/test_panelanalysis_wind.py`
|
||||
(and its siblings) already cover the front-end's own `build_deck` shape and
|
||||
the pre-existing legacy wind precedence (direct > computed > blank); this
|
||||
file does not repeat those, and does not touch anything under
|
||||
`WillTools.extension/` -- that tree is the front-end sibling's territory.
|
||||
|
||||
Import pattern mirrors `_scratch/run_w1.py`: `tools/` goes on `sys.path`,
|
||||
`deckcheck` is imported first purely for its side effect of registering a
|
||||
bare `vibe` package stub (`panelanalysis_schema` does `from vibe import
|
||||
panelanalysisform`, which needs that stub to resolve off Revit), THEN
|
||||
`panelanalysis_schema` itself.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
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 deckcheck # noqa: E402,F401 -- registers the `vibe` stub
|
||||
|
||||
import panelanalysis_schema as schema # noqa: E402
|
||||
from panellift import inplace # noqa: E402
|
||||
from panellift.model import Panel # noqa: E402
|
||||
|
||||
|
||||
def base_deck(**wind_overrides):
|
||||
deck = schema.new_deck(strands=[])
|
||||
deck['loading']['wind'] = dict(wind_overrides)
|
||||
return deck
|
||||
|
||||
|
||||
def wall_panel(length_ft=40.0, width_ft=8.0):
|
||||
return Panel(mark='TEST', length_ft=length_ft, width_ft=width_ft,
|
||||
thickness_in=8.0, unit_weight_pcf=150.0, fc_psi=5000.0,
|
||||
fci_psi=3500.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- wind.mode
|
||||
|
||||
def test_project_config_mode_refuses_with_the_named_reason():
|
||||
deck = base_deck(mode='project_config')
|
||||
zones, trib, reason = schema.wind_zones(deck)
|
||||
assert zones == []
|
||||
assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT
|
||||
assert reason == 'wind from project config is not built yet'
|
||||
|
||||
|
||||
def test_project_config_mode_refuses_even_carrying_other_wind_keys():
|
||||
"""The refusal is unconditional on `mode` alone -- an asce block or a
|
||||
direct pressure sitting beside it changes nothing."""
|
||||
deck = base_deck(mode='project_config', direct_pressure_psf=16.0,
|
||||
asce={'v_ult_mph': 115.0})
|
||||
zones, _trib, reason = schema.wind_zones(deck)
|
||||
assert zones == []
|
||||
assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT
|
||||
|
||||
|
||||
def test_manual_mode_two_zone_different_pressure_suction_reaches_the_engine():
|
||||
"""Two zones with DIFFERENT pressure/suction magnitudes: `wind_zones`
|
||||
hands `inplace` genuinely different fields, and the deck-level
|
||||
`in_place_inputs` flags `wind_zone_mode='manual'` so the engine solves
|
||||
them as two independent cases (not the old negate shortcut)."""
|
||||
deck = base_deck(
|
||||
mode='manual',
|
||||
zones=[
|
||||
{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 20.0, 'width_ft': 8.0,
|
||||
'pressure_psf': 16.0, 'suction_psf': 12.0},
|
||||
{'label': 'W2', 'x0_ft': 20.0, 'x1_ft': 40.0, 'width_ft': 8.0,
|
||||
'pressure_psf': 20.0, 'suction_psf': None},
|
||||
])
|
||||
panel = wall_panel()
|
||||
zones, trib, reason = schema.wind_zones(deck, panel)
|
||||
assert reason is None
|
||||
assert len(zones) == 2
|
||||
z1, z2 = zones
|
||||
assert z1.pressure_psf == 16.0 and z1.suction_psf == 12.0
|
||||
assert z1.width_ft == 8.0
|
||||
assert z2.pressure_psf == 20.0 and z2.suction_psf is None
|
||||
|
||||
assert schema.wind_zone_mode(deck) == 'manual'
|
||||
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 0.0, 'type': 'Pin'}, {'x_ft': 20.0, 'type': 'Pin'},
|
||||
{'x_ft': 40.0, 'type': 'Pin'}]
|
||||
inputs, in_reason = schema.in_place_inputs(deck, panel)
|
||||
assert in_reason is None
|
||||
assert inputs.wind_zone_mode == 'manual'
|
||||
res = inplace.run(panel, inputs)
|
||||
assert res.refusal is None
|
||||
# A pure-negate suction field would peak at exactly the pressure peak;
|
||||
# zone 2 (20 psf pressure, no suction) contributing nothing to W- while
|
||||
# zone 1 does breaks that equality.
|
||||
key_xs = res.cases['D'].xs
|
||||
cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs,
|
||||
res.cases)
|
||||
assert cases_suction is not None
|
||||
w_plus_peak = max(abs(m) for m in res.cases['W'].moment_lbft)
|
||||
w_minus_peak = max(abs(m) for m in cases_suction['W'].moment_lbft)
|
||||
assert w_minus_peak != pytest.approx(w_plus_peak)
|
||||
|
||||
|
||||
def test_manual_mode_zone_width_is_per_zone_not_panel_wide():
|
||||
deck = base_deck(
|
||||
mode='manual',
|
||||
zones=[{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 40.0,
|
||||
'width_ft': 4.2, 'pressure_psf': 10.0, 'suction_psf': 10.0}])
|
||||
zones, trib, reason = schema.wind_zones(deck, wall_panel())
|
||||
assert reason is None
|
||||
assert zones[0].width_ft == 4.2
|
||||
# trib (the panel-wide fallback) is untouched -- a zone carrying its own
|
||||
# width does not have to state a deck-wide tributary_width_ft too.
|
||||
assert trib is None
|
||||
|
||||
|
||||
def test_legacy_zones_deck_is_unaffected_by_the_mode_reader():
|
||||
"""No `mode` key at all (the golden shape) -- the pre-existing single-
|
||||
pressure zone reading is byte-for-byte what it always was."""
|
||||
deck = base_deck(zones=[{'x0_ft': 0.0, 'x1_ft': 37.6,
|
||||
'pressure_psf': 16.0, 'label': 'W-pressure'}])
|
||||
zones, trib, reason = schema.wind_zones(deck, wall_panel())
|
||||
assert reason is None
|
||||
assert len(zones) == 1
|
||||
assert zones[0].pressure_psf == 16.0
|
||||
assert zones[0].suction_psf is None
|
||||
assert zones[0].width_ft is None
|
||||
assert schema.wind_zone_mode(deck) == 'legacy'
|
||||
|
||||
|
||||
# ---------------------------------------------------- restraints, 'Free'
|
||||
|
||||
def test_free_restraint_is_skipped_others_are_kept():
|
||||
deck = schema.new_deck()
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 0.0, 'type': 'Pin'},
|
||||
{'x_ft': 10.0, 'type': 'Free'},
|
||||
{'x_ft': 20.0, 'type': 'Fixed'},
|
||||
{'x_ft': 30.0, 'type': 'Floor tie'},
|
||||
]
|
||||
xs, reason = schema.restraints(deck)
|
||||
assert reason is None
|
||||
assert xs == [0.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_free_restraint_match_is_case_insensitive():
|
||||
deck = schema.new_deck()
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 0.0, 'type': 'FREE'},
|
||||
{'x_ft': 10.0, 'type': 'free'},
|
||||
{'x_ft': 20.0, 'type': 'Pin'},
|
||||
{'x_ft': 30.0, 'type': 'Pin'},
|
||||
]
|
||||
xs, _reason = schema.restraints(deck)
|
||||
assert xs == [20.0, 30.0]
|
||||
|
||||
|
||||
def test_an_unrecognised_type_word_still_counts_as_lateral():
|
||||
"""Only the literal word 'Free' is excluded -- an unknown word (the
|
||||
golden's own 'restrained', or 'dunnage') is counted, never quietly
|
||||
dropped (CLAUDE.md sec.3: unrecognised never means silently excluded)."""
|
||||
deck = schema.new_deck()
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 0.0, 'type': 'restrained'},
|
||||
{'x_ft': 10.0, 'type': 'something-nobody-has-seen'},
|
||||
]
|
||||
xs, _reason = schema.restraints(deck)
|
||||
assert xs == [0.0, 10.0]
|
||||
|
||||
|
||||
def test_restraint_rows_carries_every_extra_key_for_the_page():
|
||||
deck = schema.new_deck()
|
||||
deck['structural_model']['restraints'] = [
|
||||
{'x_ft': 5.0, 'type': 'Pin', 'elevation_ft': 102.5, 'gravity': True,
|
||||
'thermal': False, 'stiffness_kin': 250.0},
|
||||
{'x_ft': 15.0, 'type': 'Free'},
|
||||
]
|
||||
rows = schema.restraint_rows(deck)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]['elevation_ft'] == 102.5
|
||||
assert rows[0]['gravity'] is True
|
||||
assert rows[0]['thermal'] is False
|
||||
assert rows[0]['stiffness_kin'] == 250.0
|
||||
assert rows[0]['lateral'] is True
|
||||
assert rows[1]['lateral'] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------- mild_bars
|
||||
|
||||
def test_mild_bars_with_depth_enter_the_capacity_blank_depth_does_not():
|
||||
deck = schema.new_deck(strands=[])
|
||||
deck['reinforcement']['mild_bars'] = [
|
||||
{'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0,
|
||||
'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6},
|
||||
{'size': '#3', 'count': 10, 'area_in2': 1.1, 'depth_in': None,
|
||||
'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6},
|
||||
{'size': '#4', 'count': 8, 'area_in2': 1.6, 'depth_in': 2.0,
|
||||
'direction': 'Transverse', 'from_ft': 0.0, 'to_ft': 37.6},
|
||||
]
|
||||
bars = schema.mild_bars(deck)
|
||||
assert len(bars) == 3
|
||||
assert bars[1]['depth_in'] is None
|
||||
|
||||
layers = schema.mild_bar_reinforcement(deck)
|
||||
# Only the first row: has a depth AND is Longitudinal.
|
||||
assert len(layers) == 1
|
||||
assert layers[0].area_in2 == pytest.approx(4.8)
|
||||
assert layers[0].count == 1 # total already, not per-bar
|
||||
assert layers[0].z_in == pytest.approx(2.0)
|
||||
assert layers[0].from_ft == pytest.approx(0.0)
|
||||
assert layers[0].to_ft == pytest.approx(37.6)
|
||||
assert layers[0].grade_ksi == pytest.approx(schema.DEFAULT_MILD_BAR_GRADE_KSI)
|
||||
|
||||
|
||||
def test_mild_bars_grade_reads_settings_rebar_fy_ksi():
|
||||
deck = schema.new_deck(strands=[])
|
||||
deck['settings']['rebar_fy_ksi'] = 40.0
|
||||
deck['reinforcement']['mild_bars'] = [
|
||||
{'size': '#5', 'count': 4, 'area_in2': 1.24, 'depth_in': 3.0,
|
||||
'direction': 'Longitudinal'}]
|
||||
layers = schema.mild_bar_reinforcement(deck)
|
||||
assert layers[0].grade_ksi == pytest.approx(40.0)
|
||||
|
||||
|
||||
def test_no_mild_bars_block_is_the_identity_on_to_panel():
|
||||
"""A deck with no `reinforcement.mild_bars` at all -- every deck before
|
||||
PANELANALYSIS-4 -- must reach `to_panel` with EXACTLY the reinforcement
|
||||
`mild_reinforcement` alone would have produced (here: none), proving the
|
||||
handling path (pu=0) sees no change for a bar-less deck."""
|
||||
deck = schema.new_deck(strands=[])
|
||||
panel = schema.to_panel(deck)
|
||||
assert panel.reinforcement == []
|
||||
|
||||
|
||||
def test_mild_bars_area_in2_is_the_layer_total_not_per_bar():
|
||||
"""24 x #4 (area_in2=4.8 TOTAL, per the field model's own convention) must
|
||||
not be re-multiplied by count into 24 * 4.8."""
|
||||
deck = schema.new_deck(strands=[])
|
||||
deck['reinforcement']['mild_bars'] = [
|
||||
{'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0,
|
||||
'direction': 'Longitudinal'}]
|
||||
panel = schema.to_panel(deck)
|
||||
assert len(panel.reinforcement) == 1
|
||||
assert panel.reinforcement[0].total_area_in2 == pytest.approx(4.8)
|
||||
|
||||
|
||||
def test_mild_bars_missing_required_key_refuses_loudly():
|
||||
deck = schema.new_deck(strands=[])
|
||||
deck['reinforcement']['mild_bars'] = [{'size': '#4', 'depth_in': 2.0}]
|
||||
with pytest.raises(schema.DeckError):
|
||||
schema.mild_bars(deck)
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cross-checks `tools/pa_web/static/units.js` against its CPython twin,
|
||||
`panellift.units` -- the two are meant to be a byte-for-byte port of each
|
||||
other (see units.py's own module docstring), and this is the one place that
|
||||
proves it rather than trusting two hand-written files stayed in sync.
|
||||
|
||||
Node-free on purpose: this whole file SKIPS, never fails, when `node` is not
|
||||
on PATH. A missing dev tool on some machine is not a reason a CPython test
|
||||
run should go red, and the JS's own behaviour is otherwise unverified only
|
||||
by inspection -- an acceptable gap, not a gate anyone has to work around.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TOOLS = os.path.abspath(os.path.join(HERE, os.pardir))
|
||||
REPO = os.path.abspath(os.path.join(TOOLS, os.pardir))
|
||||
UNITS_JS = os.path.join(TOOLS, 'pa_web', 'static', 'units.js')
|
||||
|
||||
PANELLIFT_SRC = os.path.join(REPO, 'panellift', 'src')
|
||||
if PANELLIFT_SRC not in sys.path:
|
||||
sys.path.insert(0, PANELLIFT_SRC)
|
||||
|
||||
from panellift import units # noqa: E402
|
||||
|
||||
NODE = shutil.which('node')
|
||||
|
||||
# (js function name, python function name, args, output kind)
|
||||
# output kind 'str' compares exactly; 'num' compares within a float epsilon
|
||||
# (JS and CPython float arithmetic can differ in the last bit).
|
||||
CASES = [
|
||||
('fmtFtIn', 'fmt_ft_in', [8.375], 'str'),
|
||||
('fmtFtIn', 'fmt_ft_in', [None], 'str'),
|
||||
('fmtFtIn', 'fmt_ft_in', [-0.5], 'str'),
|
||||
('fmtFtIn', 'fmt_ft_in', [0.53125], 'str'), # 0'-6 3/8"
|
||||
('fmtFtIn', 'fmt_ft_in', [37.583333333333336], 'str'), # carries to 37'-7"
|
||||
('fmtFtIn', 'fmt_ft_in', [0.0], 'str'),
|
||||
('fmtIn', 'fmt_in', [6.375], 'str'),
|
||||
('fmtIn', 'fmt_in', [None], 'str'),
|
||||
('fmtIn', 'fmt_in', [12.0], 'str'),
|
||||
('fmtIn', 'fmt_in', [0.0], 'str'),
|
||||
('parseLength', 'parse_length', ["8'-4 1/2\""], 'num'),
|
||||
('parseLength', 'parse_length', ["8'4\""], 'num'),
|
||||
('parseLength', 'parse_length', ["8' 4-1/2\""], 'num'),
|
||||
('parseLength', 'parse_length', ['4 1/2"'], 'num'),
|
||||
('parseLength', 'parse_length', ['100"'], 'num'),
|
||||
('parseLength', 'parse_length', ["-0'-6\""], 'num'),
|
||||
('parseLength', 'parse_length', ['5'], 'num'),
|
||||
('parseLength', 'parse_length', ['5', 'in'], 'num'),
|
||||
('parseLengthIn', 'parse_length_in', ['6 3/8"'], 'num'),
|
||||
('parseLengthIn', 'parse_length_in', ['5', 'ft'], 'num'),
|
||||
]
|
||||
|
||||
_HARNESS = """
|
||||
'use strict';
|
||||
var fs = require('fs');
|
||||
global.window = global;
|
||||
var src = fs.readFileSync(process.argv[2], 'utf8');
|
||||
eval(src);
|
||||
var Units = global.Units;
|
||||
var cases = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
|
||||
var out = cases.map(function (c) {
|
||||
try {
|
||||
return {ok: true, value: Units[c.fn].apply(null, c.args)};
|
||||
} catch (e) {
|
||||
return {ok: false, error: String(e)};
|
||||
}
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run_js(tmp_path):
|
||||
harness = tmp_path / 'harness.js'
|
||||
harness.write_text(_HARNESS, encoding='utf-8')
|
||||
cases_path = tmp_path / 'cases.json'
|
||||
cases_path.write_text(
|
||||
json.dumps([{'fn': fn, 'args': args} for fn, _p, args, _k in CASES]),
|
||||
encoding='utf-8')
|
||||
proc = subprocess.run(
|
||||
[NODE, str(harness), UNITS_JS, str(cases_path)],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
assert proc.returncode == 0, (
|
||||
'node harness failed: stdout={0!r} stderr={1!r}'.format(
|
||||
proc.stdout, proc.stderr))
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason='node is not on PATH')
|
||||
def test_units_js_matches_units_py(tmp_path):
|
||||
assert os.path.isfile(UNITS_JS), UNITS_JS
|
||||
js_results = _run_js(tmp_path)
|
||||
assert len(js_results) == len(CASES)
|
||||
for (fn, py_name, args, kind), js in zip(CASES, js_results):
|
||||
py_fn = getattr(units, py_name)
|
||||
py_value = py_fn(*args)
|
||||
assert js['ok'], '{0}{1} threw in JS: {2}'.format(fn, args,
|
||||
js.get('error'))
|
||||
js_value = js['value']
|
||||
if kind == 'str':
|
||||
assert js_value == py_value, (fn, args, js_value, py_value)
|
||||
else:
|
||||
assert abs(js_value - py_value) < 1e-9, (
|
||||
fn, args, js_value, py_value)
|
||||
|
||||
|
||||
def test_units_js_file_exists_even_without_node():
|
||||
"""The one assertion that always runs: the file this whole module is
|
||||
about is actually there, node or not."""
|
||||
assert os.path.isfile(UNITS_JS)
|
||||
Reference in New Issue
Block a user