# -*- coding: utf-8 -*- """The tensile limit is a job decision, and the report has to say whose. `tension_limit_sqrt_fc` multiplies sqrt(f'c) to give the allowable tension in psi. 5.0 is the modulus of rupture over a 1.5 safety factor -- "no discernible cracking"; 7.5 is the rupture modulus itself, first cracking with no margin. The two are a factor of 1.5 apart on EVERY stress verdict, which is more than any refinement of the analysis moves, and a commercial check of one of these panels ran at 7.5 while this defaulted to 5.0 -- that difference alone accounted for most of the disagreement between the two. The owner declined to settle it, 2026-08-30: "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自己 填写。I can see it both ways." So the engineer fills it in per job, and these tests hold the two halves of that: the number the job states is the number the check uses, and the page says which number that was and who chose it. A report that states 666 psi with nothing else on it cannot be told apart from the same panel at 444 psi by anybody reading it later, which is the whole failure being prevented. """ import re import pytest from panellift import cli, layout, report from panellift.model import Stage from conftest import LENGTH_FT, THICKNESS_IN, WIDTH_FT from panellift import pci def job(limit=None, source=None, stage_extra=None): """The ASPIRE panel as the exporter writes it, at whatever limit. `limit=None` leaves the key out entirely -- a job file written before anybody could set this, which must behave exactly as it always did. """ left, right = pci.optimal_two_line_supports(LENGTH_FT) stage = {'name': 'stripping', 'multiplier': pci.stripping_multiplier('flat', 'smooth'), 'fc_psi': 4000.0} if limit is not None: stage['tension_limit_sqrt_fc'] = limit if source is not None: stage['tension_limit_source'] = source stage.update(stage_extra or {}) return { 'schema': 'panellift/1', 'panel': { 'mark': 'ASPIRE-1', 'length_ft': LENGTH_FT, 'width_ft': WIDTH_FT, 'thickness_in': THICKNESS_IN, 'unit_weight_pcf': 150.0, 'fci_psi': 4000.0, 'fc_psi': 5000.0, 'lifters': [ {'mark': 'L1', 'family': 'TEST_LIFTER', 'x': left, 'y': 2.5, 'capacity_lb': 12000.0}, {'mark': 'L2', 'family': 'TEST_LIFTER', 'x': left, 'y': 7.5, 'capacity_lb': 12000.0}, {'mark': 'L3', 'family': 'TEST_LIFTER', 'x': right, 'y': 2.5, 'capacity_lb': 12000.0}, {'mark': 'L4', 'family': 'TEST_LIFTER', 'x': right, 'y': 7.5, 'capacity_lb': 12000.0}, ], }, 'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0}, 'stages': [stage], } def analysed(**kw): """(analysis, rigging, findings) for the one stage of that job.""" _panel, _rigging, cases = cli.analyse_job(job(**kw)) return cases[0] def rendered(**kw): """One stage of one piece, all the way to HTML.""" return report.build_many([analysed(**kw)], recommendation=layout.not_searched()) # ------------------------------------------------- the value reaches the sum def test_the_job_files_limit_is_the_limit_the_check_uses(): """The whole chain in one assertion: knowledge -> job file -> Stage -> allowable. A field that writes a key nothing reads is the drift this repository keeps finding, so the number has to move the arithmetic.""" five = analysed(limit=5.0)[0].stage seven = analysed(limit=7.5)[0].stage assert five.allowable_tension_psi == pytest.approx(5.0 * 4000.0 ** 0.5) assert seven.allowable_tension_psi == pytest.approx(7.5 * 4000.0 ** 0.5) # 1.5x, exactly, which is the entire argument. assert (seven.allowable_tension_psi / five.allowable_tension_psi) == pytest.approx(1.5) def test_the_limit_changes_the_number_printed_on_a_real_report(): """Not just the model object -- the rendered page. Two reports of the same piece under the two schools must not be able to look alike.""" five = rendered(limit=5.0) seven = rendered(limit=7.5) assert '316 psi' in five and '474 psi' in seven assert '474 psi' not in five and '316 psi' not in seven def test_a_permissive_limit_can_turn_a_fail_into_a_pass(): """Why this is the largest single source of disagreement: the verdict itself moves, on the same panel, with the same geometry and loads.""" def verdict(limit): # Same panel, same picks, same loads. Only f'ci is dropped to 1500 # psi, which puts the stripping stress between the two allowables: # 5*sqrt(1500) = 194 psi and 7.5*sqrt(1500) = 290 psi. found = [f for f in analysed(limit=limit, stage_extra={'fc_psi': 1500.0})[2] if f.code == 'CRACK'] return found[0].severity if found else None assert verdict(5.0) == 'FAIL' assert verdict(7.5) in ('OK', 'WARN') # ----------------------------------------------------- unset is unchanged def test_a_job_file_with_no_limit_still_gets_five(): """Byte-identity for everything already on disk. Every job file written before this was settable leaves the key out, and absent has to mean the 5.0 it has always meant -- not "ask", not "inherit something".""" assert analysed()[0].stage.tension_limit_sqrt_fc == 5.0 assert Stage(name='s', multiplier=1.0, fc_psi=4000.0) \ .tension_limit_sqrt_fc == 5.0 def test_absent_and_explicit_five_compute_identically(): """The default is the same code path as a stated 5.0, down to the findings text -- apart from the one word saying who chose it.""" absent, stated = analysed(), analysed(limit=5.0) a = [(f.severity, f.code) for f in absent[2]] b = [(f.severity, f.code) for f in stated[2]] assert a == b assert (absent[0].stage.allowable_tension_psi == stated[0].stage.allowable_tension_psi) # --------------------------------------------------------- who chose it def test_provenance_is_derived_when_the_file_does_not_say(): """A stage that states the limit is a stage somebody set; one that does not is running panellift's own number. Neither needs a second key.""" assert analysed()[0].stage.tension_limit_source == 'default' assert analysed(limit=7.5)[0].stage.tension_limit_source == 'job' def test_the_exporter_can_say_a_stage_overrode_the_job(): """The one thing this side cannot work out for itself: a handling.stages entry that beat the job-wide Project Config field. Without it, the engineer who typed 5.0 into that field and got a 7.5 report has nothing on the page that explains the difference.""" stage = analysed(limit=7.5, source='stage')[0].stage assert stage.tension_limit_source == 'stage' assert 'stage' in stage.tension_basis def test_an_unknown_provenance_word_is_refused_not_displayed(): """A label that can be quietly wrong is worse than no label: 'jobb' read as 'job' would print a confident sentence about the wrong layer.""" with pytest.raises(cli.JobError) as exc: cli.analyse_job(job(limit=7.5, source='jobb')) assert 'tension_limit_source' in str(exc.value) def test_the_basis_string_is_ascii(): """Findings are printed to whatever console launched the run. A radical sign there is a crash on a cp1252 code page, not a nicer report.""" stage = Stage(name='s', multiplier=1.0, fc_psi=4000.0) stage.tension_basis.encode('ascii') assert stage.tension_limit_text == "5*sqrt(f'c)" assert Stage(name='s', multiplier=1.0, fc_psi=4000.0, tension_limit_sqrt_fc=7.5).tension_limit_text == \ "7.5*sqrt(f'c)" # ------------------------------------------------ the report cannot be mute def test_every_finding_that_states_an_allowable_states_the_limit(): """CRACK is the check the limit decides, and its line has to carry the coefficient beside the psi -- 444 and 666 on the same concrete are two doctrines, and the psi alone does not say which.""" found = [f for f in analysed(limit=7.5)[2] if f.code == 'CRACK'] assert found assert "7.5*sqrt(f'c), job" in found[0].message def test_the_stress_plot_title_names_the_limit_it_drew(): """It used to say 5*sqrt(f'c) whatever the stage ran at -- the one place a 7.5 job was labelled as a 5.0 one.""" svg = report.stress_plot(analysed(limit=7.5)[0]) assert '7.5' in svg assert 'against 5' not in svg def test_the_basis_list_says_where_the_coefficient_came_from(): page = rendered(limit=7.5) assert 'as the job file states it' in page # notate() dresses "7.5*sqrt(f'c)" up as a real radical over a real # f-prime-sub-c, not the ASCII the underlying strings still carry. assert '7.5' + report.RADICAL_FC in page def test_the_default_is_named_as_a_default_on_the_page(): """The report must not present panellift's own fallback as a decision somebody made.""" page = rendered() assert "panellift's own default" in page or \ "panellift's own default" in page def test_the_stage_table_carries_the_limit_under_the_allowable(): """Two stages of one piece may run at different limits, so it is per row rather than a footnote.""" page = rendered(limit=7.5) assert 'class="limbasis"' in page # notate() renders "7.5*sqrt(f'c)" as a real radical, so the cell now # carries nested tags -- match to the closing of the limbasis # element itself, not to the first '<' inside it. cell = re.search(r'(.*?)', page) assert cell and '7.5' in cell.group(1) and 'job' in cell.group(1) assert report.RADICAL_FC in cell.group(1) def test_the_stage_box_shows_the_limit_beside_the_ratio(): """The boxes are what a reader takes in and moves on from.""" page = rendered(limit=7.5) box = page[page.index('