52d94303bc
ci / test (push) Has been cancelled
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.
394 lines
15 KiB
Python
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 !!'
|