52d94303bc
ci / test (push) Has been cancelled
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.
277 lines
13 KiB
Python
277 lines
13 KiB
Python
# -*- 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 `ψ`; 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 √ 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>′</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>′</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′<sub>ci</sub>'
|
||
assert report.notate("f'c") == 'f′<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′<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') == 'φM<sub>n</sub>'
|
||
assert report.notate('phi-Vn') == 'φV<sub>n</sub>'
|
||
assert report.notate('M/phi-Mn') == 'M/φM<sub>n</sub>'
|
||
assert report.notate('V/phi-Vn') == 'V/φV<sub>n</sub>'
|
||
# Bare phi, e.g. checks.py's `detail += ' (phi {0:.2f})'`.
|
||
assert report.notate('(phi 0.90)') == '(φ 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 'ℓ<sub>d</sub> 63.7 in.' in out
|
||
assert 'f<sub>ps</sub> is 187.9 of 243.4 ksi' in out
|
||
assert 'φ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 'φ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²'
|
||
# checks.py: 'S {6} in3'
|
||
assert report.notate('S 1,472 in3') == 'S 1,472 in³'
|
||
# checks.py: 'sin {4:.0f}deg' and 'the 60deg usually taken as ...'
|
||
assert report.notate('sin 60deg') == 'sin 60°'
|
||
assert report.notate('below the 60deg usually') == (
|
||
'below the 60° 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× 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 '&' in out
|
||
assert '<panel>' in out
|
||
assert "Jerry's" in out
|
||
assert 'f′<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 ≤ b'
|
||
assert report.notate('a >= b') == 'a ≥ 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 '²' in line
|
||
assert '<strong>1 cut by a blockout</strong>' in line
|
||
assert '<' not in line and '>' 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 '<br>' not in page and '<strong>' 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
|