# -*- coding: utf-8 -*- """Tests for `vibe.pa_structural.page()` -- the Structural Model section. Runs on plain host CPython via `conftest.py`'s `vibe` stub; no Revit involved and none was running for this test run. 2026-09-04 REWRITE, matching the owner's headless-render ruling the same day (see `pa_structural.py`'s module docstring for the ruling text): restraints, concentrated loads and wind zones are now EDITABLE TABLES, not free-text fields; every ASCE wind input is gone, replaced by a `wind_mode` choice and a manually-typed `wind_zone` table; Seismic / Temperature / Soil are collapsed `section` blocks; and the old four-panel `source: 'elevation'` drawing is gone, replaced by the `pa_sketch_freebody` free-body block. Checks the disciplines both this rewrite and the standing docstring commit to: * only block kinds from the fixed vocabulary are ever emitted; * every field carries a plain-English label and a hint; * every optional free-text block still states a DIFFERENT sentence for "key missing" vs. "key present but blank" ("could not judge" vs. "judged, found nothing" must never collapse); * no wind/seismic/temperature field ever carries a computed default; * a key `panelanalysisform` does not (yet) define degrades to a `problems` entry instead of raising or inventing a value; * `values` is never mutated; * every ASCE-only wind key is gone from this page; * `restraint` / `conc_load` / `wind_zone` each render as an `editable_table` with the exact contract column shape; * Seismic / Temperature Loads / Soil Loads are collapsed sections; * no inline (outside a collapsed section) non-quiet sentence runs longer than one sentence. """ import re from vibe import pa_structural _BLOCK_KINDS = set(['heading', 'columns', 'field', 'table', 'editable_table', 'section', 'kv', 'sentence', 'drawing', 'rule', 'space']) _EDITOR_KINDS = set(['text', 'num', 'check', 'choice']) _ASCE_GONE_KEYS = ('wind_speed_vult', 'wind_risk_category', 'wind_exposure', 'wind_enclosure', 'wind_asce_version', 'wind_cc_chapter', 'wind_mwfrs_chapter', 'wind_pressure_psf', 'wind_suction_psf') def _walk(blocks): """Yield every block, recursing into 'columns' and 'section'.""" for b in blocks: yield b if b.get('kind') == 'columns': for col in b['columns']: for sub in _walk(col): yield sub elif b.get('kind') == 'section': for sub in _walk(b['blocks']): yield sub def _all_field_keys(blocks): return [b['key'] for b in _walk(blocks) if b.get('kind') == 'field'] def _sentences(blocks): return [b['text'] for b in _walk(blocks) if b.get('kind') == 'sentence'] def _editable_tables(blocks): return dict((b['table'], b) for b in _walk(blocks) if b.get('kind') == 'editable_table') def _sections(blocks): return dict((b['title'], b) for b in _walk(blocks) if b.get('kind') == 'section') # --------------------------------------------------------------- structure def test_only_known_block_kinds(): blocks, _problems = pa_structural.page({}) for b in _walk(blocks): assert b['kind'] in _BLOCK_KINDS, 'unknown block kind {0!r}'.format( b.get('kind')) def test_top_level_heading_is_structural_model(): blocks, _problems = pa_structural.page({}) assert blocks[0] == {'kind': 'heading', 'text': 'Structural Model', 'level': 1} def test_never_raises_on_empty_values(): blocks, problems = pa_structural.page({}) assert isinstance(blocks, list) and blocks assert isinstance(problems, list) def test_never_raises_on_none_values(): blocks, problems = pa_structural.page(None) assert isinstance(blocks, list) and blocks def test_never_raises_on_garbage_values(): # Wrong types, weird keys, malformed row-table keys -- page() must # degrade, not crash. blocks, problems = pa_structural.page({ 'conc_loads': None, 'wind_speed_vult': object(), 'supports_ft': '', 'restraint.1.present': 12345, 'restraint.x.present': 'yes', 'restraint..present': 'yes', 'wind_zone.2.present': object(), 3: 'not even a string key', }) assert isinstance(blocks, list) and blocks def test_values_dict_not_mutated(): values = {'releases': '', 'restraint.1.present': 'yes', 'restraint.1.elevation_ft': '0'} before = dict(values) pa_structural.page(values) assert values == before # ------------------------------------------------------------------ fields def test_every_field_has_plain_english_label_and_hint(): blocks, _problems = pa_structural.page({}) fields = [b for b in _walk(blocks) if b.get('kind') == 'field'] assert fields, 'expected at least one field block' for f in fields: assert f['label'], 'field {0!r} has no label'.format(f['key']) # A label that is just the raw key (no spaces, all lower/snake) is # not plain English -- every field must carry a written-out label. assert f['label'] != f['key'] assert f['hint'], 'field {0!r} has no hint'.format(f['key']) assert f['editor'] in _EDITOR_KINDS assert isinstance(f['required'], bool) assert 'choices' in f if f['choices'] is not None: assert f['editor'] == 'choice' def test_symbol_is_beside_label_never_instead_of_it(): blocks, _problems = pa_structural.page({}) fields = [b for b in _walk(blocks) if b.get('kind') == 'field'] by_key = dict((f['key'], f) for f in fields) # temp_alpha_t carries a real engineering symbol in panelanalysisform's # fallback -- the label must still be plain English, not the symbol # standing in for it. f = by_key['temp_alpha_t'] assert f['label'].lower() != (f['symbol'] or '').lower() assert 'thermal' in f['label'].lower() or \ 'expansion' in f['label'].lower() def test_required_bearing_table_present(): # Owner ruling, 2026-09-05: `supports_ft` is no longer rendered as its # own `field` -- bearing positions are the `support` editable row # table now (a fallback for an old saved values file, never shown). blocks, _problems = pa_structural.page({}) tables = [b for b in _walk(blocks) if b.get('kind') == 'editable_table'] by_table = dict((t['table'], t) for t in tables) assert 'support' in by_table assert by_table['support']['columns'][0]['col'] == 'x_ft' fields = [b for b in _walk(blocks) if b.get('kind') == 'field'] assert 'supports_ft' not in [f['key'] for f in fields] def test_old_restraint_dropdown_fields_are_gone(): """Owner, 2026-09-04: restraints must be an editable table, not the foundation-connection dropdown + floor-tie checkbox this page used to render instead of one.""" blocks, _problems = pa_structural.page({}) keys = _all_field_keys(blocks) assert 'restraint_foundation_connection' not in keys assert 'restraint_floor_tie' not in keys # --------------------------------------------------------------- wind gate def test_no_asce_wind_fields_remain(): """Owner, 2026-09-04: '前面那些应该在 project-config 里面,从这里移除' -- every ASCE-only wind input is gone from this page.""" blocks, _problems = pa_structural.page({}) keys = set(_all_field_keys(blocks)) for key in _ASCE_GONE_KEYS: assert key not in keys, '{0} should have been removed'.format(key) def test_wind_mode_field_is_a_manual_vs_project_config_choice(): blocks, _problems = pa_structural.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') f = fields['wind_mode'] assert f['editor'] == 'choice' assert f['choices'] == ['Manual', 'From project config'] def test_wind_project_config_not_built_sentence_is_quiet(): blocks, _problems = pa_structural.page({}) sentences = [b for b in _walk(blocks) if b.get('kind') == 'sentence'] hits = [b for b in sentences if 'not built yet' in b['text'].lower() and 'project config' in b['text'].lower()] assert hits, 'expected a quiet sentence about project-config not being built' for b in hits: assert b.get('quiet') is True def test_no_wind_or_seismic_or_temp_field_carries_a_default_value(): """The owner's ruling, 2026-09-02 (and again 2026-09-04 for wind): " nothing computes wind/seismic yet, so every such field is blank -- never a pre-filled pressure or guess -- regardless of what else is in `values`. """ blocks, _problems = pa_structural.page({ 'length_ft': '40', 'width_ft': '8', 'thickness_in': '6', }) fields = [b for b in _walk(blocks) if b.get('kind') == 'field'] for f in fields: if f['key'].startswith('seismic_') or f['key'].startswith('temp_'): assert 'not yet used' in (f['hint'] or '').lower() # --------------------------------------------------------- editable tables def test_restraint_table_shape(): blocks, _problems = pa_structural.page({}) tables = _editable_tables(blocks) t = tables['restraint'] assert t['add_label'] == 'Add restraint' cols = dict((c['col'], c) for c in t['columns']) assert cols['elevation_ft']['editor'] == 'num' assert cols['type']['editor'] == 'choice' assert cols['type']['choices'] == ['Pin', 'Fixed', 'Free', 'Floor tie'] assert cols['gravity']['editor'] == 'check' assert cols['thermal']['editor'] == 'check' assert cols['stiffness_kin']['editor'] == 'num' assert list(cols.keys()) == ['elevation_ft', 'type', 'gravity', 'thermal', 'stiffness_kin'] def test_conc_load_table_shape(): blocks, _problems = pa_structural.page({}) tables = _editable_tables(blocks) t = tables['conc_load'] assert t['add_label'] == 'Add load' cols = dict((c['col'], c) for c in t['columns']) assert cols['label']['editor'] == 'text' assert cols['elevation_ft']['editor'] == 'num' assert cols['offset_in']['editor'] == 'num' assert cols['dead_k']['editor'] == 'num' assert cols['live_k']['editor'] == 'num' assert list(cols.keys()) == ['label', 'elevation_ft', 'offset_in', 'dead_k', 'live_k'] def test_wind_zone_table_shape(): blocks, _problems = pa_structural.page({}) tables = _editable_tables(blocks) t = tables['wind_zone'] assert t['add_label'] == 'Add wind zone' cols = dict((c['col'], c) for c in t['columns']) assert cols['label']['editor'] == 'text' assert cols['from_elev_ft']['editor'] == 'num' assert cols['to_elev_ft']['editor'] == 'num' assert cols['width_ft']['editor'] == 'num' assert cols['pressure_psf']['editor'] == 'num' assert cols['suction_psf']['editor'] == 'num' assert list(cols.keys()) == ['label', 'from_elev_ft', 'to_elev_ft', 'width_ft', 'pressure_psf', 'suction_psf'] def test_editable_tables_are_declarations_not_snapshots(): """The table blocks never carry row data of their own -- they name the table and its columns; the renderer reads live rows straight out of `values` via `row_ids`/`row_key` so an editable cell is a real binding, not a copy the owner correctly rejected as 'locked'.""" blocks, _problems = pa_structural.page({ 'restraint.1.present': 'yes', 'restraint.1.elevation_ft': '0', 'restraint.1.type': 'Pin'}) tables = _editable_tables(blocks) assert 'rows' not in tables['restraint'] # ------------------------------------------------------- free-body drawing def test_freebody_drawing_block_matches_contract_exactly(): """`values={}` has no conc_load/wind_zone rows, so `_freebody_aspect` -- see its own tests below -- lands on the 0.16 baseline, not the old flat 0.22.""" blocks, _problems = pa_structural.page({}) drawings = [b for b in _walk(blocks) if b.get('kind') == 'drawing'] fb = [d for d in drawings if d.get('module') == 'pa_sketch_freebody'] assert len(fb) == 1 d = fb[0] assert d['source'] == 'freebody' assert d['full_width'] is True assert d['aspect'] == 0.16 assert d['caption'] == 'Free body: restraints and loads' # 'support' added 2026-09-05 (the bearing-position row table); 'supports_ft' # kept for a saved panel with no table rows. assert set(d['depends_on']) == set(['restraint', 'support', 'conc_load', 'wind_zone', 'bottom_elevation_ft', 'top_elevation_ft', 'supports_ft']) # ------------------------------------------------------- free-body aspect def _row_values(table, n): return {'{0}.{1}.present'.format(table, n): 'yes', '{0}.{1}.elevation_ft'.format(table, n): str(n)} def test_freebody_aspect_is_016_with_no_load_or_wind_rows(): assert pa_structural._freebody_aspect({}) == 0.16 def test_freebody_aspect_grows_003_per_load_or_wind_row(): values = {} values.update(_row_values('conc_load', 1)) assert abs(pa_structural._freebody_aspect(values) - 0.19) < 1e-9 values.update(_row_values('wind_zone', 1)) assert abs(pa_structural._freebody_aspect(values) - 0.22) < 1e-9 def test_freebody_aspect_caps_at_028(): values = {} for n in range(1, 4): values.update(_row_values('conc_load', n)) for n in range(1, 4): values.update(_row_values('wind_zone', n)) # 6 rows * 0.03 + 0.16 = 0.34, clamped down to 0.28 assert pa_structural._freebody_aspect(values) == 0.28 def test_freebody_drawing_aspect_matches_helper_with_rows_present(): values = {} values.update(_row_values('conc_load', 1)) blocks, _problems = pa_structural.page(values) fb = [b for b in _walk(blocks) if b.get('kind') == 'drawing' and b.get('module') == 'pa_sketch_freebody'][0] assert fb['aspect'] == pa_structural._freebody_aspect(values) def test_old_four_panel_elevation_drawing_is_gone(): """Owner, 2026-09-04: the free body replaces the old drawing that crammed front/side/section-properties/cross-section onto one canvas here.""" blocks, _problems = pa_structural.page({}) drawings = [b for b in _walk(blocks) if b.get('kind') == 'drawing'] sources = [d.get('source') for d in drawings] assert 'elevation' not in sources captions = [d.get('caption') for d in drawings] assert not any('restraint and support elevations' in (c or '').lower() for c in captions) def test_old_weight_diagram_drawing_is_gone(): """Same-day follow-up ruling: the free body above already draws every point load and the member itself, so the old `weight_diagram` drawing block under Additional Self Weight -- a second, tiny picture repeating a subset of the same data -- is removed entirely, not merely hidden.""" blocks, _problems = pa_structural.page({}) drawings = [b for b in _walk(blocks) if b.get('kind') == 'drawing'] sources = [d.get('source') for d in drawings] assert 'weight_diagram' not in sources # exactly one drawing block on the whole page: the free body. assert len(drawings) == 1 # --------------------------------------------------------- collapsed sections def test_seismic_temperature_soil_are_collapsed_sections(): """Owner, 2026-09-04: '"seismic, temp, soil 这几个板块先折叠掉'.""" blocks, _problems = pa_structural.page({}) sections = _sections(blocks) assert set(sections.keys()) == set([ 'Seismic', 'Temperature Loads', 'Soil Loads', 'Additional Self Weight', 'Design Strips']) for title, sec in sections.items(): assert sec['collapsed'] is True, '{0} should be collapsed'.format(title) assert isinstance(sec['blocks'], list) and sec['blocks'] def test_additional_self_weight_and_design_strips_are_collapsed_sections(): """Same-day follow-up: neither block is built yet (nothing in the solve reads either), so both fold the same way Seismic/Temperature/ Soil already do -- not left open and prominent in the inline flow.""" blocks, _problems = pa_structural.page({'addl_sw_magnitude_plf': ''}) top_level_titles = set( b.get('text') for b in blocks if b.get('kind') == 'heading' and b.get('level') == 2) assert 'Additional Self Weight' not in top_level_titles assert 'Design Strips' not in top_level_titles sections = _sections(blocks) # `design_strips_method` renders unconditionally; the additional-self- # weight fields only once the key is present (see # `test_additional_self_weight_fields_appear_once_a_magnitude_is_typed` # for the 'missing' vs. 'blank' vs. 'filled' distinction) -- blank here # so both sections' fields are checkable in one call. asw_keys = _all_field_keys(sections['Additional Self Weight']['blocks']) ds_keys = _all_field_keys(sections['Design Strips']['blocks']) assert 'addl_sw_start_in' in asw_keys assert 'design_strips_method' in ds_keys def test_additional_self_weight_fields_appear_once_a_magnitude_is_typed(): blocks, _problems = pa_structural.page( {'addl_sw_magnitude_plf': '25'}) sections = _sections(blocks) asw_keys = _all_field_keys(sections['Additional Self Weight']['blocks']) assert 'addl_sw_start_in' in asw_keys assert 'addl_sw_end_in' in asw_keys def test_soil_section_keeps_the_exterior_interior_columns_layout(): blocks, _problems = pa_structural.page({}) sections = _sections(blocks) soil = sections['Soil Loads'] columns_blocks = [b for b in soil['blocks'] if b.get('kind') == 'columns'] assert columns_blocks assert len(columns_blocks[0]['columns']) == 2 def test_seismic_and_temperature_fields_live_inside_their_sections(): blocks, _problems = pa_structural.page({}) top_level_keys = _all_field_keys( [b for b in blocks if b.get('kind') != 'section']) assert 'seismic_cs' not in top_level_keys assert 'temp_ext_f' not in top_level_keys sections = _sections(blocks) seismic_keys = _all_field_keys(sections['Seismic']['blocks']) temp_keys = _all_field_keys(sections['Temperature Loads']['blocks']) assert 'seismic_cs' in seismic_keys assert 'temp_ext_f' in temp_keys # --------------------------------------------------------------- quiet prose def test_no_long_inline_non_quiet_helper_prose(): """Grey helper prose must be quiet (routed to the collapsed Notes panel); a functional one-sentence status line is fine either way. Only the INLINE flow is checked here -- content already folded inside a collapsed `section` is not inline by definition.""" blocks, _problems = pa_structural.page({}) inline = [b for b in blocks if b.get('kind') != 'section'] for b in _walk(inline): if b.get('kind') != 'sentence': continue if b.get('quiet'): continue text = b['text'] # crude one-sentence check: at most one terminal period, and it is # the last character (never "sentence one. sentence two.") assert text.count('.') <= 1, \ 'non-quiet inline sentence looks like more than one ' \ 'sentence: {0!r}'.format(text) def test_restraints_overview_sentence_is_quiet(): blocks, _problems = pa_structural.page({}) sentences = [b for b in _walk(blocks) if b.get('kind') == 'sentence'] hits = [b for b in sentences if 'tied back' in b['text'].lower()] assert hits assert hits[0].get('quiet') is True # ------------------------------------------------------- empty-case sentences def test_releases_missing_key_says_unknown_not_none(): blocks, problems = pa_structural.page({}) # no 'releases' key at all sentences = _sentences(blocks) assert any('unknown' in s.lower() and 'release' in s.lower() for s in sentences) assert not any(s == 'Design contains no releases.' for s in sentences) assert any('releases' in p for p in problems) def test_releases_blank_key_says_design_contains_no_releases(): blocks, problems = pa_structural.page({'releases': ' '}) sentences = _sentences(blocks) assert 'Design contains no releases.' in sentences def test_releases_filled_key_renders_a_table_not_a_sentence(): blocks, _problems = pa_structural.page({'releases': 'Tie 2, sideways'}) tables = [b for b in _walk(blocks) if b.get('kind') == 'table'] assert any('Release' in t['headers'] for t in tables) def test_exterior_soil_missing_vs_blank_are_different_sentences(): blocks_missing, _ = pa_structural.page({}) blocks_blank, _ = pa_structural.page({'ext_soil_loads': ''}) sm = _sentences(blocks_missing) sb = _sentences(blocks_blank) assert 'Design contains no exterior soil loads.' in sb assert 'Design contains no exterior soil loads.' not in sm assert any('exterior soil loads' in s.lower() and 'unknown' in s.lower() for s in sm) def test_interior_soil_missing_vs_blank_are_different_sentences(): blocks_missing, _ = pa_structural.page({}) blocks_blank, _ = pa_structural.page({'int_soil_loads': ''}) sm = _sentences(blocks_missing) sb = _sentences(blocks_blank) assert 'Design contains no interior soil loads.' in sb assert 'Design contains no interior soil loads.' not in sm def test_design_strips_blank_matches_eriksson_wording(): blocks, _problems = pa_structural.page({'design_strips_rows': ''}) sentences = _sentences(blocks) assert ('No design strips are defined; the entire cross section will ' 'be used.') in sentences def test_additional_self_weight_blank_vs_missing(): blocks_missing, _ = pa_structural.page({}) blocks_blank, _ = pa_structural.page({'addl_sw_magnitude_plf': ''}) sm = _sentences(blocks_missing) sb = _sentences(blocks_blank) assert any('additional self weight' in s.lower() and 'unknown' in s.lower() for s in sm) assert any("panel's own concrete weight" in s.lower() for s in sb) assert sm != sb def test_seismic_apply_to_is_a_choice_field(): blocks, _problems = pa_structural.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') f = fields['seismic_apply_to'] assert f['editor'] == 'choice' assert f['choices'] == ['None', 'Out-of-plane', 'In-plane', 'Both'] def test_load_opt_offset_from_choices(): blocks, _problems = pa_structural.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') f = fields['load_opt_offset_from'] assert f['editor'] == 'choice' assert f['choices'] == ['Deflection Section', 'Top in Form', 'Bottom in Form'] def test_load_opt_pattern_live_is_a_checkbox(): blocks, _problems = pa_structural.page({}) fields = dict((b['key'], b) for b in _walk(blocks) if b.get('kind') == 'field') assert fields['load_opt_pattern_live']['editor'] == 'check' # ---------------------------------------------------- missing-key degrading def test_missing_panelanalysisform_keys_become_problems_not_exceptions(): blocks, problems = pa_structural.page({}) assert problems, 'expected at least one problems sentence for the new keys' for p in problems: assert isinstance(p, str) or isinstance(p, type(u'')) assert 'Traceback' not in p def test_new_keys_still_render_with_a_usable_label(): blocks, problems = pa_structural.page({}) fields = [b for b in _walk(blocks) if b.get('kind') == 'field'] by_key = dict((f['key'], f) for f in fields) # 'temp_ext_f' has no entry in panelanalysisform today -- must still # render with a real label and hint rather than being skipped. f = by_key['temp_ext_f'] assert f['label'] == 'Exterior temperature' assert f['hint'] # ------------------------------------------------------------- row_ids/row_key def test_row_key_shape(): assert pa_structural.row_key('restraint', '1', 'elevation_ft') == \ 'restraint.1.elevation_ft' assert pa_structural.row_key('conc_load', 'u3', 'dead_k') == \ 'conc_load.u3.dead_k' def test_row_ids_reads_present_yes_and_sorts_numerically(): values = { 'restraint.2.present': 'yes', 'restraint.2.elevation_ft': '10', 'restraint.10.present': 'yes', 'restraint.10.elevation_ft': '30', 'restraint.1.present': 'yes', 'restraint.1.elevation_ft': '0', 'restraint.5.present': 'no', # not 'yes' -- excluded 'restraint.5.elevation_ft': '99', 'conc_load.1.present': 'yes', # a different table -- excluded } assert pa_structural.row_ids('restraint', values) == [1, 2, 10] def test_row_ids_ignores_malformed_keys(): values = {'restraint.x.present': 'yes', 'restraint..present': 'yes', 'restraint.1.2.present': 'yes', 'restraint.3.present': 'yes'} assert pa_structural.row_ids('restraint', values) == [3] def test_row_ids_empty_on_empty_values(): assert pa_structural.row_ids('restraint', {}) == [] assert pa_structural.row_ids('restraint', None) == [] # ------------------------------------------------------------------ leaks def test_no_client_or_job_strings_leak_into_the_page(): """Nothing this page emits may carry an identity. The obvious version of this test spells the forbidden strings out -- and a leak detector that names the secret IS the leak, which is exactly why `.scrub-terms` is gitignored ("so the list of names is not itself the leak"). So this asks about SHAPE instead: an emitted string that looks like a job number, a run date, a source-file name or a licensee line is refused whatever it says. The named-terms half of this belongs to `tools/scrub.py`, which reads that private list. This is the half that still works on a machine where the list is absent. """ blocks, problems = pa_structural.page({}) text = repr(blocks) + repr(problems) assert not re.search(r'\d{5,}', text) # a job number assert not re.search(r'\d{2}/\d{2}/\d{4}', text) # a run date assert not re.search(r'\.(ewf|rvt)', text, re.I) # a source file assert 'licensee' not in text.lower() # ---------------------------------------------------------------- issue #22 def test_free_body_is_the_last_block_on_the_page(): # Owner, 2026-09-07 (issue #22, item 6b): "把 free body 的图挪到页面的 # 最底下" -- after every collapsed section, so everything it draws is # entered above it. Top-level order, not the recursive walk: the # drawing must be the final entry of `page()`'s own list. blocks, _problems = pa_structural.page({}) last = blocks[-1] assert last['kind'] == 'drawing' and last['source'] == 'freebody' drawings = [b for b in _walk(blocks) if b.get('kind') == 'drawing'] assert len(drawings) == 1 # and the collapsed sections all sit before it idx_last_section = max(i for i, b in enumerate(blocks) if b.get('kind') == 'section') assert idx_last_section < len(blocks) - 1 def test_support_and_restraint_tables_carry_an_empty_state(): # Issue #22, items 5/11: an empty `support` table must say why it is # empty -- the read's own `supports_basis` / `restraints_basis` # sentence when there is one, else a could-not-judge sentence -- never # a bare "Add support" button. The `support` one also says the solve # needs at least two rows, so the gap shows before Run, not after. blocks, _problems = pa_structural.page({}) tables = _editable_tables(blocks) sup = tables['support']['empty_state'] assert sup['basis_keys'][0] == 'supports' assert 'at least two' in sup['text'] assert 'could-not-judge' in sup['text'] res = tables['restraint']['empty_state'] assert res['basis_keys'][0] == 'restraints' # tables with no model read (conc_load, wind_zone) carry none: the # page's generic "nothing entered yet" line covers them assert 'empty_state' not in tables['conc_load']