Files
willtools-demo/panellift/tests/test_notation.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

277 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Real typography in the HTML report, and why the source strings stay ASCII.
`report.notate()` and `report.notate_plain()` are the ONE place engineering
shorthand -- "phi-Mn", "5*sqrt(f'c)", "f_se", "60deg" -- becomes real
subscripts, a real radical, a real degree sign. Everywhere else in this
package, in `checks.py`, `capacity.py` and `model.py`, that text stays plain
ASCII on purpose: the SAME strings are written to sweep/problems.txt, printed
to the console, exported into the job/problem JSON, and printed inside
Revit's pyRevit output window under IronPython 2.7 (see
`WillTools.extension/lib/vibe/liftexport.py`). A radical sign or a raw
`<sub>` tag in any of those is a crash or mojibake, not a nicer report --
this project has already been bitten by exactly that shape of bug once, in
the Discord bridge.
So these tests do two things: pin the rendering (given a known ASCII string,
the HTML/plain output is exactly this), and guard the boundary (the
producers stay ASCII; nothing here should ever need to change if `notate()`
changes).
Every string tested against notate()/notate_plain() below is copied VERBATIM
from `checks.py`, `capacity.py` or `model.py` -- not invented -- because an
untested token is exactly the "regex over prose" failure this was built to
avoid (`psi` the unit must never become `&psi;`; there is no rule for it at
all, on purpose -- see test_units_and_ratios_pass_through_untouched below).
"""
import re
from conftest import aspire_pockets, make_panel
from panellift import checks, report, statics
from panellift.model import Blockout, Prestress, Rigging, Stage, Strand
# --------------------------------------------------------- notate(): HTML
def test_the_radical_is_real_mathml_with_a_plain_fallback_beside_it():
"""A bare &radic; glued to a CSS overline is the old half-measure: the
glyph and the border never actually join. RADICAL_FC now has to carry a
real <msqrt> -- drawn as one shape by the browser's own MathML layout --
AND, right beside it, the plain-Unicode reading a non-MathML viewer gets
instead, because <math> has no fallback of its own."""
assert report.RADICAL_FC == (
'<math><msqrt><mrow><msub><mrow><mi>f</mi><mo>&#x2032;</mo></mrow>'
'<mi>c</mi></msub></mrow></msqrt></math>'
'<span class="radical-fallback">√f′c</span>')
assert report.RADICAL_FCI == (
'<math><msqrt><mrow><msub><mrow><mi>f</mi><mo>&#x2032;</mo></mrow>'
'<mi>ci</mi></msub></mrow></msqrt></math>'
'<span class="radical-fallback">√f′ci</span>')
# The CSS-overline technique is gone; nothing should still reference it.
assert '.rt{' not in report.CSS
assert 'class="rt"' not in report.RADICAL_FC
# The fallback switch: hidden by default, shown only behind a real
# MathML capability test, never behind a guess or a browser sniff.
assert 'math{display:none}' in report.CSS
assert '@supports (math-depth:0)' in report.CSS
assert '.radical-fallback{display:none}' in report.CSS
def test_fc_prime_gets_a_real_prime_and_a_real_subscript():
# Copied from Stage.tension_limit_text's shape: "5*sqrt(f'c)".
assert report.notate("5*sqrt(f'c)") == '5' + report.RADICAL_FC
assert report.notate("7.5*sqrt(f'c)") == '7.5' + report.RADICAL_FC
# f'ci (stripping strength) is a DIFFERENT subscript and must not be
# truncated to f'c's -- "f'ci" contains "f'c" as a substring.
assert report.notate("f'ci") == 'f&prime;<sub>ci</sub>'
assert report.notate("f'c") == 'f&prime;<sub>c</sub>'
# Bare, mid sentence -- from capacity.py's `point.why[col] = "stage
# states no f'c"`.
assert report.notate("stage states no f'c") == (
'stage states no f&prime;<sub>c</sub>')
def test_phi_mn_and_phi_vn_are_real_subscripts_not_hyphenated_ascii():
# From capacity.py's _FLEX_BASIS / _SHEAR_BASIS and checks.py's RATIO_WORD.
assert report.notate('phi-Mn') == '&phi;M<sub>n</sub>'
assert report.notate('phi-Vn') == '&phi;V<sub>n</sub>'
assert report.notate('M/phi-Mn') == 'M/&phi;M<sub>n</sub>'
assert report.notate('V/phi-Vn') == 'V/&phi;V<sub>n</sub>'
# Bare phi, e.g. checks.py's `detail += ' (phi {0:.2f})'`.
assert report.notate('(phi 0.90)') == '(&phi; 0.90)'
def test_a_real_finding_message_renders_with_real_markup():
"""Not a synthetic string -- an actual CapacityPoint.why /
development_note() sentence, copied from capacity.py, run through the
same function the Checks table uses."""
why = ('within a strand\'s development length of a free end (36.0 in. '
'from the end of the piece; l_d 63.7 in.) -- f_ps is 187.9 of '
'243.4 ksi here, so phi-Mn, which is strand only, tends to zero '
'while M_cr does not')
out = report.notate(why)
assert '&#8467;<sub>d</sub> 63.7 in.' in out
assert 'f<sub>ps</sub> is 187.9 of 243.4 ksi' in out
assert '&phi;M<sub>n</sub>, which is strand only' in out
assert 'M<sub>cr</sub> does not' in out
# And nothing here should ever come back out as raw ASCII shorthand.
assert 'phi-Mn' not in out and 'M_cr' not in out and 'l_d' not in out
def test_the_shear_basis_sentence_renders_v_n_equals_v_c():
# capacity.py's _SHEAR_BASIS, verbatim.
basis = ('phi-Vn here is CONCRETE ALONE -- ACI 318-19 22.5.5.1 '
'simplified, no shear reinforcement in the handling direction, '
'so V_n = V_c')
out = report.notate(basis)
assert '&phi;V<sub>n</sub> here is CONCRETE ALONE' in out
assert 'V<sub>n</sub> = V<sub>c</sub>' in out
def test_aps_in2_in3_and_degrees():
# checks.py: 'P {0} lb over Aps {1:.2f} in2 ...'
assert report.notate('Aps 0.61 in2') == 'A<sub>ps</sub> 0.61 in&sup2;'
# checks.py: 'S {6} in3'
assert report.notate('S 1,472 in3') == 'S 1,472 in&sup3;'
# checks.py: 'sin {4:.0f}deg' and 'the 60deg usually taken as ...'
assert report.notate('sin 60deg') == 'sin 60&deg;'
assert report.notate('below the 60deg usually') == (
'below the 60&deg; usually')
def test_the_multiplication_idiom_not_the_coordinate_one():
# checks.py SLING-ANGLE: 'line force is {1:.2f}x the vertical reaction'
assert report.notate('1.03x the vertical reaction') == (
'1.03&times; the vertical reaction')
# A coordinate is always written "x=1.23 ft" in this package -- no digit
# immediately in front of the letter -- so it must NOT be touched.
assert report.notate('the pick line at x=1.23 ft') == (
'the pick line at x=1.23 ft')
def test_units_and_ratios_pass_through_untouched():
"""The owner's own example: psi/ksi/plf/pli/lb/kips are already correct
and must never be touched, and a ratio like 4:1 stays exactly as typed.
There is no rule for any of these in _NOTATION_RULES -- this asserts the
omission, not an exception that could rot."""
for s in ('444 psi', '270 ksi', '109.4 plf', '80 pli', '34,638 lb',
'8 kips', 'Capacity in Concrete 4:1 SWL', '0.207'):
assert report.notate(s) == report.esc(s)
def test_data_is_still_escaped_around_the_markup():
"""A panel mark or a job-authored reason can carry HTML-significant
characters; notate() has to escape those exactly like esc() does, in the
SAME string that also carries real notation."""
out = report.notate("Tom & Jerry's <panel> f'c 4000 psi")
assert '&amp;' in out
assert '&lt;panel&gt;' in out
assert "Jerry&#x27;s" in out
assert 'f&prime;<sub>c</sub>' in out
# And never literal angle brackets from the data itself.
assert '<panel>' not in out
def test_le_ge_and_none_input():
assert report.notate('a <= b') == 'a &le; b'
assert report.notate('a >= b') == 'a &ge; b'
assert report.notate(None) == ''
# ---------------------------------------------- notate_plain(): SVG / JS text
def test_plain_mode_never_emits_a_tag():
"""<sub> in an <svg> <text> node or in JS textContent renders as four
literal characters, not a subscript -- notate_plain() must never emit
one."""
samples = ["5*sqrt(f'c)", 'phi-Mn', 'f_t', 'f_se', 'M_cr', 'Aps 0.61 in2',
'sin 60deg', '1.03x the vertical reaction', '(phi 0.90)']
for s in samples:
out = report.notate_plain(s)
assert '<' not in out and '>' not in out, (s, out)
assert '&' not in out, (s, out)
def test_plain_mode_uses_unicode_subscripts_where_they_exist():
assert report.notate_plain('f_t') == 'fₜ' # fₜ
assert report.notate_plain('phi-Mn') == 'φMₙ' # φMₙ
assert report.notate_plain("5*sqrt(f'c)") == "5√f′c"
# No Unicode subscript 'c' exists -- f_c degrades to plain "fc", not a
# fabricated subscript and not the bare underscore.
assert report.notate_plain('f_c') == 'fc'
assert '_' not in report.notate_plain('f_c')
# ---------------------------------------------------- the architecture edge
def test_the_source_strings_stay_ascii():
"""The producers -- Stage.tension_limit_text, and a real finding message
-- must still be pure ASCII after this change. Only report.py's renderer
may carry Unicode or HTML; console/sweep/JSON/IronPython read this."""
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
text = stage.tension_limit_text
assert text == "7.5*sqrt(f'c)"
text.encode('ascii') # raises if it is not
panel = make_panel(blockouts=aspire_pockets())
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
findings = checks.run_all(analysis, Rigging(mode='rolling'))
for f in findings:
f.message.encode('ascii')
def test_a_rendered_report_carries_no_raw_ascii_shorthand_in_its_body():
"""End to end: build a real report and check the BODY (not the CSS
comments, which are developer prose, not rendered text) for the ASCII
forms this whole module exists to replace."""
panel = make_panel(blockouts=aspire_pockets())
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
findings = checks.run_all(analysis, Rigging(mode='rolling'))
page = report.build(analysis, Rigging(mode='rolling'), findings,
recommendation=False)
body = page[page.index('</style>'):]
for pattern in (r"\bphi-Mn\b", r"\bphi-Vn\b", r"f'c\b", r"f'ci\b",
r"\bf_t\b", r"\bf_c\b", r"\bf_se\b", r"\bf_pu\b",
r"\bf_ps\b", r"\bl_d\b", r"\bl_t\b", r"\bM_cr\b",
r"\bV_n\b", r"\bV_c\b", r"\bAps\b", r"\bin2\b",
r"\bin3\b", r"\bd_b\b", r"\beps_t\b"):
assert not re.search(pattern, body), pattern
# And the positive side of the same assertion: the markup is actually
# there, not just absent.
assert '<sub>' in body and report.RADICAL_FC in body
# --------------------------------------- _prestress_line() vs. _rows()/notate()
def _panel_with_a_cut_strand():
"""A panel with real prestress and a blockout that severs the one
strand -- enough to exercise every clause of `_prestress_line()`,
including the `<strong>...cut by a blockout</strong>` branch."""
panel = make_panel(blockouts=[
Blockout(name='OPENING', x0=15.0, y0=1.5, length=2.0, width=1.0)])
panel.prestress = Prestress(
strands=[Strand(y=2.0, z_in=4.0, area_in2=0.153,
initial_pull_lb=0.153 * 270_000 * 0.75)],
loss_pct=8.0, diameter_in=0.5)
return panel
def test_prestress_line_returns_real_markup_never_escaped():
"""`_prestress_line()` builds its own <br>/<strong>/<sub> markup and
escapes only the free-form pieces itself (see its own docstring) -- this
pins that raw output. See the end-to-end test below for the regression
it was written to catch: `_rows()` running this same markup through
notate() a second time, printing the literal text "<br>" on the page."""
line = report._prestress_line(_panel_with_a_cut_strand())
assert '<br>' in line
assert '<sub>ps</sub>' in line and '&sup2;' in line
assert '<strong>1 cut by a blockout</strong>' in line
assert '&lt;' not in line and '&gt;' not in line
def test_the_rendered_piece_table_prestress_row_is_not_double_escaped():
"""Regression: the Piece table used to build the Prestress row with
`_rows()`, which runs every value through `notate()` -- safe for the
other rows' plain prose, but `_prestress_line()`'s value is already real
HTML it built and escaped itself, so notate() escaped it a SECOND time
and the report printed the literal text "<br>" / "<strong>" instead of a
line break and bold. Build a whole report and check the actual row."""
panel = _panel_with_a_cut_strand()
stage = Stage(name='stripping', multiplier=1.3, fc_psi=4000.0,
tension_limit_sqrt_fc=7.5, tension_limit_source='job')
analysis = statics.analyse(panel, stage, Rigging(mode='rolling'))
findings = checks.run_all(analysis, Rigging(mode='rolling'))
page = report.build(analysis, Rigging(mode='rolling'), findings,
recommendation=False)
assert '&lt;br&gt;' not in page and '&lt;strong&gt;' not in page
assert '<tr><th>Prestress</th><td>' in page
row = page[page.index('<tr><th>Prestress</th><td>'):]
row = row[:row.index('</td></tr>') + len('</td></tr>')]
assert '<br>' in row
assert '<strong>' in row and 'cut by a blockout' in row