WillTools 2026-09-09 -- clean snapshot of the development repo for review
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:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
+372
View File
@@ -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
+128
View File
@@ -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 &mdash; {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)
+257
View File
@@ -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)
+115
View File
@@ -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)