# -*- coding: utf-8 -*- """Host tests for `vibe.pa_extents` -- the Panel Analysis form's "Concrete Extents" section layout: panel geometry, the openings table, the drawings row, and Section Properties. Runs on plain CPython via `tests/conftest.py`'s `vibe` stub -- this module imports no Revit and no `panelsketch` (it only NAMES a `panelsketch` entry point in a `drawing` block's `source`; the renderer, not this module, calls it). What this suite checks, per the task brief: only known block `kind`s come out; every `field` block carries a plain-English label and a hint; `openings_rows` being absent degrades to a `problems` entry rather than a crash; Section Properties never prints a row it did not derive; and `values` is never mutated. """ from vibe import panelanalysisform as paf # noqa: F401 (registers the stub) from vibe import pa_extents as E ALLOWED_KINDS = {'heading', 'columns', 'field', 'kv', 'sentence', 'table', 'editable_table', 'drawing', 'rule', 'space'} def _walk(blocks): """Yield every block in `blocks`, recursing into `columns`.""" for b in blocks: yield b if b.get('kind') == 'columns': for col in b['columns']: for sub in _walk(col): yield sub def _all_field_blocks(blocks): return [b for b in _walk(blocks) if b.get('kind') == 'field'] def _all_drawing_blocks(blocks): return [b for b in _walk(blocks) if b.get('kind') == 'drawing'] # --------------------------------------------------------------- vocabulary def test_empty_values_only_known_kinds_no_crash(): blocks, problems = E.page({}) assert isinstance(blocks, list) assert isinstance(problems, list) for b in _walk(blocks): assert b['kind'] in ALLOWED_KINDS, 'unknown kind: {0!r}'.format(b) def test_none_values_does_not_raise(): # page() must never raise -- an absent values dict is a valid call. blocks, problems = E.page(None) assert isinstance(blocks, list) assert isinstance(problems, list) def test_filled_values_only_known_kinds_no_crash(): values = { 'length_ft': '107', 'width_ft': '9', 'thickness_in': '8', 'section_type': 'Solid', 'top_in_form': 'Interior', 'top_elevation_ft': '107', 'bottom_elevation_ft': '0', 'left_miter': 'no', 'right_miter': 'yes', 'openings_rows': [ {'type': 'opening', 'pos_along': 8.0 / 12.0, 'pos_along_from': 'start', 'length': 6.875, 'pos_cross': 2 + 11.5 / 12.0, 'pos_cross_from': 'left', 'width': 3 + 4.5 / 12.0, 'pos_vert': None, 'height': None, 'label': ''}, ], } blocks, problems = E.page(values) for b in _walk(blocks): assert b['kind'] in ALLOWED_KINDS # ------------------------------------------------------------ field quality def test_every_field_has_a_plain_label_and_a_hint(): blocks, _problems = E.page({}) fields = _all_field_blocks(blocks) assert fields, 'expected at least one field block' for f in fields: assert f['label'] and f['label'].strip(), \ 'field {0!r} has no plain-English label'.format(f['key']) assert f['hint'] and f['hint'].strip(), \ 'field {0!r} has no hint'.format(f['key']) assert f['editor'] in ('text', 'num', 'check', 'choice') assert isinstance(f['required'], bool) def test_known_paf_fields_use_panelanalysisform_label_verbatim(): # Checked LIVE against panelanalysisform.FIELDS rather than a hardcoded # key list, because a sibling agent is restructuring that module WHILE # this suite runs -- `left_miter` / `right_miter` landed there mid-task # (see the module docstring's "read defensively" discipline). Any key # `_paf_field` finds must be reflected verbatim; any key it does not # find is covered by the MISSING test below instead. blocks, _problems = E.page({}) fields = {f['key']: f for f in _all_field_blocks(blocks)} candidates = ('length_ft', 'width_ft', 'thickness_in', 'section_type', 'top_in_form', 'left_miter', 'right_miter', 'top_elevation_ft', 'bottom_elevation_ft') for key in candidates: if key not in fields: continue if E._paf_field(key) is None: continue # MISSING from panelanalysisform -- not this test's job assert fields[key]['label'] == paf.label_of(key), \ '{0} EXISTS in panelanalysisform but label was not taken from it' \ .format(key) assert fields[key]['hint'] == (paf.help_of(key) or fields[key]['hint']) def test_previously_missing_elevation_keys_now_declared_and_rendered(): # 2026-09-05: `top_elevation_ft` / `bottom_elevation_ft` are real # `panelanalysisform.FIELDS` entries (they used to be MISSING, which is # what the deleted predecessor of this test pinned) -- a value typed # into either is saved, and `_field` no longer prints a "not a field # yet" limitation note for them. blocks, problems = E.page({}) fields = {f['key']: f for f in _all_field_blocks(blocks)} joined = ' '.join(problems) for key in ('top_elevation_ft', 'bottom_elevation_ft'): assert E._paf_field(key) is not None, \ '{0} should be declared in panelanalysisform.FIELDS'.format(key) assert key in fields assert repr(key) not in joined, \ '{0} is declared but still named as missing in problems' \ .format(key) def test_formed_step_controls_stay_declared_but_are_not_rendered(): # Tombstone (issue #22, item 4, 2026-09-07). `top_in_form_check` / # `top_in_form_width_in` / `top_in_form_height_in` are Eriksson's "top # in form" STEP; nothing reads them (not `pa_sketch_extents`, not the # weight/centroid summary, not `panellift`), and the owner's complaint # was a checkbox plus two boxes that accepted input and changed # nothing. They stay DECLARED (a saved value survives) and are NOT # rendered until something consumes them -- and the hint says so. blocks, problems = E.page({'top_in_form_check': 'yes', 'top_in_form_width_in': '2', 'top_in_form_height_in': '3'}) fields = {f['key']: f for f in _all_field_blocks(blocks)} for key in ('top_in_form_check', 'top_in_form_width_in', 'top_in_form_height_in'): assert E._paf_field(key) is not None, \ '{0} must stay declared in panelanalysisform.FIELDS'.format(key) assert key not in fields, \ '{0} is rendered again but still consumed by nothing'.format(key) assert 'not shown on the page' in paf.help_of(key).lower() assert repr(key) not in ' '.join(problems) def test_face_up_field_is_visible_only_for_a_sandwich_section(): # Owner, 2026-09-07 (issue #22, item 3): "section type = solid 的时候, # which face is up the casting bed 不应该存在". The block carries the # `visible_when` contract the page honours live; the field itself is # still emitted (its value is kept) -- hiding is the page's job. blocks, _problems = E.page({'section_type': 'Solid'}) fields = {f['key']: f for f in _all_field_blocks(blocks)} assert fields['top_in_form']['visible_when'] == { 'key': 'section_type', 'in': ['Sandwich']} assert 'visible_when' not in fields['length_ft'] def test_length_ft_is_required_and_matches_paf(): blocks, _problems = E.page({}) fields = {f['key']: f for f in _all_field_blocks(blocks)} assert fields['length_ft']['required'] is True assert fields['width_ft']['required'] is True assert fields['thickness_in']['required'] is True # ---------------------------------------------------------------- miters def test_miter_blank_prints_no_sentence(): blocks, _problems = E.page({}) sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence'] assert not any('miter not included' in s for s in sentences) def test_miter_explicit_no_prints_a_sentence_not_a_blank(): blocks, _problems = E.page({'left_miter': 'no'}) sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence'] assert any('Left miter not included' in s for s in sentences) def test_miter_explicit_yes_prints_no_sentence(): blocks, _problems = E.page({'right_miter': 'yes'}) sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence'] assert not any('Right miter not included' in s for s in sentences) def test_miter_sentences_are_quiet_2026_09_04(): # Owner ruling, 2026-09-04: the miter sentences stay `sentence` blocks # (already rendered grey/italic by `script.py`'s `_render_sentence`) but # now carry `'quiet': True`, distinguishing a footnote beside a field # from the openings summary sentence -- the other `sentence` block this # page emits, deliberately left un-quiet. blocks, _problems = E.page({'left_miter': 'no', 'right_miter': 'no'}) quiet_texts = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence' and b.get('quiet') is True] assert any('Left miter not included' in s for s in quiet_texts) assert any('Right miter not included' in s for s in quiet_texts) summary = [b for b in _walk(blocks) if b['kind'] == 'sentence' and 'openings' in b['text'].lower()] assert summary and not any(b.get('quiet') for b in summary) def test_miters_are_choice_editors_not_checkboxes(): """2026-09-04: a plain CheckBox can only write 'yes' or '' (see `script.py`'s `current_values`), so it could never produce the explicit 'no' `_miter_sentence` looks for -- the miter fields are an EDITABLE Yes/No dropdown instead, so blank/Yes/No all round-trip as real text.""" blocks, _problems = E.page({}) fields = {f['key']: f for f in _all_field_blocks(blocks)} for key in ('left_miter', 'right_miter'): assert fields[key]['editor'] == 'choice' assert fields[key]['choices'] == ['Yes', 'No'] # -------------------------------------------------------------- openings # # Owner ruling, 2026-09-05: the Openings/Reveals/Solid Zones table is now an # EDITABLE ROW TABLE (`kind: 'editable_table'`, `table: 'opening'`) -- always # present, regardless of what `values['openings_rows']` (the legacy read-only # source) carries, since an editable table always has somewhere to add a # first row. The tests this replaces pinned the old read-only grouped-table # behaviour (a `problems` entry when `openings_rows` was absent, a `table` # block with Eriksson's own 7-column layout) -- deleted rather than kept # alongside, since there is no successor invariant for either. def _opening_table_block(blocks): return next(b for b in _walk(blocks) if b.get('kind') == 'editable_table' and b.get('table') == 'opening') def test_opening_table_always_present_regardless_of_legacy_openings_rows(): for values in ({}, {'openings': ''}, {'openings_rows': []}, {'openings_rows': [{'type': 'Opening'}]}): blocks, _problems = E.page(values) block = _opening_table_block(blocks) assert block['columns'][0]['col'] == 'type' assert set(c['col'] for c in block['columns']) == set( ['type', 'x_ft', 'length_ft', 'y_ft', 'width_ft', 'depth_in']) def test_opening_table_type_column_offers_the_three_kinds(): blocks, _problems = E.page({}) block = _opening_table_block(blocks) type_col = next(c for c in block['columns'] if c['col'] == 'type') assert type_col['editor'] == 'choice' assert type_col['choices'] == ['Opening', 'Reveal', 'Solid zone'] def test_no_read_only_openings_table_kind_left_on_the_page(): # The OLD grouped read-only 'table' kind is gone from this section -- # only the new editable_table remains for openings. blocks, _problems = E.page({'openings_rows': [{'type': 'Opening'}]}) assert not any(b.get('kind') == 'table' for b in _walk(blocks)) # ------------------------------------------------------------ section props # # The owner ruled, 2026-09-03, that Concrete Extents drops the Section # Properties parameter dump (Area/Width/Height/Cx/Cy/Izz/Iyy/Izy/Sb/St) # entirely -- no kv block, ever, regardless of what inputs are given. The # three tests that used to live here (`test_section_properties_never_ # invents_a_derived_row`, `test_section_properties_values_are_correct`, # `test_section_properties_missing_inputs_never_prints_a_kv_block`) pinned # that removed feature -- one of them even asserted a kv block MUST appear # once width+thickness are given, which is now false unconditionally -- and # are deleted rather than rewritten: there is no successor invariant to carry # forward, because the page has no Section Properties concept left to test. # `test_drawings_are_the_stacked_full_width_pa_sketch_extents_pair` below # already covers "no kv block, ever" for the new page. # -------------------------------------------------------------- drawings # # 2026-09-04: the outline/section pair is no longer a `columns`-wrapped, # fixed-pixel pair naming `panelsketch.extents_outline` / `extents_section` # directly. The owner ruled the outline drawn bigger, the section moved # BELOW it (not beside it), both `full_width`, and a draggable cut line on # the outline that the section redraws around -- see `pa_extents.py`'s "WHY # THE TWO DRAWINGS ARE NOW STACKED" docstring note. `test_drawings_use_only_ # named_panelsketch_entry_points` (the old invariant, pinned to `source in # {'extents_outline', 'extents_section'}` plus integer `w`/`h`) is deleted # rather than rewritten -- there is no `w`/`h` any more, and `source` now # names an entry point in `pa_sketch_extents`, not `panelsketch` directly. _DRAWING_ORDER = ['outline', 'section'] def test_drawings_are_the_stacked_full_width_pa_sketch_extents_pair(): blocks, _problems = E.page({}) drawings = _all_drawing_blocks(blocks) assert [d['source'] for d in drawings] == _DRAWING_ORDER for d in drawings: assert d['module'] == 'pa_sketch_extents' assert d['full_width'] is True assert isinstance(d['aspect'], float) assert 0.0 < d['aspect'] <= 1.0 assert d['caption'] and d['caption'].strip() assert isinstance(d['depends_on'], list) and d['depends_on'] assert not any(b['kind'] == 'kv' for b in _walk(blocks)) def test_outline_block_carries_the_cutline_contract_the_section_does_not(): blocks, _problems = E.page({}) drawings = {d['source']: d for d in _all_drawing_blocks(blocks)} assert drawings['outline']['cutline'] == {'key': '_cut_frac', 'default': 0.5} assert 'cutline' not in drawings['section'] def test_outline_and_section_depends_on_match_the_contract_exactly(): # These lists are read by NAME on the renderer's side of the contract -- # see `pa_extents.py`'s `_extents_drawing_blocks()` docstring. Pinned # verbatim so a future edit that drops or renames a key is caught here, # not by a redraw that silently stops happening in the live form. blocks, _problems = E.page({}) drawings = {d['source']: d for d in _all_drawing_blocks(blocks)} # 'opening' (2026-09-05, the ROW TABLE prefix) leads the list now -- # `redrawDependents` treats a `depends_on` entry as an exact match OR a # `key + '.'` PREFIX match, so this one entry watches every cell of # every opening row; 'openings_rows' stays for a saved panel with no # table rows at all. assert drawings['outline']['depends_on'] == [ 'opening', 'openings_rows', 'length_ft', 'width_ft'] assert drawings['section']['depends_on'] == [ '_cut_frac', 'opening', 'openings_rows', 'thickness_in', 'width_ft', 'section_type', 'wythe_top_in', 'insulation_in', 'wythe_bottom_in'] def test_outline_aspect_is_width_over_length_clamped(): # 4 / 40 = 0.1, below the 0.12 floor -- clamped up so a very long, thin # panel does not collapse the outline's canvas to a sliver. Then +0.10, # the fixed allowance `pa_sketch_extents.outline()` needs above/below the # panel rectangle for the title/cut label and the L label/dimension # chain (2026-09-04 live-render finding -- see `_OUTLINE_LABEL_ROWS_ASPECT`). blocks, _problems = E.page({'length_ft': '40', 'width_ft': '4'}) drawings = {d['source']: d for d in _all_drawing_blocks(blocks)} assert abs(drawings['outline']['aspect'] - 0.22) < 1e-9 # 30 / 40 = 0.75, above the 0.5 ceiling -- clamped down so a nearly # square panel does not blow the outline's canvas up to a near-square. # Then +0.10, same allowance. blocks, _problems = E.page({'length_ft': '40', 'width_ft': '30'}) drawings = {d['source']: d for d in _all_drawing_blocks(blocks)} assert abs(drawings['outline']['aspect'] - 0.6) < 1e-9 # 8 / 40 = 0.2, inside the clamp -- passed through, then +0.10. blocks, _problems = E.page({'length_ft': '40', 'width_ft': '8'}) drawings = {d['source']: d for d in _all_drawing_blocks(blocks)} assert abs(drawings['outline']['aspect'] - 0.3) < 1e-9 def test_section_aspect_is_thickness_over_width_clamped(): # thickness_ft = 4/12; width_ft = 20 -> ratio ~0.0167, below the 0.12 # floor -- clamped up. blocks, _problems = E.page( {'length_ft': '24', 'width_ft': '20', 'thickness_in': '4'}) drawings = {d['source']: d for d in _all_drawing_blocks(blocks)} assert abs(drawings['section']['aspect'] - 0.12) < 1e-9 def test_aspects_default_when_geometry_is_missing(): blocks, _problems = E.page({}) drawings = {d['source']: d for d in _all_drawing_blocks(blocks)} # outline carries the +0.10 label-rows allowance on top of the clamped # ratio's own [0.12, 0.5] range -- see `_OUTLINE_LABEL_ROWS_ASPECT`. assert 0.22 <= drawings['outline']['aspect'] <= 0.6 assert 0.12 <= drawings['section']['aspect'] <= 0.35 def test_geometry_then_openings_table_then_outline_then_section(): """2026-09-04 layout ruling: geometry fields, then the openings table, then the outline, then the section, each full width -- pinning the ORDER blocks come out in, not just which ones exist. 2026-09-05: the openings table is now `editable_table`, not `table`.""" blocks, _problems = E.page( {'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'openings_rows': []}) kinds_and_sources = [] for b in blocks: if b['kind'] in ('heading', 'table', 'editable_table'): kinds_and_sources.append(b['kind']) elif b['kind'] == 'columns': kinds_and_sources.append('columns') elif b['kind'] == 'drawing': kinds_and_sources.append('drawing:' + b['source']) # heading (Concrete Extents), heading+columns (Panel Geometry), # heading (Openings...), editable_table, drawing:outline, drawing:section assert kinds_and_sources == [ 'heading', 'heading', 'columns', 'heading', 'editable_table', 'drawing:outline', 'drawing:section'] # ------------------------------------------------------------ no mutation def test_values_dict_is_never_mutated(): values = {'length_ft': '107', 'width_ft': '9', 'thickness_in': '8', 'openings': 'garbage'} before = dict(values) E.page(values) assert values == before def test_page_never_raises_on_garbage_values(): garbage = { 'width_ft': 'not-a-number', 'thickness_in': [1, 2, 3], 'openings_rows': 'not-a-list', 'left_miter': object(), 'openings': None, } blocks, problems = E.page(garbage) assert isinstance(blocks, list) assert isinstance(problems, list) # --------------------------------------------------------------- utilities def test_feet_str_formats_like_eriksson(): assert E._feet_str(8.0 / 12.0) == '8"' assert E._feet_str(0.0) == '0"' assert E._feet_str(20.0 + 8.0 / 12.0) == "20'-8\"" assert E._feet_str(2.0 + 11.5 / 12.0) == "2'-11 1/2\"" def test_dim_or_dash_passes_through_preformatted_strings(): assert E._dim_or_dash("2'-11.5\"") == "2'-11.5\"" assert E._dim_or_dash('') == '-' assert E._dim_or_dash(None) == '-' def test_tri_bool_blank_is_none_not_false(): assert E._tri_bool('') is None assert E._tri_bool(None) is None assert E._tri_bool('no') is False assert E._tri_bool('yes') is True # `test_drawings_are_the_new_extents_outline_and_section_pair` (the # 2026-09-03 side-by-side `columns` pair naming `panelsketch.extents_outline` # / `extents_section` directly) is deleted, not rewritten -- the 2026-09-04 # stacked, full-width, `pa_sketch_extents`-naming pair it pinned is now # covered by `test_drawings_are_the_stacked_full_width_pa_sketch_extents_pair` # and its neighbours above. def test_one_opening_produces_the_single_summary_sentence(): values = {'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'openings_rows': [ {'type': 'Opening', 'pos_along': 10, 'pos_along_from': 'start', 'length': 4, 'pos_cross': 1, 'pos_cross_from': 'left', 'width': 3, 'pos_vert': 2, 'height': 3, 'label': 'DOOR'}, ]} blocks, _problems = E.page(values) sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence'] assert '1 opening, 0 reveals.' in sentences def test_no_openings_produces_the_no_openings_sentence(): blocks, _problems = E.page({'length_ft': '24', 'width_ft': '8', 'thickness_in': '8', 'openings_rows': []}) sentences = [b['text'] for b in _walk(blocks) if b['kind'] == 'sentence'] assert 'No openings or reveals.' in sentences