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,237 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The figures have to be the data, and the data has to be readable.
|
||||
|
||||
Two complaints from the engineer using this, both about the same thing --
|
||||
a picture that does not answer a question is decoration on a calculation:
|
||||
|
||||
1. The free body's load was the same drawing on every piece. It was: the
|
||||
tall stroke at each station came out of `beam_y - 30 - h + h`, so the
|
||||
load height cancelled itself out and the one stroke that DID scale was
|
||||
drawn inside the constant one. A panel with a run of openings looked
|
||||
exactly like a solid slab. `test_two_load_profiles_draw_differently` is
|
||||
the guard.
|
||||
|
||||
2. Shear, moment and stress were curves with a peak label and nothing else,
|
||||
so reading a value at a station meant measuring the page with your eye.
|
||||
They now carry their own samples and the cursor reads them off, in the
|
||||
units the engineer states them in: kip, kip-ft, psi.
|
||||
|
||||
The rule underneath both: the report never shows a number the engine did not
|
||||
compute. The hover values are the plotted samples, converted once at the point
|
||||
of display, written onto the SVG -- not recalculated in the browser.
|
||||
"""
|
||||
import re
|
||||
|
||||
from conftest import aspire_pockets, make_panel
|
||||
from panellift import checks, layout, report, statics
|
||||
from panellift.model import Blockout
|
||||
|
||||
|
||||
def _analyse(panel, stage, rigging):
|
||||
return statics.analyse(panel, stage, rigging)
|
||||
|
||||
|
||||
def _svg_of(page, title_fragment):
|
||||
"""The one <svg> whose title contains this fragment."""
|
||||
i = page.index(title_fragment)
|
||||
return page[page.rindex('<svg', 0, i):page.index('</svg>', i)]
|
||||
|
||||
|
||||
# --------------------------------------------------- 1. the free body is data
|
||||
|
||||
def test_the_load_band_steps_where_the_section_steps(stripping, rolling):
|
||||
"""A blockout takes width out of the piece, so w drops over it. The
|
||||
drawing has to drop over it too, or it is not the free body of anything.
|
||||
"""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
svg = _svg_of(report.free_body(a), 'Free body')
|
||||
band = re.search(r'<path class="loadband" d="([^"]+)"', svg).group(1)
|
||||
ys = set(re.findall(r',([0-9.]+)', band))
|
||||
assert len(ys) >= 3 # beam line, full load, reduced load
|
||||
|
||||
|
||||
def test_two_load_profiles_draw_differently(stripping, rolling):
|
||||
"""The complaint, as a test. Same piece, same pick, same everything but
|
||||
the openings -- and before this the two free bodies were identical."""
|
||||
solid = _analyse(make_panel(), stripping, rolling)
|
||||
pocketed = _analyse(make_panel(blockouts=aspire_pockets()),
|
||||
stripping, rolling)
|
||||
|
||||
a = _svg_of(report.free_body(solid), 'Free body')
|
||||
b = _svg_of(report.free_body(pocketed), 'Free body')
|
||||
|
||||
band_a = re.search(r'<path class="loadband" d="([^"]+)"', a).group(1)
|
||||
band_b = re.search(r'<path class="loadband" d="([^"]+)"', b).group(1)
|
||||
assert band_a != band_b
|
||||
|
||||
# and the arrows, which are what makes the band read as a load
|
||||
arrows_a = re.findall(r'<path class="load" d="([^"]+)"', a)
|
||||
arrows_b = re.findall(r'<path class="load" d="([^"]+)"', b)
|
||||
assert arrows_a != arrows_b
|
||||
|
||||
|
||||
def test_a_uniform_piece_draws_one_flat_band(stripping, rolling):
|
||||
"""The other half of the same guarantee: an unbroken piece must not be
|
||||
given a shape it does not have."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
segs = report.load_segments(a.profile)
|
||||
assert len(segs) == 1
|
||||
svg = _svg_of(report.free_body(a), 'Free body')
|
||||
assert 'plf factored' in svg
|
||||
assert ' to ' not in re.search(r'<text class="note"[^>]*>([^<]*)<',
|
||||
svg).group(1)
|
||||
|
||||
|
||||
def test_the_segments_carry_their_own_magnitudes(stripping, rolling):
|
||||
"""Per-segment labels, in the unit the profile is in -- plf, factored."""
|
||||
panel = make_panel(blockouts=[
|
||||
Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)])
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
svg = _svg_of(report.free_body(a), 'Free body')
|
||||
labels = re.findall(r'<text class="loadlab"[^>]*>([^<]*)</text>', svg)
|
||||
assert len(labels) >= 2
|
||||
assert len(set(labels)) >= 2 # not the same number twice
|
||||
note = re.search(r'<text class="note"[^>]*>([^<]*)<', svg).group(1)
|
||||
assert 'plf factored' in note and ' to ' in note
|
||||
|
||||
|
||||
def test_the_load_segments_are_the_profile_not_an_average(stripping, rolling):
|
||||
"""load_segments must return the real w in each run, and cover the piece
|
||||
end to end -- the free body's numbers are quoted, so they are exact."""
|
||||
panel = make_panel(blockouts=[
|
||||
Blockout(name='B1', x0=8.0, y0=0.0, length=10.0, width=4.0)])
|
||||
a = _analyse(panel, stripping, rolling)
|
||||
segs = report.load_segments(a.profile)
|
||||
assert len(segs) == 3
|
||||
assert segs[0][0] == 0.0
|
||||
assert abs(segs[-1][1] - panel.length_ft) < 1e-9
|
||||
for i in range(len(segs) - 1):
|
||||
assert segs[i][1] == segs[i + 1][0]
|
||||
assert segs[1][2] < segs[0][2] # the opening lightens the middle
|
||||
# every value is one the profile actually holds
|
||||
held = set(a.profile.w)
|
||||
assert all(w in held for _x0, _x1, w in segs)
|
||||
|
||||
|
||||
# ------------------------------------------------- 2 and 3. hover, and units
|
||||
|
||||
def test_the_diagrams_carry_their_samples_in_display_units(stripping,
|
||||
rolling):
|
||||
"""Shear in kip, moment in kip-ft, stress in psi, x in feet. Converted
|
||||
once, here, and written onto the plot -- nothing converts in the browser.
|
||||
"""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
|
||||
units = re.findall(r'data-yu="([^"]*)" data-yl="([^"]*)"', page)
|
||||
# 'fₜ' (real Unicode subscript), not 'f_t': this label lands in an <svg>
|
||||
# <text> node and in the hover script's textContent, which parse a
|
||||
# <sub> tag as four literal characters rather than a subscript -- see
|
||||
# report.notate_plain().
|
||||
assert units == [('kip', 'V'), ('kip-ft', 'M'), ('psi', 'fₜ')]
|
||||
assert page.count('data-xu="ft"') == 3
|
||||
|
||||
shear_svg = _svg_of(page, 'Shear V(x)')
|
||||
hy = re.search(r'data-hy="([^"]*)"', shear_svg).group(1).split()
|
||||
peak_kip = max(abs(float(v)) for v in hy)
|
||||
peak_lb = max(abs(v) for v in a.diagram.shear_lb)
|
||||
assert abs(peak_kip - peak_lb / report.LB_PER_KIP) < 0.02
|
||||
assert peak_kip < peak_lb # it really was divided
|
||||
|
||||
moment_svg = _svg_of(page, 'Moment M(x)')
|
||||
hy = re.search(r'data-hy="([^"]*)"', moment_svg).group(1).split()
|
||||
peak_kipft = max(abs(float(v)) for v in hy)
|
||||
assert abs(peak_kipft -
|
||||
max(abs(m) for m in a.diagram.moment_lbft)
|
||||
/ report.LB_PER_KIP) < 0.2
|
||||
assert all(len(v.split('.')[1]) == 1 for v in hy) # 1 dp, consistent
|
||||
|
||||
|
||||
def test_every_hover_sample_has_a_pixel_and_a_value(stripping, rolling):
|
||||
"""Four arrays of the same length, or the lookup is off by one somewhere
|
||||
and the tooltip quietly reports the wrong station."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
for svg in re.findall(r'<svg[^>]*class="hoverplot"[^>]*>', page):
|
||||
got = [len(re.search(k + r'="([^"]*)"', svg).group(1).split())
|
||||
for k in ('data-hx', 'data-hy', 'data-px', 'data-py')]
|
||||
assert len(set(got)) == 1 and got[0] > 10
|
||||
assert re.search(r'data-t="([-0-9.]+)"', svg)
|
||||
assert re.search(r'data-b="([-0-9.]+)"', svg)
|
||||
|
||||
|
||||
def test_the_stations_are_feet_to_two_decimals(stripping, rolling):
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
svg = _svg_of(report.stress_plot(a), 'Tensile stress')
|
||||
hx = re.search(r'data-hx="([^"]*)"', svg).group(1).split()
|
||||
assert all(len(v.split('.')[1]) == 2 for v in hx)
|
||||
assert float(hx[0]) >= 0.0
|
||||
assert float(hx[-1]) <= round(a.panel.length_ft, 2) + 0.01
|
||||
|
||||
|
||||
def test_the_script_is_present_exactly_once(stripping, rolling):
|
||||
"""One document, one listener. build() nested inside build_many() must
|
||||
not bring a second copy with it."""
|
||||
a = _analyse(make_panel(), stripping, rolling)
|
||||
found = checks.run_all(a, rolling)
|
||||
|
||||
one = report.build(a, rolling, found, recommendation=False)
|
||||
assert one.count('<script>') == one.count('</script>') == 1
|
||||
|
||||
nested = report.build(a, rolling, found, recommendation=False,
|
||||
standalone=False)
|
||||
assert '<script>' not in nested
|
||||
|
||||
many = report.build_many([(a, rolling, found)],
|
||||
recommendation=layout.not_searched())
|
||||
assert many.count('<script>') == many.count('</script>') == 1
|
||||
assert many.count("querySelectorAll('svg.hoverplot')") == 1
|
||||
|
||||
|
||||
def test_a_page_with_no_script_still_shows_every_curve(stripping, rolling):
|
||||
"""Hover is additive. Strip the script out and the report is exactly the
|
||||
report it was -- same curves, same peak rules, same disclosures, and the
|
||||
print overrides untouched. That is the no-JS and the printed case."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
without = page[:page.index('<script>')]
|
||||
|
||||
assert without.count('<svg') == without.count('</svg>') == 5
|
||||
assert without.count('class="series"') == 3
|
||||
assert 'kip-ft' in without and 'allowable' in without
|
||||
assert 'details::details-content' in without # print override intact
|
||||
assert '<details class="section">' in without # closed by default
|
||||
assert '<details class="section" open' not in without
|
||||
assert '.hoverlayer{display:none !important}' in without
|
||||
|
||||
|
||||
def test_hover_values_are_the_values_that_were_plotted(stripping, rolling):
|
||||
"""The samples on the <svg> are the samples the path was drawn from --
|
||||
the same decimation, the same points. A tooltip reading off a different
|
||||
series is a second, unreviewed calculation."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
svg = _svg_of(report.stress_plot(a), 'Tensile stress')
|
||||
px = re.search(r'data-px="([^"]*)"', svg).group(1).split()
|
||||
py = re.search(r'data-py="([^"]*)"', svg).group(1).split()
|
||||
drawn = re.search(r'<path class="series" d="M ([^"]+)"', svg).group(1)
|
||||
pairs = [seg.strip().split(',') for seg in drawn.split(' L ')]
|
||||
assert len(pairs) == len(px)
|
||||
for (dx, dy), hx, hy in zip(pairs, px, py):
|
||||
assert abs(float(dx) - float(hx)) < 0.06
|
||||
assert abs(float(dy) - float(hy)) < 0.06
|
||||
|
||||
|
||||
def test_the_stations_are_monotonic_so_the_lookup_can_bisect(stripping,
|
||||
rolling):
|
||||
"""The script binary-searches data-px. It may do that only because the
|
||||
stations come out of a sorted diagram."""
|
||||
a = _analyse(make_panel(blockouts=aspire_pockets()), stripping, rolling)
|
||||
page = report.build(a, rolling, checks.run_all(a, rolling),
|
||||
recommendation=False)
|
||||
for svg in re.findall(r'<svg[^>]*class="hoverplot"[^>]*>', page):
|
||||
px = [float(v) for v in
|
||||
re.search(r'data-px="([^"]*)"', svg).group(1).split()]
|
||||
assert px == sorted(px)
|
||||
Reference in New Issue
Block a user