WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
# -*- 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 !!'
|
||||
Reference in New Issue
Block a user