# -*- coding: utf-8 -*- """Tests for vibe.configdump.emit -- the hand-written YAML writer. `configdump.build()` needs a live Revit `doc` (it reads `doc.Title`, walks assemblies through `vibe.ticketqc`, etc.) and is out of scope here. `emit()` itself takes a plain dict/list/scalar tree and returns YAML lines -- no doc, no Revit -- and that is what a project config file is built out of, so it is worth trusting on its own. The real reader on the Revit side is `pyrevit.coreutils.yaml.load_as_dict`, which is not available off a Revit host, and this repo deliberately keeps `panellift` (and by extension this test suite) free of external dependencies -- see panellift/PLAN.md. So `_read_emitted` below is a small, purpose-built reader that understands exactly the grammar `emit()` produces (2-space indented mappings, `- ` block-sequence items, `[a, b]` flow lists, `'quoted'` scalars) and nothing more general than that. It is not a YAML parser; it is `emit()`'s inverse, kept next to the writer it tests. """ import sys import types import pytest from vibe import configdump def _read_emitted(text): """Parse text produced by configdump.emit() back into Python. Deliberately minimal -- see the module docstring. Handles everything emit() can write: nested mappings, empty {}/[], flow lists ('[a, b]'), block sequences of scalars/dicts/lists, and every scalar type _scalar() produces (null/true/false, int, float, quoted and bare strings). """ lines = [line for line in text.split('\n') if line.strip() != ''] tokens = [((len(ln) - len(ln.lstrip(' '))) // 2, ln.strip()) for ln in lines] pos = [0] def parse_scalar(tok): if tok == 'null': return None if tok == 'true': return True if tok == 'false': return False if len(tok) >= 2 and tok[0] == "'" and tok[-1] == "'": return tok[1:-1].replace("''", "'") try: return int(tok) except ValueError: pass try: return float(tok) except ValueError: pass return tok def split_flow(inner): items, current, in_quote = [], '', False for ch in inner: if ch == "'": in_quote = not in_quote current += ch elif ch == ',' and not in_quote: items.append(current) current = '' else: current += ch if current.strip(): items.append(current) return items def parse_flow_list(tok): inner = tok[1:-1].strip() return [parse_scalar(item.strip()) for item in split_flow(inner)] if inner else [] def assign(container, key_raw, rest_raw, child_indent): key = parse_scalar(key_raw.strip()) rest = rest_raw.strip() if rest == '': container[key] = (parse_block(child_indent) if pos[0] < len(tokens) and tokens[pos[0]][0] == child_indent else {}) elif rest == '{}': container[key] = {} elif rest == '[]': container[key] = [] elif rest.startswith('['): container[key] = parse_flow_list(rest) else: container[key] = parse_scalar(rest) def parse_dict(indent): result = {} while pos[0] < len(tokens) and tokens[pos[0]][0] == indent: content = tokens[pos[0]][1] if content.startswith('-'): break key_raw, _, rest_raw = content.partition(':') pos[0] += 1 assign(result, key_raw, rest_raw, indent + 1) return result def parse_list(indent): result = [] while (pos[0] < len(tokens) and tokens[pos[0]][0] == indent and tokens[pos[0]][1].startswith('-')): content = tokens[pos[0]][1] pos[0] += 1 body = content[1:].strip() if body == '': result.append(parse_block(indent + 1)) elif ':' in body: key_raw, _, rest_raw = body.partition(':') item = {} assign(item, key_raw, rest_raw, indent + 1) item.update(parse_dict(indent + 1)) # rest of this item result.append(item) else: result.append(parse_scalar(body)) return result def parse_block(indent): if pos[0] >= len(tokens) or tokens[pos[0]][0] != indent: return {} return (parse_list(indent) if tokens[pos[0]][1].startswith('-') else parse_dict(indent)) return parse_block(0) # A representative slice of a real knowledge tree: nested mappings, a flat # list, a non-flat list of dicts (like handling.stages), empty {} and [], # and every scalar type _scalar() special-cases. SAMPLE = { 'nomenclature': { 'design_number_re': r'^([A-Za-z]+)\s*(\d+)', 'ignore_sheet_prefixes': ['zvoid', 'zzvoid', 'xxx'], 'design_folder': '', }, 'strands': {'area_in2': {'0.375': 0.085, '0.5': 0.153}}, 'handling': { 'rig_mode': 'rolling', 'open_report': True, 'transfer_length_in': None, 'dynamic_factor': 1.5, 'stages': [ {'name': 'stripping', 'pci_case': 'stripping', 'multiplier': None, 'groups': ['face'], 'rig_mode': None}, {'name': 'erection', 'exclude_top_rows': 1, 'groups': ['face', 'edge_top']}, ], 'lifter_groups': {}, }, 'flags': {'on': True, 'off': False, 'unset': None}, 'empty_dict': {}, 'empty_list': [], 'catalogue': {'inserts': {}}, } def test_emit_round_trips_a_representative_knowledge_tree(): text = '\n'.join(configdump.emit(SAMPLE)) + '\n' assert _read_emitted(text) == SAMPLE def test_emit_round_trips_the_real_defaults(): # The actual production tree, not a hand-picked sample -- if a future # DEFAULTS edit adds a shape emit() cannot write back correctly, this is # the test that notices. from vibe import knowledge text = '\n'.join(configdump.emit(knowledge.DEFAULTS)) + '\n' assert _read_emitted(text) == knowledge.DEFAULTS def test_emit_quotes_a_numeric_looking_string_so_it_reads_back_as_a_string(): # A part number like '4.10' must not come back as the float 4.1. text = '\n'.join(configdump.emit({'part': '4.10'})) assert text == "part: '4.10'" assert _read_emitted(text + '\n') == {'part': '4.10'} def test_emit_flat_list_stays_on_one_line(): lines = configdump.emit({'a': ['x', 'y', 'z']}) assert lines == ['a: [x, y, z]'] def test_emit_empty_containers(): lines = configdump.emit({'d': {}, 'l': []}) assert lines == ['d: {}', 'l: []'] def test_the_group_tolerance_note_carries_its_diagram(): """The one setting whose meaning lives in a picture: without the diagram, 'along the piece' reads as across the width, and the number then guards exactly the wrong direction.""" notes = dict(configdump.DECISION_NOTES) assert 'group_tolerance_ft' in notes text = notes['group_tolerance_ft'] assert 'x=14.92' in text # the diagram's axis labels assert 'gap <=' in text # and the two branches it decides assert 'gap >' in text # ================================================== an unreadable layer, in # the dump's own re-read of the files # # `configdump.layer_values()` reads every layer file itself, separately from # `knowledge.load()`, so the dump can say which layer set which value. Its # old shape called `knowledge._merge_file(holder, path)` with NO `layer` # token, so `_note_layer_unreadable` returned immediately and a file that # IS there and will not parse came back as `data = {}` -- indistinguishable # from a layer that is there and genuinely sets nothing. Those are opposite # facts (one is the 255KB-plant-file-silently-voided incident this whole # marker mechanism exists for; the other is nothing going wrong at all), and # the dump is the file a human reads first, so it must not render them the # same way. # # Fixtures below follow `test_knowledge.py`'s `fake_designspec` / # `yaml_on_host` pattern: `vibe.designspec` faked with real paths underneath # it, and the pyRevit YAML reader faked HONESTLY -- strict about duplicate # keys the way YamlDotNet is -- so a "duplicate key" fixture is a real # duplicate key, not a monkeypatched exception standing in for one. @pytest.fixture def fake_designspec(monkeypatch, tmp_path): """`vibe.designspec` with the Revit half faked and the paths REAL. Same shape as `test_knowledge.py`'s fixture of the same name -- kept local rather than imported, per that module's own note on why fixtures for this tree do not live in a shared conftest. """ from vibe import plantpath job_dir = tmp_path / 'job' job_dir.mkdir() plant_root = tmp_path / 'plants' plant_root.mkdir() monkeypatch.setenv(plantpath.PLANT_ENV, str(plant_root)) module = types.ModuleType('vibe.designspec') module.PLANT_ENV = plantpath.PLANT_ENV module.plant_knowledge_path = plantpath.plant_knowledge_path module.knowledge_path = lambda doc: str(job_dir / 'knowledge.yaml') module.active_config_path = lambda doc: module._config module._config = None module._job_dir = job_dir module._plant_root = plant_root monkeypatch.setitem(sys.modules, 'vibe.designspec', module) return module @pytest.fixture def yaml_on_host(monkeypatch): """`pyrevit.coreutils.yaml` for host CPython -- as UNFORGIVING as the real one, including refusing a mapping key stated twice. See `pyrevit_yaml_fake.py` and `test_knowledge.py`'s fixture of the same name for why a kinder double would prove nothing here.""" from pyrevit_yaml_fake import (install_pyrevit_yaml, strings_only_yaml_reader) install_pyrevit_yaml(monkeypatch, strings_only_yaml_reader()) def _write(path, text): path.write_text(text, encoding='utf-8') return str(path) _DUPLICATE = (u'nomenclature:\n' u' design_folder: NORTHGATE PRODUCT\n' u' rules:\n' u' cap_label_mismatch_is_view_specific: true\n' u' something_else: 1\n' u' cap_label_mismatch_is_view_specific: false\n') def test_a_layer_with_a_duplicate_key_is_named_unreadable_with_its_message( fake_designspec, yaml_on_host): """The core fix: `layer_problems()` finds the file, names the layer, and quotes the parser's own message -- not a generic complaint.""" folder = fake_designspec._plant_root / 'northgate' folder.mkdir() path = _write(folder / 'knowledge.yaml', _DUPLICATE) _write(fake_designspec._job_dir / 'knowledge.yaml', u'job:\n plant: northgate\n') problems = configdump.layer_problems(None) assert len(problems) == 1 label, found_path, message = problems[0] assert label == 'plant' assert found_path == path assert 'Duplicate key' in message def test_an_unreadable_layers_data_is_marker_free_but_layer_values_shape_unchanged( fake_designspec, yaml_on_host): """`layer_values()` keeps returning (label, path, exists, data) -- every existing caller unpacks a 4-tuple -- and `data` never leaks the `_plant_unreadable` bookkeeping key as if it were a setting the file made.""" folder = fake_designspec._plant_root / 'northgate' folder.mkdir() _write(folder / 'knowledge.yaml', _DUPLICATE) _write(fake_designspec._job_dir / 'knowledge.yaml', u'job:\n plant: northgate\n') rows = configdump.layer_values(None) plant_rows = [row for row in rows if row[0] == 'plant'] assert len(plant_rows) == 1 label, path, exists, data = plant_rows[0] assert exists is True assert data == {} assert '_plant_unreadable' not in data def test_an_empty_layer_file_is_not_reported_as_a_problem( fake_designspec, yaml_on_host): """The opposite fixture: a plant file that IS there and genuinely sets nothing. `layer_problems()` must stay silent about it -- this is the 'sets nothing' case the dump must still be able to say, unconfused with the unreadable one above.""" folder = fake_designspec._plant_root / 'northgate' folder.mkdir() _write(folder / 'knowledge.yaml', u'') _write(fake_designspec._job_dir / 'knowledge.yaml', u'job:\n plant: northgate\n') assert configdump.layer_problems(None) == [] rows = configdump.layer_values(None) plant_rows = [row for row in rows if row[0] == 'plant'] assert len(plant_rows) == 1 label, path, exists, data = plant_rows[0] assert exists is True assert data == {} def test_layer_problems_is_empty_when_every_layer_reads_clean( fake_designspec, yaml_on_host): _write(fake_designspec._job_dir / 'knowledge.yaml', u'scope:\n declared_by: Will\n') assert configdump.layer_problems(None) == [] def test_unreadable_layer_banner_names_the_path_and_quotes_the_message(): banner = configdump.unreadable_layer_banner( [('plant', 'C:/plants/northgate/knowledge.yaml', 'Duplicate key: cap_label at line 812')]) assert 'plant' in banner assert 'C:/plants/northgate/knowledge.yaml' in banner assert 'Duplicate key: cap_label at line 812' in banner assert 'COULD NOT BE READ' in banner # visually distinct from the ordinary "layer sets nothing" case -- not # merely present, but explicit that this is not that assert 'EXACTLY like a layer that agrees with the defaults' in banner def test_unreadable_layer_banner_is_empty_string_with_no_problems(): assert configdump.unreadable_layer_banner([]) == u'' def test_build_marks_an_unreadable_layer_in_its_own_header_line( fake_designspec, yaml_on_host): """The per-layer summary line `build()` writes into its header, built by `_layer_status_suffix()`, must not read the same for an unreadable layer as for one that is simply absent or that read clean -- all three are different facts. Exercised through the real `_layer_rows()` + `_layer_status_suffix()` pair `build()` itself uses, not a re-implemented copy of the logic.""" folder = fake_designspec._plant_root / 'northgate' folder.mkdir() _write(folder / 'knowledge.yaml', _DUPLICATE) _write(fake_designspec._job_dir / 'knowledge.yaml', u'job:\n plant: northgate\n') rows = configdump._layer_rows(None) plant_row = [row for row in rows if row[0] == 'plant'][0] _label, _path, exists, _data, unreadable = plant_row suffix = configdump._layer_status_suffix(exists, unreadable) assert '!! UNREADABLE !!' in suffix assert '-- not present' not in suffix def test_layer_status_suffix_the_three_facts_never_collapse(): assert configdump._layer_status_suffix(True, None) == u'' assert configdump._layer_status_suffix(False, None) == u' -- not present' assert configdump._layer_status_suffix(True, 'boom') == \ u' !! UNREADABLE !!' # a layer cannot be both absent and unreadable, but if that ever became # possible the loud fact must win, not the quiet one assert configdump._layer_status_suffix(False, 'boom') == \ u' !! UNREADABLE !!'