# -*- coding: utf-8 -*- """Tests for `vibe.pa_criteria.page` -- the Design Criteria section layout. No Revit and no WPF renderer are available here (see `conftest.py`'s docstring for why `vibe` imports this way), so this suite cannot check that the page LOOKS right -- nobody has run this inside Revit and this file does not claim otherwise. What it can and does check mechanically: * `page()` never raises, on an empty dict, a filled dict, `None` values, `None` reads, or a `values` object that is not even a dict. * every block returned is a well-formed member of the block vocabulary `page()`'s docstring promises (`heading`, `columns`, `field`, `kv`, `sentence`, `table`, `rule`, `space`) -- an unknown `kind` renders as nothing on the real form, so a typo here would be a silently missing block that only this test would catch. * every `field` block carries a non-empty, plain-English `label` and a non-empty `hint` -- the owner's whole complaint about the old form was that a field carried only its engineering symbol. * a field key with no backing entry in `panelanalysisform.FIELDS` still renders (an empty editor), and is named in `problems` -- never silently dropped. * `values` is never mutated by a call to `page()`. * the block list is stable: calling `page()` twice on the same input produces the same output. """ import copy import sys from pathlib import Path TESTS_DIR = Path(__file__).resolve().parent if str(TESTS_DIR) not in sys.path: sys.path.insert(0, str(TESTS_DIR)) from vibe import pa_criteria as pc # noqa: E402 KNOWN_KINDS = set(['heading', 'columns', 'field', 'kv', 'sentence', 'table', 'lc_table', 'rule', 'space']) KNOWN_EDITORS = set(['text', 'num', 'check', 'choice']) def _walk(blocks): """Yield every block in the tree, including nested `columns` children.""" for b in blocks: yield b if b.get('kind') == 'columns': for col in b.get('columns') or []: for sub in _walk(col): yield sub def _assert_well_formed(blocks): for b in blocks: assert isinstance(b, dict), 'block is not a dict: {0!r}'.format(b) kind = b.get('kind') assert kind in KNOWN_KINDS, 'unknown block kind {0!r}'.format(kind) if kind == 'heading': assert isinstance(b['text'], str) and b['text'].strip() assert b['level'] in (1, 2, 3) elif kind == 'columns': assert isinstance(b['columns'], list) and b['columns'] for col in b['columns']: assert isinstance(col, list) _assert_well_formed(col) elif kind == 'field': assert isinstance(b['key'], str) and b['key'] assert isinstance(b['label'], str) and b['label'].strip(), ( 'field {0!r} has no plain-English label'.format(b['key'])) assert isinstance(b['hint'], str) and b['hint'].strip(), ( 'field {0!r} has no hint'.format(b['key'])) assert b['symbol'] is None or isinstance(b['symbol'], str) assert isinstance(b['required'], bool) assert b['editor'] in KNOWN_EDITORS, ( 'field {0!r} has unknown editor {1!r}' .format(b['key'], b['editor'])) if b['editor'] == 'choice': assert b['choices'], ( 'choice field {0!r} has no choices'.format(b['key'])) else: assert b['choices'] is None elif kind == 'kv': assert isinstance(b['rows'], list) for row in b['rows']: assert len(row) == 2 elif kind == 'sentence': assert isinstance(b['text'], str) and b['text'].strip() elif kind == 'table': assert isinstance(b['headers'], list) and b['headers'] assert isinstance(b['rows'], list) for row in b['rows']: assert len(row) == len(b['headers']), ( 'table row {0!r} does not match headers {1!r}' .format(row, b['headers'])) for cell in row: if isinstance(cell, dict): # `_render_table` grew support for ANY editor kind in # a cell, not just a tick box (see its own comment, # "ANY editor kind, not just 'check'") -- that is what # let Resistance Factors move from a squeezed # `columns`-of-`field`s to a `table` of live `num` # editors on 2026-09-04 (WPA655). A cell dict is no # longer only ever a checkbox. assert cell.get('editor') in KNOWN_EDITORS assert cell.get('key') else: assert isinstance(cell, str) elif kind == 'lc_table': # The load-combination grid: same row/header contract as `table`, # plus a `name` naming the table and a `factors` list the renderer # needs to build an added row's cells. Unlike `table`, a cell may # carry ANY editor kind -- the factors are editable now. assert isinstance(b['name'], str) and b['name'] assert isinstance(b['headers'], list) and b['headers'] assert isinstance(b['factors'], list) and b['factors'] assert isinstance(b['rows'], list) for row in b['rows']: assert len(row) == len(b['headers']), ( 'lc_table row {0!r} does not match headers {1!r}' .format(row, b['headers'])) for cell in row: if isinstance(cell, dict): assert cell.get('editor') in KNOWN_EDITORS assert cell.get('key') else: assert isinstance(cell, str) elif kind == 'rule': assert set(b.keys()) == set(['kind']) elif kind == 'space': assert isinstance(b['h'], int) def _field_keys(blocks): return [b['key'] for b in _walk(blocks) if b.get('kind') == 'field'] # --------------------------------------------------------------------- tests def test_empty_values_never_raises_and_is_well_formed(): blocks, problems = pc.page({}) assert isinstance(blocks, list) and blocks assert isinstance(problems, list) _assert_well_formed(blocks) def test_none_values_and_none_reads_never_raises(): blocks, problems = pc.page(None, None) assert isinstance(blocks, list) and blocks _assert_well_formed(blocks) def test_non_dict_values_degrades_rather_than_raising(): blocks, problems = pc.page('not a dict') assert isinstance(blocks, list) and blocks _assert_well_formed(blocks) def test_filled_values_never_raises_and_is_well_formed(): values = { 'fc_psi': '6000', 'fci_psi': '3500', 'unit_weight_pcf': '150', 'concrete_type': 'Normalweight', 'rebar_fy_ksi': '60', 'rebar_es_ksi': '29000', 'ps_fpu_ksi': '270', 'strand_ep_ksi': '28500', 'ps_loss_method': 'PCI', 'ps_loss_humidity_pct': '75', 'design_criteria.rvalue_calc_enabled': 'yes', 'design_criteria.min_reinf_checked': 'true', } blocks, problems = pc.page(values, {'mark': 'sample_mark', 'design_number': 'PNL1.0'}) assert isinstance(blocks, list) and blocks _assert_well_formed(blocks) def test_values_dict_is_never_mutated(): values = {'fc_psi': '6000'} before = copy.deepcopy(values) pc.page(values, {'mark': 'sample_mark'}) assert values == before def test_reads_dict_is_never_mutated(): reads = {'mark': 'sample_mark', 'design_number': 'PNL1.0'} before = copy.deepcopy(reads) pc.page({}, reads) assert reads == before def test_block_list_is_stable_for_the_same_input(): values = {'fc_psi': '6000', 'unit_weight_pcf': '150'} reads = {'mark': 'sample_mark'} a, pa = pc.page(values, reads) b, pb = pc.page(values, reads) assert a == b assert pa == pb def test_missing_key_degrades_to_a_field_and_names_a_problem(): blocks, problems = pc.page({}) keys = set(_field_keys(blocks)) # A key this file invented because Eriksson's page has no home for it # yet in panelanalysisform.FIELDS. assert 'design_criteria.analysis_beta_d' in keys joined = ' '.join(problems) assert 'design_criteria.analysis_beta_d' in joined def test_existing_form_key_is_not_reported_as_missing(): blocks, problems = pc.page({}) joined = ' '.join(problems) # fc_psi is a real, long-standing panelanalysisform.FIELDS key. assert "'fc_psi'" not in joined or 'fc_psi) has no backing' not in joined for p in problems: assert 'fc_psi)' not in p def test_every_field_key_appears_at_most_once(): blocks, _problems = pc.page({}) keys = _field_keys(blocks) dupes = [k for k in set(keys) if keys.count(k) > 1] assert not dupes, 'field key(s) rendered more than once: {0}'.format(dupes) def test_section_heading_is_present(): blocks, _problems = pc.page({}) headings = [b['text'] for b in blocks if b.get('kind') == 'heading'] assert 'Design Criteria' in headings assert headings[0] == 'Design Criteria' def test_prime_notation_used_not_ascii_apostrophe(): blocks, _problems = pc.page({}) for b in _walk(blocks): if b.get('kind') != 'field': continue symbol = b.get('symbol') or '' # f'c / f'ci style ASCII apostrophes must never appear; the real # prime character (U+2032) is what the renderer turns into a # subscript. assert "f'c" not in symbol and "f'ci" not in symbol if u"f′" in symbol or u"E′" in symbol: assert True # sanity: the prime character is reachable at all def test_load_combination_tables_have_include_checkboxes(): blocks, problems = pc.page({}) tables = [b for b in _walk(blocks) if b.get('kind') == 'lc_table'] assert len(tables) == 2 assert set(t['name'] for t in tables) == set(['strength', 'service']) for t in tables: assert t['headers'][-1] == 'Include' for row in t['rows']: cell = row[-1] assert isinstance(cell, dict) and cell['editor'] == 'check' assert cell['key'].startswith( 'load_combo.{0}.'.format(t['name'])) assert cell['key'].endswith('.include') # The Include ticks used to be reported as having "no backing fields yet". # They do now (`panelanalysisform.load_combo_include_key`, pre-ticked by # `prefill`), so that problem is gone rather than standing stale. joined = ' '.join(problems) assert 'no backing fields yet' not in joined def test_load_combination_factor_cells_are_editable_and_keyed(): """Owner, 2026-09-03: the ACI factors are the user's to change. Every factor cell must be a live numeric editor with a key of its own, not the plain printed text the old `table` block carried.""" blocks, _problems = pc.page({}) tables = [b for b in _walk(blocks) if b.get('kind') == 'lc_table'] assert tables for t in tables: assert t['factors'] == ['D', 'L', 'R', 'W', 'E', 'T', 'H'] for row in t['rows']: rid = row[0] assert isinstance(rid, str) and rid for i, col in enumerate(t['factors']): cell = row[i + 1] assert isinstance(cell, dict), ( 'factor cell {0} of row {1} is not an editor' .format(col, rid)) assert cell['editor'] == 'num' assert cell['key'] == 'load_combo.{0}.{1}.{2}'.format( t['name'], rid, col) def test_load_combination_defaults_match_the_form_modules_table(): """The local ACI tuples are a fallback for an unimportable sibling module (see `_combo_table`); they must not drift from the live copy.""" from vibe import panelanalysisform as paf assert pc._STRENGTH_COMBOS == paf.LOAD_COMBO_ROWS['strength'] assert pc._SERVICE_COMBOS == paf.LOAD_COMBO_ROWS['service'] def test_a_user_added_row_shows_up_in_the_table(): """A row the user added exists only as keys in `values`; the table has to rebuild it from there or the row would vanish on the next render.""" values = {'load_combo.strength.u1.include': 'yes', 'load_combo.strength.u1.D': '1.05'} blocks, _problems = pc.page(values) strength = [b for b in _walk(blocks) if b.get('kind') == 'lc_table' and b['name'] == 'strength'][0] ids = [row[0] for row in strength['rows']] assert ids[-1] == 'u1' assert len(ids) == 9 # the eight ACI rows, then the user's def test_soil_blocks_are_sentences_not_editors(): blocks, problems = pc.page({}) sentences = [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence'] assert any('no exterior soil loads' in s for s in sentences) assert any('no interior soil loads' in s for s in sentences) assert any('soil loads are not collected' in p for p in problems) def test_prestress_loss_group_is_a_sentence_when_blank(): blocks, _problems = pc.page({}) sentences = [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence'] assert any('No prestress loss assumptions' in s for s in sentences) # And its fields must NOT be rendered while blank -- shown only once # something in the group is filled in. keys = set(_field_keys(blocks)) assert 'ps_loss_method' not in keys def test_prestress_loss_group_becomes_fields_when_filled(): blocks, _problems = pc.page({'ps_loss_method': 'PCI'}) keys = set(_field_keys(blocks)) assert 'ps_loss_method' in keys assert 'ps_loss_humidity_pct' in keys def test_filling_one_group_field_does_not_file_a_phantom_problem_for_others(): # Regression guard: `_group_or_sentence` must not evaluate the # filled-in branch (and thus call `F(key)`, which files a "missing" # problem) when the sentence branch is the one actually shown. _blocks, problems_blank = pc.page({}) joined = ' '.join(problems_blank) assert 'ps_loss_method' not in joined assert 'ps_loss_humidity_pct' not in joined def test_rvalue_and_min_reinforcement_default_sentences_present(): blocks, _problems = pc.page({}) sentences = [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence'] assert any('R-value is not being calculated' in s for s in sentences) assert any('Minimum steel is not being checked' in s for s in sentences) def test_concrete_grid_has_release_and_inplace_strength_fields(): blocks, _problems = pc.page({}) keys = set(_field_keys(blocks)) assert 'fci_psi' in keys assert 'fc_psi' in keys assert 'conc_eci_psi' in keys assert 'conc_ec_psi' in keys def test_ksi_reconciliation_hint_on_psi_keys(): blocks, _problems = pc.page({}) for b in _walk(blocks): if b.get('kind') == 'field' and b['key'] in ('fc_psi', 'fci_psi'): assert 'ksi' in b['hint'].lower() def test_required_fields_match_panelanalysisform_where_it_exists(): from vibe import panelanalysisform as pf blocks, _problems = pc.page({}) required_here = dict((b['key'], b['required']) for b in _walk(blocks) if b.get('kind') == 'field') for f in pf.FIELDS: key = f['key'] if key in required_here: assert required_here[key] == f['required'], ( 'required flag mismatch for {0!r}'.format(key)) def test_panel_header_kv_shown_only_when_reads_present(): blocks_no_reads, _ = pc.page({}, {}) kvs_no_reads = [b for b in blocks_no_reads if b.get('kind') == 'kv'] blocks_with_reads, _ = pc.page({}, {'mark': 'sample_mark'}) kvs_with_reads = [b for b in blocks_with_reads if b.get('kind') == 'kv'] assert len(kvs_with_reads) >= len(kvs_no_reads) + 1 def test_page_import_survives_a_broken_panelanalysisform(monkeypatch): """Simulates the sibling agent's file being mid-edit / unimportable. `_existing_keys()` is the one function that reaches for `panelanalysisform`; monkeypatching it directly to behave the way it would if the import raised (returning `None`) exercises the same "could not judge" path `page()` must degrade through, without depending on the interpreter's module-cache mechanics for a package (`vibe`) that other tests in this same process have already imported `panelanalysisform` onto as an attribute. """ monkeypatch.setattr(pc, '_existing_keys', lambda: None) blocks, problems = pc.page({}) assert isinstance(blocks, list) and blocks assert any('could not read panelanalysisform' in p for p in problems) def test_choice_field_has_choices(): blocks, _problems = pc.page({}) for b in _walk(blocks): if b.get('kind') == 'field' and b['key'] == 'concrete_type': assert b['editor'] == 'choice' assert 'Normalweight' in b['choices'] # ------------------------------------------------ 2026-09-04 dropdown pass # # Owner: "很多东西应该是选项,而不是让用户填写" -- governing code, ASCE # chapters and the rest of the enumerated Design Criteria settings become # pick-lists. Vocabulary is pinned exactly because `vibe.pa_defaults` (a # sibling module) writes these values verbatim. def test_design_spec_is_a_choice_field(): blocks, _problems = pc.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') f = fields['design_spec'] assert f['editor'] == 'choice' assert f['choices'] == ['ACI 318-19', 'ACI 318-14', 'ACI 318-11'] def test_design_criteria_settings_fields_are_choice_editors(): blocks, _problems = pc.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') expected = { 'design_criteria.analysis_beta_d': ['Non-Sway', 'Sway'], 'design_criteria.cracking_method': ['Mast Method', 'Uncracked', 'Cracked'], 'design_criteria.flexure_method': ['Whitney Stress Block', 'Strain Compatibility'], 'design_criteria.flexure_cracking_check': ['None', '1.2 Mcr', '1.33 Mu'], 'design_criteria.deflection_cantilever_limit': ['L/75', 'L/120', 'L/150'], } for key, choices in expected.items(): f = fields[key] assert f['editor'] == 'choice', '{0} is not a choice editor'.format( key) assert f['choices'] == choices def test_yes_no_design_criteria_fields_stay_checkboxes(): blocks, _problems = pc.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') for key in ( 'design_criteria.deflection_add_bow', 'design_criteria.deflection_check_cantilever', 'design_criteria.rvalue_calc_enabled', 'design_criteria.min_reinf_checked', 'design_criteria.stress_failure_if_cracked', 'design_criteria.stress_ignore_lateral_ecc', 'design_criteria.stress_use_principal_axis', 'design_criteria.shear_treat_as_column', 'design_criteria.axial_ignore_tensile_failures', 'design_criteria.axial_ignore_buckling_failures', ): assert fields[key]['editor'] == 'check', key assert fields[key]['choices'] is None def test_cracking_assumption_now_renders_as_a_choice_field(): """`cracking_assumption` is a real `panelanalysisform.FIELDS` key with a Cracked/Uncracked vocabulary (and a `PREFILL_DEFAULTS` entry) that no page module surfaced before this change -- added beside `second_order` in Structural Analysis so a pre-filled value has somewhere to be seen and edited.""" blocks, _problems = pc.page({}) keys = set(_field_keys(blocks)) assert 'cracking_assumption' in keys fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') f = fields['cracking_assumption'] assert f['editor'] == 'choice' assert f['choices'] == ['Cracked', 'Uncracked'] # ------------------------------------------------ 2026-09-04 headless review # # WPA655's rendered Design Criteria tab was reviewed by eye and five findings # were filed; these tests pin the fix for each. See the module docstring's # "UPDATE 2026-09-04" note for the Resistance Factors layout change and the # `ps_loss_humidity_pct` comment above the Prestress Losses block. def _table_blocks(blocks): """Every `table`-kind block anywhere in the tree, `columns` descended.""" return [b for b in _walk(blocks) if b.get('kind') == 'table'] def _table_cell_keys(blocks): """Every editor key embedded in a `table` cell. These live inside `table.rows`, not as `field` blocks -- `_field_keys` cannot see them, which is exactly why the Resistance Factors phi editors need their own check rather than reusing that helper.""" keys = [] for t in _table_blocks(blocks): for row in t.get('rows') or []: for cell in row: if isinstance(cell, dict) and cell.get('key'): keys.append(cell['key']) return keys _PHI_KEYS = ('phi_flexure', 'design_criteria.phi_tension_transfer', 'design_criteria.phi_compression', 'phi_shear', 'design_criteria.phi_shear_transfer') def test_every_resistance_factor_phi_has_a_visible_table_editor(): """Finding 1: the old two-column `Developed | Transferring` `columns` of `field` blocks squeezed every phi editor to zero width. All five phi keys must now show up as live editors somewhere on the page.""" blocks, _problems = pc.page({}) keys = set(_table_cell_keys(blocks)) for key in _PHI_KEYS: assert key in keys, '{0} has no visible editor'.format(key) def test_resistance_factor_phi_cells_are_live_num_editors_not_text(): blocks, _problems = pc.page({}) cells_by_key = {} for t in _table_blocks(blocks): for row in t.get('rows') or []: for cell in row: if isinstance(cell, dict) and cell.get('key') in _PHI_KEYS: cells_by_key[cell['key']] = cell assert set(cells_by_key) == set(_PHI_KEYS) for key, cell in cells_by_key.items(): assert cell['editor'] == 'num', '{0} is not a num editor'.format(key) def test_resistance_factors_table_marks_compression_transferring_na(): """The one phi that genuinely does not apply (compression once the strand has fully transferred) stays a plain text cell, not an editor -- same discipline as the old `sentence`, just inside the new table.""" blocks, _problems = pc.page({}) tables = [t for t in _table_blocks(blocks) if t['headers'][0] == u'φ'] assert len(tables) == 1 rows = dict((row[0], row) for row in tables[0]['rows']) assert 'Compression' in rows na_cell = rows['Compression'][2] # the 'Transferring' column assert isinstance(na_cell, str) assert 'not applicable' in na_cell.lower() def test_resistance_factor_missing_backing_field_problems_still_filed(): """Moving the three `design_criteria.phi_*` keys into table cells must not silently drop the existing 'no backing field yet' bookkeeping -- `_cell()` routes every key through `F()` first specifically to keep it.""" _blocks, problems = pc.page({}) joined = ' '.join(problems) for key in ('design_criteria.phi_tension_transfer', 'design_criteria.phi_compression', 'design_criteria.phi_shear_transfer'): assert key in joined, '{0} missing-field problem was dropped'.format( key) def test_mesh_material_fields_are_collected(): """Finding 2: `mesh_fy_ksi` / `mesh_es_ksi` exist in `panelanalysisform.FIELDS` (80 ksi / 29000 ksi defaults) but this page used to render only a 'not collected yet' sentence for Mesh -- now shown the same way Rebar's fy / Es are.""" blocks, problems = pc.page({}) keys = set(_field_keys(blocks)) assert 'mesh_fy_ksi' in keys assert 'mesh_es_ksi' in keys joined = ' '.join(problems) # `_field()`'s missing-backing-field message quotes the key inside # parens -- "(mesh_fy_ksi)" -- and must not appear, since both keys are # real `panelanalysisform.FIELDS` entries. assert '(mesh_fy_ksi)' not in joined assert '(mesh_es_ksi)' not in joined sentences = [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence'] assert not any('No mesh material properties' in s for s in sentences) def test_inplace_tension_sqrt_row_present_above_modulus_of_rupture(): """Finding 3: the uncracked in-place tension limit (`inplace_tension_sqrt`, 7.5 sqrt(f'c)) is a real `panelanalysisform` key this page never surfaced. All three In-Place Stress Limits rows must be present and named the way Eriksson prints them.""" blocks, _problems = pc.page({}) keys = set(_field_keys(blocks)) assert 'inplace_tension_sqrt' in keys assert 'design_criteria.inplace_modulus_rupture_sqrt_fc' in keys assert 'inplace_comp_ratio' in keys fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') assert fields['inplace_tension_sqrt']['label'] == u'Tension, uncracked' assert (fields['design_criteria.inplace_modulus_rupture_sqrt_fc']['label'] == u'Tension, transition (modulus of rupture)') assert fields['inplace_comp_ratio']['label'] == u'Compression' def test_inplace_tension_sqrt_precedes_modulus_of_rupture_in_col_b(): """Order matters, not just presence -- the uncracked tension row belongs above the modulus-of-rupture row, as on Eriksson's own printout.""" blocks, _problems = pc.page({}) field_keys = _field_keys(blocks) i_tension = field_keys.index('inplace_tension_sqrt') i_rupture = field_keys.index( 'design_criteria.inplace_modulus_rupture_sqrt_fc') assert i_tension < i_rupture def test_prestress_loss_percent_symbol_field_still_present_and_editable(): """Finding 4: the '%' unit on `ps_loss_humidity_pct` must stay attached to a live editor after the group's fields moved from one wide column into a 2x2 layout -- only the layout changed, not the field itself.""" blocks, _problems = pc.page({'ps_loss_method': 'PCI'}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') f = fields['ps_loss_humidity_pct'] assert f['symbol'] == '%' assert f['editor'] == 'num' def test_sway_condition_field_is_a_choice_editor(): """Finding 6: keep the label, make sure the choice editor is used.""" blocks, _problems = pc.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') f = fields['design_criteria.analysis_beta_d'] assert f['label'] == u'Sway condition for 2nd-order analysis' assert f['symbol'] == u'βd' assert f['editor'] == 'choice' assert f['choices'] == ['Non-Sway', 'Sway']