25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
275 lines
10 KiB
Python
275 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for `vibe.specsave` -- where an extracted spec is copied to, and what
|
|
the button says is still unresolved in it.
|
|
|
|
The module is deliberately Revit-free (paths and text in, paths and text out),
|
|
so all of it is reachable from host CPython through the conftest trick. What
|
|
is worth testing here is not that it formats a string: it is the three rules
|
|
that would be invisible if they broke --
|
|
|
|
* the file name box has to MEAN something for a single design, and the
|
|
folder has to be what is used for several;
|
|
* a `file:///` href with forward slashes, because a bare Windows path is a
|
|
dead link in pyRevit's output panel and a dead link looks exactly like a
|
|
live one;
|
|
* a blank field must NOT force a fold open, or every section opens on every
|
|
press and the fold's contract is worth nothing.
|
|
"""
|
|
import io
|
|
import os
|
|
import re
|
|
|
|
from vibe import specsave
|
|
|
|
|
|
# ----------------------------------------------------------------- file names
|
|
|
|
def test_safe_name_keeps_a_design_number_readable():
|
|
assert specsave.safe_name('PNL1.0') == 'PNL1.0'
|
|
assert specsave.safe_name('WP 12-A') == 'WP 12-A'
|
|
|
|
|
|
def test_safe_name_replaces_what_a_path_cannot_hold():
|
|
assert specsave.safe_name('PNL/1') == 'PNL_1'
|
|
assert specsave.safe_name('') == 'unnamed'
|
|
|
|
|
|
def test_default_name_is_the_design_when_there_is_one():
|
|
assert specsave.default_name(['PNL1']) == 'PNL1.yaml'
|
|
|
|
|
|
def test_default_name_counts_when_there_are_several():
|
|
assert specsave.default_name(['PNL1', 'PNL2', 'PNL3']) == \
|
|
'design-extraction-3-designs.yaml'
|
|
|
|
|
|
def test_default_name_survives_an_empty_list():
|
|
assert specsave.default_name([]) == 'design-extraction.yaml'
|
|
|
|
|
|
# --------------------------------------------------------------- where it goes
|
|
|
|
def test_one_design_is_written_to_exactly_the_path_that_was_typed():
|
|
# The name box has to mean something. A dialog whose file name is quietly
|
|
# ignored is the trap CLAUDE.md names about the config file.
|
|
got = specsave.targets(r'C:\jobs\my-reading.yaml', ['PNL1'])
|
|
assert got == [('PNL1', r'C:\jobs\my-reading.yaml')]
|
|
|
|
|
|
def test_several_designs_take_the_folder_and_keep_their_own_names():
|
|
got = specsave.targets(os.path.join('C:', 'jobs', 'whatever.yaml'),
|
|
['PNL1', 'PNL2'])
|
|
assert [d for d, _p in got] == ['PNL1', 'PNL2']
|
|
assert [os.path.basename(p) for _d, p in got] == ['PNL1.yaml', 'PNL2.yaml']
|
|
assert set(os.path.dirname(p) for _d, p in got) == \
|
|
{os.path.join('C:', 'jobs')}
|
|
|
|
|
|
def test_several_designs_keep_the_extension_that_was_chosen():
|
|
got = specsave.targets(os.path.join('C:', 'jobs', 'x.txt'),
|
|
['PNL1', 'PNL2'])
|
|
assert [os.path.basename(p) for _d, p in got] == ['PNL1.txt', 'PNL2.txt']
|
|
|
|
|
|
def test_no_path_means_no_targets():
|
|
assert specsave.targets('', ['PNL1']) == []
|
|
assert specsave.targets('C:/x.yaml', []) == []
|
|
|
|
|
|
# --------------------------------------------------------------------- links
|
|
|
|
def test_href_is_a_url_not_a_windows_path():
|
|
# This is the bug the fix exists for: backslashes and no scheme render as
|
|
# a link and navigate nowhere.
|
|
assert specsave.href(r'C:\jobs\PNL1.yaml') == 'file:///C:/jobs/PNL1.yaml'
|
|
|
|
|
|
def test_same_file_spots_a_save_onto_the_original(tmp_path):
|
|
# The dialog opens on the folder the specs already live in, so pressing
|
|
# Save without moving is an ordinary accident. It must not be reported as
|
|
# a copy to somewhere else.
|
|
one = str(tmp_path / 'PNL1.yaml')
|
|
assert specsave.same_file(one, one)
|
|
# The same file reached the other way round the separator.
|
|
assert specsave.same_file(one, one.replace('\\', '/'))
|
|
assert not specsave.same_file(one, str(tmp_path / 'other' / 'PNL1.yaml'))
|
|
|
|
|
|
def test_no_path_is_never_the_same_file():
|
|
assert not specsave.same_file('', '')
|
|
assert not specsave.same_file('C:/x.yaml', '')
|
|
|
|
|
|
def test_link_shows_the_real_path_and_hrefs_the_url():
|
|
got = specsave.link(r'C:\jobs\PNL1.yaml')
|
|
assert got == r'[C:\jobs\PNL1.yaml](file:///C:/jobs/PNL1.yaml)'
|
|
# The visible half keeps the backslashes -- that is what gets copied into
|
|
# Explorer.
|
|
assert got.split('](')[0] == r'[C:\jobs\PNL1.yaml'
|
|
|
|
|
|
# ------------------------------------------------------- remembering a folder
|
|
|
|
def test_recall_is_empty_when_nothing_was_ever_saved(tmp_path):
|
|
assert specsave.recall_dir(str(tmp_path)) == ''
|
|
|
|
|
|
def test_remember_then_recall(tmp_path):
|
|
folder = tmp_path / 'somewhere'
|
|
folder.mkdir()
|
|
specsave.remember_dir(str(tmp_path), str(folder))
|
|
assert specsave.recall_dir(str(tmp_path)) == str(folder)
|
|
|
|
|
|
def test_a_pointer_to_a_folder_that_is_gone_is_dropped(tmp_path):
|
|
# Seeding a dialog with a path that no longer exists is how a file lands
|
|
# somewhere nobody meant.
|
|
pointer = tmp_path / specsave.POINTER
|
|
with io.open(str(pointer), 'w', encoding='utf-8') as fh:
|
|
fh.write(u'Z:\\not\\here')
|
|
assert specsave.recall_dir(str(tmp_path)) == ''
|
|
|
|
|
|
def test_remembering_never_raises(tmp_path):
|
|
assert specsave.remember_dir(str(tmp_path / 'no-such-dir'), 'C:\\x') == ''
|
|
assert specsave.remember_dir(str(tmp_path), '') == ''
|
|
|
|
|
|
# ---------------------------------------------------------------- unresolved
|
|
|
|
def test_unresolved_entries_read_text_and_reason():
|
|
spec = {'unresolved': [{'text': 'USE MC STANDARD', 'reason': 'no rule'}]}
|
|
assert specsave.unresolved_entries(spec) == [('USE MC STANDARD', 'no rule')]
|
|
|
|
|
|
def test_a_bare_string_entry_still_counts():
|
|
# An older extractor could write a plain string. The one list whose job is
|
|
# not losing lines must not lose a line to its own shape.
|
|
assert specsave.unresolved_entries({'unresolved': ['SOMETHING']}) == \
|
|
[('SOMETHING', '')]
|
|
|
|
|
|
def test_entries_with_no_text_are_dropped():
|
|
spec = {'unresolved': [{'reason': 'why'}, {'text': '', 'reason': 'x'}]}
|
|
assert specsave.unresolved_entries(spec) == []
|
|
|
|
|
|
def test_no_unresolved_section_at_all_is_not_an_error():
|
|
assert specsave.unresolved_entries({}) == []
|
|
assert specsave.unresolved_entries({'unresolved': None}) == []
|
|
|
|
|
|
def test_shared_reason_when_every_note_gives_the_same_one():
|
|
entries = [('a', 'no rule matched'), ('b', 'no rule matched')]
|
|
assert specsave.shared_reason(entries) == 'no rule matched'
|
|
|
|
|
|
def test_no_shared_reason_when_they_differ():
|
|
assert specsave.shared_reason([('a', 'one'), ('b', 'two')]) == ''
|
|
assert specsave.shared_reason([]) == ''
|
|
|
|
|
|
# --------------------------------------------------------------- blank fields
|
|
|
|
def _filled():
|
|
return {
|
|
'concrete': {'fc_psi': 6000, 'fci_psi': 3500, 'unit_weight_pcf': 150},
|
|
'thickness_in': 8,
|
|
'envelope': {'length': {'max_ft': 49.25}, 'width': {'max_ft': 10}},
|
|
'reinforcing': {
|
|
'wwf': {'mesh': '6x6 W2.9'},
|
|
'strand_groups': [{'qty': 14, 'dia_in': 0.5}],
|
|
'strand_layout': {'columns_in': [4, 16, 28]},
|
|
},
|
|
}
|
|
|
|
|
|
def test_a_full_spec_has_nothing_blank():
|
|
assert specsave.blank_fields(_filled()) == []
|
|
|
|
|
|
def test_a_missing_number_is_named_by_its_dotted_path():
|
|
spec = _filled()
|
|
spec['concrete']['fci_psi'] = None
|
|
assert specsave.blank_fields(spec) == ['concrete.fci_psi']
|
|
|
|
|
|
def test_an_empty_list_counts_as_blank():
|
|
spec = _filled()
|
|
spec['reinforcing']['strand_layout']['columns_in'] = []
|
|
assert specsave.blank_fields(spec) == \
|
|
['reinforcing.strand_layout.columns_in']
|
|
|
|
|
|
def test_an_empty_spec_names_every_field_rather_than_erroring():
|
|
assert len(specsave.blank_fields({})) == \
|
|
len(specsave.KEY_FIELDS) + len(specsave.LIST_FIELDS)
|
|
|
|
|
|
# ------------------------------------------------------------ what opens a fold
|
|
|
|
def test_nothing_wrong_means_no_problem_sentence():
|
|
assert specsave.problem_sentence([], []) is None
|
|
|
|
|
|
def test_unresolved_notes_open_the_section():
|
|
sentence = specsave.problem_sentence([('a', 'x')], [])
|
|
assert sentence and '1 note' in sentence
|
|
|
|
|
|
def test_validation_problems_open_the_section():
|
|
sentence = specsave.problem_sentence([], ["missing concrete.fc_psi"])
|
|
assert sentence and 'validation problem' in sentence
|
|
|
|
|
|
def test_blank_fields_alone_do_not_open_the_section():
|
|
# THE REGRESSION THIS GUARDS. The mechanical reader leaves the strand
|
|
# matrix empty on every spec it writes, so opening on a blank field would
|
|
# open every section on every press, and "if nothing is open, nothing
|
|
# needs you" would stop being true.
|
|
assert specsave.problem_sentence([], []) is None
|
|
|
|
|
|
def test_summary_line_says_both_numbers():
|
|
assert specsave.summary_line([('a', '')], ['x', 'y']) == \
|
|
'1 unresolved note, 2 fields blank'
|
|
|
|
|
|
# ------------------------------------------- nobody branches on the old value
|
|
#
|
|
# Specs on disk carry `source.extractor = 'claude-code'`, written before the
|
|
# owner's ruling named the model. The AI path stamps 'opus' from now on. Every
|
|
# reader has to tolerate both, and the way to keep that true is to have no
|
|
# reader that compares the field to a literal at all.
|
|
|
|
# Two shapes: a comparison on a line that names the field, and a comparison
|
|
# against one of the values only this field ever holds. 'regex' is NOT in the
|
|
# second list on purpose -- `ticketqc` puts the unrelated string 'regex' in
|
|
# `spec_source` (whether a spec FILE existed at all, not who wrote it), and a
|
|
# guard that fires on somebody else's field is a guard that gets deleted.
|
|
_EXT_COMPARE = re.compile(r"extractor[^\n]{0,60}[=!]=|"
|
|
r"[=!]=[^\n]{0,60}extractor|"
|
|
r"[=!]=\s*u?['\"](claude-code|claude|opus)['\"]")
|
|
|
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def test_no_code_branches_on_an_extractor_literal():
|
|
offenders = []
|
|
for folder, dirs, files in os.walk(_ROOT):
|
|
dirs[:] = [d for d in dirs if d not in ('__pycache__', 'tests')]
|
|
for name in files:
|
|
if not name.endswith('.py'):
|
|
continue
|
|
path = os.path.join(folder, name)
|
|
with io.open(path, 'r', encoding='utf-8') as fh:
|
|
for n, line in enumerate(fh, 1):
|
|
if line.lstrip().startswith('#'):
|
|
continue
|
|
if _EXT_COMPARE.search(line):
|
|
offenders.append('{0}:{1}'.format(path, n))
|
|
assert offenders == [], (
|
|
'these compare an extractor value to a literal; a spec written by an '
|
|
'older build says "claude-code" and would take the other branch: '
|
|
+ ', '.join(offenders))
|