Files
willtools-demo/WillTools.extension/tests/test_configdump.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

394 lines
15 KiB
Python

# -*- 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 !!'