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.
402 lines
14 KiB
Python
402 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for vibe.yamlkit: the hand-rolled YAML subset emit()/parse() round
|
|
trips through, and nothing else.
|
|
|
|
No Revit dependency at all -- see conftest.py -- so every function here is
|
|
in scope.
|
|
"""
|
|
from vibe import yamlkit
|
|
|
|
|
|
# ------------------------------------------------------------------ scalar
|
|
|
|
def test_scalar_plain_string_is_unquoted():
|
|
assert yamlkit.scalar('left_rail') == 'left_rail'
|
|
|
|
|
|
def test_scalar_quotes_a_reserved_word():
|
|
assert yamlkit.scalar('true') == "'true'"
|
|
assert yamlkit.scalar('null') == "'null'"
|
|
|
|
|
|
def test_scalar_quotes_a_number_looking_string_so_it_reads_back_as_text():
|
|
# A part number like '4.10' must not come back as the float 4.1.
|
|
assert yamlkit.scalar('4.10') == "'4.10'"
|
|
|
|
|
|
def test_scalar_quotes_a_string_with_a_leading_or_trailing_space():
|
|
assert yamlkit.scalar(' x') == "' x'"
|
|
assert yamlkit.scalar('x ') == "'x '"
|
|
|
|
|
|
def test_scalar_quotes_a_string_ending_in_colon():
|
|
assert yamlkit.scalar('note:') == "'note:'"
|
|
|
|
|
|
def test_scalar_escapes_an_embedded_quote_by_doubling_it():
|
|
assert yamlkit.scalar("it's") == "'it''s'"
|
|
|
|
|
|
def test_scalar_empty_string_is_quoted():
|
|
assert yamlkit.scalar('') == "''"
|
|
|
|
|
|
def test_scalar_none_true_false():
|
|
assert yamlkit.scalar(None) == 'null'
|
|
assert yamlkit.scalar(True) == 'true'
|
|
assert yamlkit.scalar(False) == 'false'
|
|
|
|
|
|
def test_scalar_float_drops_a_trailing_dot_zero():
|
|
assert yamlkit.scalar(270.0) == '270'
|
|
assert yamlkit.scalar(3.5) == '3.5'
|
|
|
|
|
|
def test_scalar_int_is_bare():
|
|
assert yamlkit.scalar(28920) == '28920'
|
|
|
|
|
|
# --------------------------------------------------------------- emit/parse
|
|
|
|
def test_round_trip_flat_dict():
|
|
doc = {'a': 1, 'b': 'text', 'c': True, 'd': None, 'e': 2.5}
|
|
assert yamlkit.parse('\n'.join(yamlkit.emit(doc))) == doc
|
|
|
|
|
|
def test_round_trip_nested_dict():
|
|
doc = {'outer': {'inner': {'x': 1, 'y': [1, 2, 3]}}}
|
|
assert yamlkit.parse('\n'.join(yamlkit.emit(doc))) == doc
|
|
|
|
|
|
def test_round_trip_flat_list_stays_on_one_line():
|
|
doc = {'columns_in': [3.5, 16.5, 40.5]}
|
|
lines = yamlkit.emit(doc)
|
|
assert lines == ['columns_in: [3.5, 16.5, 40.5]']
|
|
assert yamlkit.parse('\n'.join(lines)) == doc
|
|
|
|
|
|
def test_round_trip_list_of_dicts():
|
|
doc = {'rows': [{'from_bottom_in': 1.75, 'strands': ['X', 'X', '.']},
|
|
{'from_bottom_in': 6.25, 'strands': ['X', 'X', 'X']}]}
|
|
text = '\n'.join(yamlkit.emit(doc))
|
|
assert yamlkit.parse(text) == doc
|
|
|
|
|
|
def test_round_trip_empty_dict_and_list():
|
|
doc = {'confidence': {}, 'notes': []}
|
|
text = '\n'.join(yamlkit.emit(doc))
|
|
assert yamlkit.parse(text) == doc
|
|
|
|
|
|
def test_round_trip_string_containing_a_colon_stays_a_scalar():
|
|
# A note that happens to contain ':' must not be misread as a nested key.
|
|
doc = {'text': 'STRAND PATTERN: see detail'}
|
|
text = '\n'.join(yamlkit.emit(doc))
|
|
assert yamlkit.parse(text) == doc
|
|
|
|
|
|
def test_parse_ignores_comments_and_blank_lines():
|
|
text = '# a header comment\n\nkey: 1\n# inline-ish comment on its own line\nother: 2\n'
|
|
assert yamlkit.parse(text) == {'key': 1, 'other': 2}
|
|
|
|
|
|
def test_parse_ignores_a_comment_indented_above_a_section():
|
|
text = ('reinforcing:\n'
|
|
' # a note about this field\n'
|
|
' mild_grade_ksi: 60\n')
|
|
assert yamlkit.parse(text) == {'reinforcing': {'mild_grade_ksi': 60}}
|
|
|
|
|
|
def test_deterministic_key_order_is_alphabetical():
|
|
doc = {'z': 1, 'a': 2, 'm': 3}
|
|
lines = yamlkit.emit(doc)
|
|
assert [ln.split(':')[0] for ln in lines] == ['a', 'm', 'z']
|
|
|
|
|
|
def test_is_flat_respects_the_width_budget():
|
|
short = [1, 2, 3]
|
|
long_list = list(range(40))
|
|
assert yamlkit.is_flat(short) is True
|
|
assert yamlkit.is_flat(long_list) is False
|
|
|
|
|
|
def test_is_flat_false_for_a_list_containing_a_dict():
|
|
assert yamlkit.is_flat([{'a': 1}]) is False
|
|
|
|
|
|
def test_loads_is_an_alias_for_parse():
|
|
text = 'a: 1\n'
|
|
assert yamlkit.loads(text) == yamlkit.parse(text)
|
|
|
|
|
|
# ------------------------------------------------ hand-written truncation
|
|
#
|
|
# EVERY TEST BELOW ASSERTS ON WHAT COMES AFTER THE AWKWARD CONSTRUCT, and
|
|
# that is the whole point of them. `parse()` did not raise on any of these
|
|
# shapes and did not return a wrong value for them either -- it returned a
|
|
# tree that was simply MISSING everything below, and a test that checked
|
|
# only the construct itself would have passed against the broken parser.
|
|
#
|
|
# The one that cost a real press: a job knowledge file wrapped
|
|
# `parts: [P76, P89,` onto a second line two thirds of the way down, and
|
|
# the three sections under it vanished. The config editor compares its tree
|
|
# against the Revit-side reader's key by key, so a WHOLE-FILE failure was
|
|
# reported as a disagreement about whichever innocent key was being
|
|
# written. 16 top-level keys in the file, 13 in the tree.
|
|
|
|
WRAPPED = (
|
|
'first: 1\n'
|
|
'middle:\n'
|
|
' parts: [P76, P89, P92,\n'
|
|
' SP29, SP30,\n'
|
|
" '0010001']\n"
|
|
' after_the_list: yes it is read\n'
|
|
'last:\n'
|
|
' deep: 2\n'
|
|
)
|
|
|
|
|
|
def test_a_wrapped_flow_list_does_not_end_the_document():
|
|
doc = yamlkit.parse(WRAPPED)
|
|
# The keys AFTER the wrapped list are the assertion that matters.
|
|
assert sorted(doc) == ['first', 'last', 'middle']
|
|
assert doc['last'] == {'deep': 2}
|
|
assert doc['middle']['after_the_list'] == 'yes it is read'
|
|
|
|
|
|
def test_a_wrapped_flow_list_reads_back_as_the_whole_list():
|
|
doc = yamlkit.parse(WRAPPED)
|
|
assert doc['middle']['parts'] == ['P76', 'P89', 'P92', 'SP29', 'SP30',
|
|
'0010001']
|
|
|
|
|
|
def test_a_wrapped_flow_list_keeps_a_quoted_bracket_or_comma_intact():
|
|
doc = yamlkit.parse("a: ['P76, spare', '[not a list]',\n"
|
|
" 'it''s']\nb: 2\n")
|
|
assert doc['a'] == ['P76, spare', '[not a list]', "it's"]
|
|
assert doc['b'] == 2
|
|
|
|
|
|
def test_a_hash_inside_a_quoted_flow_value_is_not_a_comment():
|
|
doc = yamlkit.parse("bars: ['#4 BAR (TYP.)',\n"
|
|
" '#9 (80ksi)']\nafter: ok\n")
|
|
assert doc['bars'] == ['#4 BAR (TYP.)', '#9 (80ksi)']
|
|
assert doc['after'] == 'ok'
|
|
|
|
|
|
def test_a_comment_inside_a_wrapped_flow_list_is_dropped():
|
|
doc = yamlkit.parse('a: [1, 2, # why these two\n'
|
|
' 3]\nb: 4\n')
|
|
assert doc == {'a': [1, 2, 3], 'b': 4}
|
|
|
|
|
|
def test_a_double_quote_that_is_an_inch_mark_does_not_open_a_string():
|
|
# knowledge/catalogue.yaml really writes this. Treating the inch marks
|
|
# as quotes hides the closing bracket and the file ends there.
|
|
doc = yamlkit.parse('dims: [3", 3", 3"]\nafter: ok\n')
|
|
assert doc == {'dims': ['3"', '3"', '3"'], 'after': 'ok'}
|
|
|
|
|
|
def test_an_unterminated_flow_list_raises_instead_of_returning_what_it_had():
|
|
# THE DECISION, pinned: running off the end of the file and returning
|
|
# the part it managed to read is the exact failure this whole path
|
|
# exists to end.
|
|
import pytest
|
|
with pytest.raises(yamlkit.YamlKitError):
|
|
yamlkit.parse('a: 1\nb: [1, 2,\nc: 3\n')
|
|
|
|
|
|
def test_the_unterminated_message_names_the_line_to_go_and_look_at():
|
|
import pytest
|
|
with pytest.raises(yamlkit.YamlKitError) as caught:
|
|
yamlkit.parse('a: 1\nb: 2\nparts: [P76,\n')
|
|
assert 'line 3' in str(caught.value)
|
|
|
|
|
|
def test_yamlkit_error_is_a_value_error():
|
|
# A caller already guarding a parse with `except ValueError` keeps
|
|
# working; one that wants to name this failure still can.
|
|
assert issubclass(yamlkit.YamlKitError, ValueError)
|
|
|
|
|
|
def test_a_block_sequence_at_its_keys_own_indent_does_not_end_the_document():
|
|
doc = yamlkit.parse('one:\n'
|
|
' notes:\n'
|
|
' - the first note\n'
|
|
' - the second\n'
|
|
' after: read\n'
|
|
'two: 2\n')
|
|
assert doc['one']['notes'] == ['the first note', 'the second']
|
|
assert doc['one']['after'] == 'read'
|
|
assert doc['two'] == 2
|
|
|
|
|
|
def test_a_trailing_comment_on_a_key_line_does_not_end_the_document():
|
|
doc = yamlkit.parse('prefixes: # dead sheets, skipped outright\n'
|
|
' - zvoid\n'
|
|
' - zzvoid\n'
|
|
'after: read\n')
|
|
assert doc == {'prefixes': ['zvoid', 'zzvoid'], 'after': 'read'}
|
|
|
|
|
|
def test_a_trailing_comment_is_stripped_from_a_value():
|
|
assert yamlkit.parse('a: 1 # one\nb: two # and two\n') == {
|
|
'a': 1, 'b': 'two'}
|
|
|
|
|
|
def test_a_hash_that_is_part_of_a_value_is_not_a_comment():
|
|
# A '#' with no whitespace in front of it is text, which is what YAML
|
|
# says and what a pattern like this needs (checked against PyYAML).
|
|
assert yamlkit.parse('sheet_re: ^WP#\\d+\nafter: ok\n') == {
|
|
'sheet_re': '^WP#\\d+', 'after': 'ok'}
|
|
assert yamlkit.parse("note: '# 4 bar'\n") == {'note': '# 4 bar'}
|
|
|
|
|
|
def test_a_hash_after_a_space_is_a_comment_even_when_it_looks_like_a_value():
|
|
# `size: #4` really is a null with a comment in YAML -- agreeing with
|
|
# the other reader matters more than guessing the writer's intent.
|
|
assert yamlkit.parse('size: #4\nafter: ok\n') == {'size': None,
|
|
'after': 'ok'}
|
|
|
|
|
|
def test_a_wrapped_plain_scalar_does_not_end_the_document():
|
|
doc = yamlkit.parse('one:\n'
|
|
' - a note that ran long and wrapped\n'
|
|
' onto a second indented line\n'
|
|
' - a shorter one\n'
|
|
'two: 2\n')
|
|
assert doc['one'] == ['a note that ran long and wrapped onto a second '
|
|
'indented line', 'a shorter one']
|
|
assert doc['two'] == 2
|
|
|
|
|
|
def test_a_wrapped_scalar_does_not_swallow_a_list_items_second_field():
|
|
# The guard that keeps `_fold_plain` from eating structure: the second
|
|
# field of a `- key: value` item is a mapping line, not a continuation.
|
|
doc = yamlkit.parse('rows:\n'
|
|
' - qty: 14\n'
|
|
' dia_in: 0.5\n'
|
|
' - qty: 6\n'
|
|
' dia_in: 0.5\n'
|
|
'after: read\n')
|
|
assert doc['rows'] == [{'qty': 14, 'dia_in': 0.5},
|
|
{'qty': 6, 'dia_in': 0.5}]
|
|
assert doc['after'] == 'read'
|
|
|
|
|
|
def test_a_block_scalars_body_keeps_its_hashes_and_indentation():
|
|
doc = yamlkit.parse('note: >-\n'
|
|
' a sentence with a # in it\n'
|
|
' and a second line\n'
|
|
'after: read\n')
|
|
assert doc['note'] == 'a sentence with a # in it and a second line'
|
|
assert doc['after'] == 'read'
|
|
|
|
|
|
def test_a_folded_block_scalar_clips_one_trailing_newline_by_default():
|
|
# `>` clips (one newline), `>-` strips. Reading them the same way is a
|
|
# disagreement with every real YAML reader on the value of the key.
|
|
assert yamlkit.parse('note: >\n one line\n')['note'] == 'one line\n'
|
|
assert yamlkit.parse('note: >-\n one line\n')['note'] == 'one line'
|
|
|
|
|
|
# ------------------------------------------------------ flow mappings
|
|
|
|
def test_a_flow_mapping_is_a_dict_and_not_a_string():
|
|
doc = yamlkit.parse("bar_notes: {SR356: OPPOSING PAIRS}\nafter: read\n")
|
|
assert doc['bar_notes'] == {'SR356': 'OPPOSING PAIRS'}
|
|
assert doc['after'] == 'read'
|
|
|
|
|
|
def test_a_flow_mapping_reads_a_quoted_key():
|
|
doc = yamlkit.parse("part_notes: {'0010001': (TYP.), SR7: 2}\n")
|
|
assert doc['part_notes'] == {'0010001': '(TYP.)', 'SR7': 2}
|
|
|
|
|
|
def test_a_wrapped_flow_mapping_does_not_end_the_document():
|
|
doc = yamlkit.parse('m: {a: 1,\n'
|
|
' b: 2}\n'
|
|
'after: read\n')
|
|
assert doc == {'m': {'a': 1, 'b': 2}, 'after': 'read'}
|
|
|
|
|
|
def test_a_nested_flow_list_is_not_split_on_its_inner_commas():
|
|
doc = yamlkit.parse('a: [[1, 2], [3, 4]]\nb: 5\n')
|
|
assert doc == {'a': [[1, 2], [3, 4]], 'b': 5}
|
|
|
|
|
|
def test_an_empty_flow_collection_still_reads_as_empty():
|
|
assert yamlkit.parse('a: []\nb: {}\nc: 1\n') == {'a': [], 'b': {}, 'c': 1}
|
|
|
|
|
|
def test_a_double_quoted_scalar_is_unquoted():
|
|
# emit() never writes one; a person editing the file by hand does, and
|
|
# a value the two readers disagree about is a value the config editor
|
|
# refuses to write beside.
|
|
assert yamlkit.parse('note: "quoted by hand"\n') == {
|
|
'note': 'quoted by hand'}
|
|
|
|
|
|
def test_a_realistic_hand_edited_file_reads_to_its_last_key():
|
|
# The shape of the file that caused this: several sections, an awkward
|
|
# construct partway down, and the sections that follow it are the
|
|
# thing under test.
|
|
text = (
|
|
'job:\n'
|
|
' name: sample_job\n'
|
|
'details:\n'
|
|
' 05/X4:\n'
|
|
' bar_notes: {SR356: OPPOSING PAIRS}\n'
|
|
' parts: [P76, P89, P92, P98,\n'
|
|
' SP29, SP30, SP32,\n'
|
|
" '0010001']\n"
|
|
' notes:\n'
|
|
' - a note at the key\'s own indent that also wraps\n'
|
|
' onto a second line\n'
|
|
' dims: [3", 3"] # inch marks, not quotes\n'
|
|
'takeoff:\n'
|
|
' enabled: true\n'
|
|
'snapshot:\n'
|
|
' detached_dir: somewhere\n'
|
|
'catalogue:\n'
|
|
' checked: true\n'
|
|
)
|
|
doc = yamlkit.parse(text)
|
|
assert sorted(doc) == ['catalogue', 'details', 'job', 'snapshot',
|
|
'takeoff']
|
|
# The innocent key the old failure was reported against.
|
|
assert doc['snapshot']['detached_dir'] == 'somewhere'
|
|
detail = doc['details']['05/X4']
|
|
assert detail['parts'][-1] == '0010001'
|
|
assert detail['bar_notes'] == {'SR356': 'OPPOSING PAIRS'}
|
|
assert detail['dims'] == ['3"', '3"']
|
|
assert detail['notes'] == ["a note at the key's own indent that also "
|
|
'wraps onto a second line']
|
|
|
|
|
|
def test_round_trip_matches_a_realistic_spec_shaped_document():
|
|
doc = {
|
|
'schema': 1,
|
|
'design_number': 'WP1',
|
|
'source': {'extractor': 'regex', 'reviewed_by': None},
|
|
'concrete': {'fc_psi': 6000, 'fci_psi': 3500},
|
|
'reinforcing': {
|
|
'strand_groups': [
|
|
{'qty': 14, 'dia_in': 0.5, 'grade_ksi': 270, 'it_lb': 28920,
|
|
'role': None, 'note': None},
|
|
],
|
|
'strand_layout': {
|
|
'datum': 'left_rail',
|
|
'columns_in': [3.5, 16.5, 40.5, 59.5, 87.5, 109.5, 132.5],
|
|
'rows': [
|
|
{'from_bottom_in': 1.75, 'strands': ['X'] * 7},
|
|
{'from_bottom_in': 6.25, 'strands': ['X'] * 7},
|
|
],
|
|
'total_in': 143.0,
|
|
},
|
|
},
|
|
'unresolved': [{'text': 'STRAND PATTERN', 'reason': 'no rule matched'}],
|
|
}
|
|
text = '\n'.join(yamlkit.emit(doc))
|
|
assert yamlkit.parse(text) == doc
|