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