# -*- coding: utf-8 -*- """Tests for vibe.configmodel -- the Revit-free half of the config editor. Everything the Project Config button does except talking to Revit and drawing a dialog lives in `configmodel`: which settings exist, what layer each value came from, whether a value is legal, and the splice that writes one key into a YAML file without disturbing the thousand lines of comments around it. All of that is testable here, on host CPython, with no model and no licence -- which is the point of keeping it out of the button script. The layer fixtures below are the shape `configdump.layer_values()` returns: (label, path, exists, data), in merge order. """ import pytest from vibe import configmodel def layers(defaults=None, catalogue=None, plant=None, job=None, config=None, missing=()): rows = [('defaults', None, True, defaults or {}), ('catalogue', 'C:/repo/catalogue.yaml', 'catalogue' not in missing, catalogue or {}), ('plant', 'C:/plant/knowledge.yaml', 'plant' not in missing, plant or {}), ('job', 'C:/job/knowledge.yaml', 'job' not in missing, job or {})] if config is not None: rows.append(('config', 'C:/job/project-config.yaml', True, config)) return rows # ------------------------------------------------------------- the inventory def test_every_setting_is_either_editable_or_says_why_not(): for row in configmodel.SETTINGS: assert row['edit'] in ('job', 'ro') if row['edit'] == 'ro': assert row['why'], '{0} is read-only with no reason'.format( row['key']) def test_the_plant_layer_is_not_editable_from_here(): """The ruling: plant edits do not happen in this button, at all.""" keys = [row['key'] for row in configmodel.editable()] for plant_only in ('handling.lifter_groups', 'lifters.capacity_lb', 'lifters.catalogue_alias', 'handling.python_exe'): assert plant_only not in keys def test_parameters_are_shown_even_though_they_are_not_edited(): assert 'parameters' in [name for name, _note in configmodel.SUBTREES] def test_the_lifter_factors_of_safety_are_shown_not_edited(): # The paths are `catalogue.safety_factors.*`, not `rigging.*`. `rigging` # is the shape of panellift's JOB FILE, which liftexport generates -- it # was never a knowledge path, and while these rows named it they pointed # at a key no layer declared. Corrected 2026-08-30 after a knowledge/code # drift audit found it. # # AND BOTH ARE NULL as of 2026-08-31, so the `why` has to say null ON # PURPOSE or the screen reads as an unfilled setting somebody should go # and fill. The factors are per part now, inside the catalogue entries; # this pair is only a fallback for hardware the catalogue does not cover. for key in ('catalogue.safety_factors.tension_fos', 'catalogue.safety_factors.shear_fos'): row = configmodel.setting(key) assert row['edit'] == 'ro' assert 'null on purpose' in row['why'].lower(), row['why'] assert 'fallback' in row['why'].lower(), row['why'] def test_the_factors_of_safety_are_not_offered_under_a_rigging_path(): # Regression: naming a key nothing declares makes the screen report it as # unset forever, and no edit could ever reach it. for gone in ('rigging.tension_fos', 'rigging.shear_fos'): assert configmodel.setting(gone) is None # ------------------------------------------------------------- provenance def test_resolve_reports_the_winning_layer_and_the_whole_chain(): res = configmodel.resolve( layers(defaults={'handling': {'rig_mode': 'rolling'}}, plant={'handling': {'rig_mode': 'static'}}), 'handling.rig_mode') assert res['value'] == 'static' assert res['layer'] == 'plant' assert [label for label, _p, _v in res['chain']] == ['defaults', 'plant'] assert res['overrides'] == [('defaults', 'rolling')] def test_a_layer_that_restates_a_value_is_not_an_override(): res = configmodel.resolve( layers(defaults={'handling': {'finish': 'smooth'}}, job={'handling': {'finish': 'smooth'}}), 'handling.finish') assert res['layer'] == 'job' assert res['overrides'] == [] def test_a_setting_no_layer_mentions_says_so_rather_than_going_blank(): res = configmodel.resolve(layers(), 'handling.rig_mode') assert res['known'] is False assert configmodel.chip(res) == '?' text = '\n'.join(configmodel.chain_lines(res)) assert 'NOT SET IN ANY LAYER' in text def test_a_missing_layer_file_is_named_not_silently_skipped(): res = configmodel.resolve( layers(defaults={'handling': {'finish': 'smooth'}}, missing=('plant',)), 'handling.finish') assert res['missing'] == ['plant'] assert 'file not present' in '\n'.join(configmodel.chain_lines(res)) def test_the_config_layer_wins_and_the_chain_says_the_edit_will_not_reach(): res = configmodel.resolve( layers(plant={'handling': {'rig_mode': 'static'}}, config={'handling': {'rig_mode': 'rolling'}}), 'handling.rig_mode') assert res['layer'] == 'config' text = '\n'.join(configmodel.chain_lines(res)) assert 'will NOT reach this model' in text def test_a_read_only_setting_shows_its_reason_instead_of_an_edit_target(): res = configmodel.resolve(layers(), 'handling.python_exe') text = '\n'.join(configmodel.chain_lines(res)) assert 'NOT EDITABLE HERE' in text assert 'Editing target' not in text def test_fast_lifter_box_is_never_shown_as_a_boolean(): assert configmodel.display_value('handling.fast_lifter_box', True) == 'fast' assert configmodel.display_value('handling.fast_lifter_box', False) == 'precise' assert configmodel.to_stored('handling.fast_lifter_box', 'fast') is True def test_no_value_ever_formats_as_an_empty_string(): for value in (None, '', [], {}, ' '): assert configmodel.format_value(value).strip() # ------------------------------------------------------------------- lists def test_a_list_edit_says_it_replaces_and_names_what_was_replaced(): res = configmodel.resolve( layers(defaults={'families': {'lifter_include': ['LIFT', 'LPA', 'ANCHOR']}}, plant={'families': {'lifter_include': ['LIFT', 'ANCHOR', 'A1', 'X1']}}), 'families.lifter_include') text = '\n'.join(configmodel.replace_warning(res)) assert 'LISTS REPLACE' in text assert 'already REPLACED defaults' in text assert 'LPA' in text assert configmodel.start_from(res) == ['LIFT', 'ANCHOR', 'A1', 'X1'] def test_start_from_is_empty_when_nothing_is_in_force(): assert configmodel.start_from(configmodel.resolve( layers(), 'families.lifter_include')) == [] # -------------------------------------------------------------- entering @pytest.mark.parametrize('text,expected', [ ('3.5', 3.5), ('', None), ('null', None), (' 2 ', 2.0)]) def test_numbers_parse_and_blank_means_null(text, expected): ok, value, _why = configmodel.parse_input('float', text) assert ok and value == expected def test_a_typo_in_a_choice_is_refused_not_guessed(): ok, _value, why = configmodel.parse_input( 'choice', 'static_lift', configmodel.RIG_MODES) assert not ok and 'static_lift' in why def test_a_list_is_entered_as_text_and_split_on_commas_and_lines(): ok, value, _why = configmodel.parse_input('list', 'LIFT, ANCHOR\n A1 ,') assert ok and value == ['LIFT', 'ANCHOR', 'A1'] # ------------------------------------------------------------- validation @pytest.mark.parametrize('key,value,ok', [ ('handling.rig_mode', 'static', True), ('handling.rig_mode', 'static_lift', False), ('handling.group_tolerance_ft', 0.4, True), ('handling.group_tolerance_ft', 0.0, False), ('handling.group_tolerance_ft', None, False), ('handling.sling_angle_deg', 60.0, True), ('handling.sling_angle_deg', 91.0, False), ('handling.prestress_loss_pct', 8.0, True), ('handling.prestress_loss_pct', 100.0, False), ('handling.product_type', 'flat', True), ('handling.product_type', 'flatt', False), ('handling.dunnage.fraction', 0.21, True), ('handling.dunnage.fraction', 0.7, False), ('families.lifter_include', [], False), ('families.lifter_include', ['LIFT'], True), ]) def test_validation(key, value, ok): assert bool(configmodel.validate(key, value)) is (not ok) def test_fraction_mode_needs_a_fraction_that_is_already_set(): problems = configmodel.validate('handling.dunnage.mode', 'fraction', {'handling': {'dunnage': {}}}) assert problems and 'fraction must be set' in problems[0] assert not configmodel.validate( 'handling.dunnage.mode', 'fraction', {'handling': {'dunnage': {'fraction': 0.2}}}) def test_a_stage_with_no_multiplier_needs_a_pci_case(): stages = [{'name': 'stripping', 'multiplier': None, 'strength': 'fci'}] problems = configmodel.validate('handling.stages', stages) assert any('pci_case' in p for p in problems) def test_a_stage_naming_an_unknown_insert_group_is_refused(): know = {'handling': {'lifter_groups': {'face': {}}}} stages = [{'name': 'strip', 'pci_case': 'stripping', 'strength': 'fci', 'groups': ['edge_top']}] problems = configmodel.validate('handling.stages', stages, know) assert any('edge_top' in p for p in problems) def test_no_stage_enabled_is_refused(): stages = [{'name': 'strip', 'pci_case': 'stripping', 'strength': 'fci', 'enabled': False}] assert any('at least one stage enabled' in p for p in configmodel.validate('handling.stages', stages)) def test_a_wide_weight_tolerance_warns_but_does_not_refuse(): assert not configmodel.validate('handling.weight_tolerance', 0.08) assert configmodel.warnings('handling.weight_tolerance', 0.08) # ------------------------------------------------------------- the splice JOB = u"""# A job knowledge file, comments and all. rails: # this job's left rail is not fixed left_rail_is_fixed: false handling: # Where the reports go. Written by a person, kept by the splice. report_dir: X:/somewhere/lifting stages: - name: stripping pci_case: stripping - name: erection pci_case: yard # a trailing comment about nothing """ def _parse(text): from vibe import yamlkit return yamlkit.parse(text) def test_an_existing_scalar_is_replaced_and_the_comments_survive(): out = configmodel.splice(JOB, 'handling.report_dir', 'Y:/new') assert _parse(out)['handling']['report_dir'] == 'Y:/new' assert "this job's left rail is not fixed" in out assert 'Where the reports go' in out assert 'a trailing comment about nothing' in out assert not configmodel.verify(JOB, out, 'handling.report_dir', 'Y:/new') def test_a_new_key_lands_inside_the_parent_that_already_exists(): out = configmodel.splice(JOB, 'handling.rig_mode', 'static') tree = _parse(out) assert tree['handling']['rig_mode'] == 'static' assert tree['handling']['report_dir'] == 'X:/somewhere/lifting' assert len(tree['handling']['stages']) == 2 assert not configmodel.verify(JOB, out, 'handling.rig_mode', 'static') def test_a_new_parent_is_appended_whole(): out = configmodel.splice(JOB, 'families.lifter_include', ['LIFT', 'A1']) assert _parse(out)['families']['lifter_include'] == ['LIFT', 'A1'] assert not configmodel.verify(JOB, out, 'families.lifter_include', ['LIFT', 'A1']) def test_a_new_key_three_deep_creates_the_nesting(): out = configmodel.splice(JOB, 'handling.dunnage.mode', 'fraction') assert _parse(out)['handling']['dunnage']['mode'] == 'fraction' assert not configmodel.verify(JOB, out, 'handling.dunnage.mode', 'fraction') def test_replacing_a_block_replaces_the_whole_block(): stages = [{'name': 'strip', 'pci_case': 'stripping', 'rig_mode': 'static'}] out = configmodel.splice(JOB, 'handling.stages', stages) tree = _parse(out) assert len(tree['handling']['stages']) == 1 assert tree['handling']['stages'][0]['rig_mode'] == 'static' assert tree['handling']['report_dir'] == 'X:/somewhere/lifting' assert not configmodel.verify(JOB, out, 'handling.stages', stages) def test_the_note_is_written_above_the_value_and_not_stacked_on_re_edit(): res = configmodel.resolve( layers(defaults={'handling': {'rig_mode': 'rolling'}}), 'handling.rig_mode') note = configmodel.note_lines('handling.rig_mode', res, 'this yard strips ' 'on fixed slings', when='2026-08-30') once = configmodel.splice(JOB, 'handling.rig_mode', 'static', note) assert ' # override: defaults says rolling' in once assert 'fixed slings' in once twice = configmodel.splice(once, 'handling.rig_mode', 'rolling', note) assert twice.count('# override: defaults says rolling') == 1 def test_verify_catches_a_splice_that_changed_something_else(): good = configmodel.splice(JOB, 'handling.report_dir', 'Y:/new') damaged = good.replace(' - name: erection\n', '') assert configmodel.verify(JOB, damaged, 'handling.report_dir', 'Y:/new') def test_verify_catches_an_edit_that_did_not_take(): assert configmodel.verify(JOB, JOB, 'handling.report_dir', 'Y:/new') def test_the_write_is_refused_when_the_two_readers_disagree(): assert configmodel.reader_disagreement( {'handling': {'rig_mode': 'static'}}, {'handling': {'rig_mode': 'rolling'}}, 'handling.rig_mode') assert configmodel.reader_disagreement( {}, {'handling': {'rig_mode': 'rolling'}}, 'handling.rig_mode') # pyRevit's reader hands back strings; a string 8.0 and a float 8.0 are # the same value written two ways, not a disagreement. assert not configmodel.reader_disagreement( {'handling': {'prestress_loss_pct': '8.0'}}, {'handling': {'prestress_loss_pct': 8.0}}, 'handling.prestress_loss_pct') # --------------------------------------------------- run-time options def test_run_options_default_on_and_have_no_layers(): assert configmodel.default_run_options()['expensive_geometry'] is True assert 'expensive_geometry' not in [row['key'] for row in configmodel.SETTINGS] def test_run_options_round_trip_and_keep_a_key_from_a_newer_build(): text = configmodel.render_run_options({'expensive_geometry': False, 'something_new': 3}) out = configmodel.read_run_options(text) assert out['expensive_geometry'] is False assert out['something_new'] == 3 assert 'NOT knowledge' in text def test_missing_run_options_file_means_every_default(): assert configmodel.read_run_options(None) == \ configmodel.default_run_options() def test_a_replaced_key_is_never_left_as_a_second_copy(): """The bug this caught: nesting level compared against a column count. `report_dir` sits two spaces in at nesting level 1. Comparing those two numbers directly says "not found", and the splice then appends a SECOND `report_dir:` below the first -- a file that reads back correctly through a last-wins parser and is wrong for a person. """ out = configmodel.splice(JOB, 'handling.report_dir', 'Y:/new') assert out.count('report_dir:') == 1 again = configmodel.splice(out, 'handling.report_dir', 'Z:/newer') assert again.count('report_dir:') == 1 def test_run_options_round_trip_through_a_real_folder(tmp_path): root = str(tmp_path) assert configmodel.load_run_options(root) == \ configmodel.default_run_options() configmodel.save_run_options(root, {'expensive_geometry': False}) assert configmodel.load_run_options(root)['expensive_geometry'] is False assert configmodel.run_options_path(root).endswith('run-options.yaml') # ------------------------------------- the position search, as a run option def test_the_position_search_is_a_run_option_and_defaults_off(): """OFF, by the owner's ruling of 2026-08-30 ("searching insert location 还是非常慢"). He re-checks pieces far more often than he asks where the inserts should have gone, and the search is 93% of the run -- 12.6 s of a 13.6 s median piece. The report always says when positions were not computed, so OFF is visible, never silent; switching it on is one toggle in ProjectConfig for the piece that wants the answer. (This inverts the original default. The reasoning both ways is in RUN_OPTIONS' own comment; the owner's usage decided it.) """ row = next((o for o in configmodel.RUN_OPTIONS if o['key'] == 'recommend_positions'), None) assert row is not None assert row['default'] is False def test_the_position_search_option_round_trips(tmp_path): root = str(tmp_path) configmodel.save_run_options(root, {'recommend_positions': False}) assert configmodel.load_run_options(root)['recommend_positions'] is False text = configmodel.render_run_options({'recommend_positions': False}) assert 'recommend_positions' in text def test_the_position_search_note_says_a_batch_never_runs_it(): """The option governs the single-piece check only, and somebody reading the file has to be told that rather than discovering it.""" row = configmodel.run_option('recommend_positions') assert row is not None assert 'BATCH' in row['note'] # ================================================================== # checks.muted -- per-job check muting, typed on the config screen # ================================================================== # # Owner, 2026-08-30: "让我可以注释掉一些check。比如说这个项目里面3.3我就想注释 # 掉。因为所有的panel都不会有这个,一直看着NOT_RUN我也糟心。" # # The feature removes rows from a checklist somebody signs, so every guard # around it is a refusal, not a warning. def test_a_map_is_typed_one_entry_per_line(): ok, value, why = configmodel.parse_input('map', '3.3 = no piece has one') assert ok and why is None assert value == {'3.3': 'no piece has one'} def test_a_map_accepts_a_colon_and_splits_on_the_first_one_only(): """The value is a sentence, and a sentence contains colons.""" ok, value, _why = configmodel.parse_input( 'map', '3.3: reason: this job has no release requirement') assert ok assert value == {'3.3': 'reason: this job has no release requirement'} def test_a_map_reads_several_lines(): ok, value, _why = configmodel.parse_input( 'map', '3.3 = never applies\n5.3 = decided by hand here\n') assert ok assert value == {'3.3': 'never applies', '5.3': 'decided by hand here'} def test_a_map_entry_with_no_reason_is_refused_at_the_keyboard(): ok, _value, why = configmodel.parse_input('map', '3.3') assert not ok assert 'value is REQUIRED' in why def test_a_map_entry_with_an_empty_reason_is_refused(): ok, _value, why = configmodel.parse_input('map', '3.3 =') assert not ok assert 'no value' in why def test_a_blank_map_clears_it(): ok, value, _why = configmodel.parse_input('map', '') assert ok and value == {} def test_muting_with_a_reason_validates(): assert configmodel.validate( 'checks.muted', {'3.3': 'no piece on this job has one'}) == [] def test_muting_with_no_reason_is_refused(): """A row that vanished for a reason nobody wrote down reads as a bug.""" out = configmodel.validate('checks.muted', {'3.3': ''}) assert out assert 'reason is' in out[0] or 'REQUIRED' in out[0] def test_muting_a_number_that_is_not_a_checklist_item_is_refused(): out = configmodel.validate('checks.muted', {'9.9': 'typo'}) assert out assert 'not a ticket-checklist item' in out[0] def test_muting_must_be_a_map_not_a_list(): """A list would not deep-merge across the layers, and carries no reason.""" out = configmodel.validate('checks.muted', ['3.3']) assert out assert 'map' in out[0] def test_no_muting_at_all_is_fine(): assert configmodel.validate('checks.muted', {}) == [] assert configmodel.validate('checks.muted', None) == [] def test_muting_is_editable_on_the_config_screen(): """It is a job decision with a reason, so it is made where the note is written -- not typed into a YAML file with no provenance.""" row = configmodel.setting('checks.muted') assert row is not None assert row['edit'] == 'job' assert row['kind'] == 'map' assert row in configmodel.editable() def test_muting_ships_empty(): """Nothing is muted by default, and empty is the only honest default.""" from vibe import knowledge assert knowledge.DEFAULTS['checks']['muted'] == {} # ------------------------------------------------- maps the layers MERGE # # `knowledge._deep_update` recurses into a dict and assigns anything else, so a # LIST replaces and a MAP merges. `resolve` answers for the first shape. Asking # it about the second is what made the config window show one mute and hide # another: with the plant muting 3.3 and the job muting 5.4 it answers # `{'5.4': ...}`, and a screen built on that answer lets somebody "remove" a # plant mute the merge puts straight back. def _muted(plant=None, job=None, defaults=None): def wrap(value): return {'checks': {'muted': value}} if value is not None else {} return layers(defaults=wrap(defaults), plant=wrap(plant), job=wrap(job)) def test_a_merged_map_keeps_every_layers_entries(): merged = configmodel.merged_map( _muted(plant={'3.3': 'the plant never has one'}, job={'5.4': 'no mesh on this job'}), 'checks.muted') assert sorted(merged) == ['3.3', '5.4'] assert merged['3.3']['layer'] == 'plant' assert merged['5.4']['layer'] == 'job' def test_resolve_alone_would_have_hidden_the_plant_entry(): """The bug, pinned. `resolve` is right about a key that REPLACES and wrong about one that merges, and the difference is invisible until two layers have an opinion.""" given = _muted(plant={'3.3': 'plant'}, job={'5.4': 'job'}) assert configmodel.resolve(given, 'checks.muted')['value'] == \ {'5.4': 'job'} assert sorted(configmodel.merged_map(given, 'checks.muted')) == \ ['3.3', '5.4'] def test_a_later_layer_naming_the_same_entry_beats_it_and_says_what_it_beat(): merged = configmodel.merged_map( _muted(plant={'3.3': 'plant reason'}, job={'3.3': 'job reason'}), 'checks.muted') assert merged['3.3']['value'] == 'job reason' assert merged['3.3']['layer'] == 'job' assert merged['3.3']['overrides'] == [('plant', 'plant reason')] def test_a_layer_that_sets_the_key_to_something_that_is_not_a_map_replaces(): """Mirrors `_deep_update`'s sharp edge rather than smoothing it: a list there really does wipe the map above it, and a merger that pretended otherwise would describe a file the run does not read.""" merged = configmodel.merged_map( _muted(plant={'3.3': 'plant'}, job=['5.4']), 'checks.muted') assert merged == {} def test_layer_map_is_this_files_own_entries_and_not_the_merge(): """The splice baseline. Starting from the MERGE would copy the plant's entries into the job file, where they would stop tracking the plant for good -- silently, which is the only way this project ever gets hurt.""" given = _muted(plant={'3.3': 'plant'}, job={'5.4': 'job'}) assert configmodel.layer_map(given, 'checks.muted', 'job') == \ {'5.4': 'job'} assert configmodel.layer_map(given, 'checks.muted', 'plant') == \ {'3.3': 'plant'} def test_layer_map_is_empty_when_that_layer_never_mentions_the_key(): """Empty is correct, not missing: `splice` creates a key that is not there, and an edit has to start from nothing rather than from someone else's map.""" assert configmodel.layer_map(_muted(plant={'3.3': 'plant'}), 'checks.muted', 'job') == {} def test_the_chip_for_a_merged_map_never_names_one_layer_as_the_owner(): two = configmodel.merged_map(_muted(plant={'3.3': 'a'}, job={'5.4': 'b'}), 'checks.muted') one = configmodel.merged_map(_muted(plant={'3.3': 'a'}), 'checks.muted') assert configmodel.map_chip(two) == '+' assert configmodel.map_layers(two) == ['plant', 'job'] assert configmodel.map_chip(one) == 'P' assert configmodel.map_chip({}) == '?' def test_a_merged_map_formats_with_the_layer_beside_each_entry(): merged = configmodel.merged_map(_muted(plant={'3.3': 'a'}, job={'5.4': 'b'}), 'checks.muted') assert configmodel.format_merged_map(merged) == \ '2 entries: 3.3 (plant), 5.4 (job)' assert configmodel.format_merged_map({}) == '(nothing)' def test_only_the_keys_declared_as_merging_are_treated_that_way(): assert configmodel.merges('checks.muted') is True assert configmodel.merges('families.lifter_include') is False # ------------------------------------------------ the note over a merged map def test_the_note_over_a_merged_map_never_calls_it_an_override(): """A job entry ADDS to what the plant muted; it does not displace it. "# override: plant says 2 keys" would be a sentence that is simply untrue, in the one place a reader has left six months later.""" res = configmodel.resolve(_muted(plant={'3.3': 'a'}), 'checks.muted') body = '\n'.join(configmodel.note_lines('checks.muted', res, 'because', when='2026-08-30')) assert 'override:' not in body assert 'merged:' in body assert 'plant sets entries here too' in body def test_a_merged_map_note_is_not_stacked_on_re_edit(): text = "checks:\n muted:\n '3.3': a\n" res = configmodel.resolve(_muted(job={'3.3': 'a'}), 'checks.muted') note = configmodel.note_lines('checks.muted', res, 'first', when='2026-08-30') once = configmodel.splice(text, 'checks.muted', {'3.3': 'b'}, note) twice = configmodel.splice(once, 'checks.muted', {'3.3': 'c'}, configmodel.note_lines('checks.muted', res, 'second', when='2026-08-30')) assert twice.count('# merged:') == once.count('# merged:') assert 'first' not in twice def test_the_continuation_note_line_is_recognised_and_not_stacked(): """`# (defaults said X)` was not in `_NOTE`, so the upward walk stopped on it and every re-edit of a key more than one layer had touched stacked another block of notes on the last -- leaving the OLDEST, the wrong one, readable at the top.""" given = layers(defaults={'handling': {'rig_mode': 'rolling'}}, plant={'handling': {'rig_mode': 'static'}}) res = configmodel.resolve(given, 'handling.rig_mode') assert res['overrides'] == [('defaults', 'rolling')] text = 'handling:\n rig_mode: static\n' once = configmodel.splice(text, 'handling.rig_mode', 'rolling', configmodel.note_lines('handling.rig_mode', res, 'first', when='2026-08-30')) twice = configmodel.splice(once, 'handling.rig_mode', 'static', configmodel.note_lines('handling.rig_mode', res, 'second', when='2026-08-30')) assert twice.count('(defaults said rolling)') == 1 assert 'first' not in twice def test_a_merged_map_splices_and_verifies_like_any_other_key(): text = "job:\n name: sample_job\n\nchecks:\n muted:\n '3.3': a\n" after = configmodel.splice(text, 'checks.muted', {'3.3': 'a', '5.4': 'b'}, ['# note']) assert configmodel.verify(text, after, 'checks.muted', {'3.3': 'a', '5.4': 'b'}) == [] assert 'sample_job' in after # ------------------------------------- one reader seeing less than the other WRAPPED = """job: name: sample_job details: marks_seen: parts: [P76, P89, SP29, SP30] snapshot: detached_dir: null """ def test_a_reader_that_stops_early_is_reported_as_that_and_not_as_one_key(): """THE DEFECT. The owner set `snapshot.detached_dir` from the window and was told "the two YAML readers disagree about snapshot.detached_dir. This file has to be edited by hand." Both halves were true and neither was useful: nothing is wrong with that key. What had happened is that the editor's reader STOPPED READING at a `[...]` list wrapped over two lines, and `snapshot` -- three sections further down -- was simply not in its tree. Every key past the cut would have produced the same message and sent him to the same wrong place. """ theirs = {'job': {'name': 'sample_job'}, 'details': {'marks_seen': {'parts': ['P76']}}, 'snapshot': {'detached_dir': None}} ours = {'job': {'name': 'sample_job'}} body = '\n'.join(configmodel.reader_coverage(theirs, ours, WRAPPED)) assert 'cannot see all of this file' in body assert 'snapshot' in body assert 'NOTHING HAS BEEN WRITTEN' in body def test_the_coverage_check_names_the_wrapped_list_as_the_likely_cause(): body = '\n'.join(configmodel.reader_coverage( {'snapshot': {'detached_dir': None}}, {}, WRAPPED)) assert 'more than one line' in body assert 'line 6' in body # `parts: [P76, P89,` def test_a_wrapped_flow_list_is_found_and_a_closed_one_is_not(): assert configmodel.wrapped_flow_lines( 'a:\n parts: [P76, P89,\n SP29]\n') == [2] assert configmodel.wrapped_flow_lines('a:\n parts: [P76, P89]\n') == [] # A comment is not a finding: nothing reads it. assert configmodel.wrapped_flow_lines('# parts: [P76,\n') == [] def test_two_readers_that_see_the_same_file_report_nothing(): same = {'snapshot': {'detached_dir': None}, 'job': {'name': 'sample_job'}} assert configmodel.reader_coverage(same, dict(same), WRAPPED) == [] def test_the_coverage_check_says_the_cause_is_unknown_rather_than_guessing(): """It scans for ONE shape. A file that stops being readable for another reason must not be told it has a wrapped list in it.""" body = '\n'.join(configmodel.reader_coverage( {'snapshot': {'detached_dir': None}}, {}, 'job:\n name: x\n')) assert 'has to be found by hand' in body assert 'more than one line' not in body # ============================================================================ # `checks.not_applicable` / `checks.only_applies_to` -- refused at the door # ============================================================================ # # Belt and braces with the resolver, exactly as `checks.muted` is: a # knowledge file may be edited by hand and never come through this screen, so # the run-time side refuses the same shapes and prints the refusal on the # report. Both halves have to exist, because either one alone lets somebody # believe a safety check is off while it is on. def _entry(**over): got = {'applies': False, 'not_applicable_because': 'no piece here has one', 'not_applicable_since': '2026-08-31'} got.update(over) return got def test_a_good_declaration_is_accepted(): assert configmodel.validate( 'checks.not_applicable', {'3.3': {'any': _entry()}}) == [] def test_a_bare_list_is_refused_because_it_cannot_merge(): said = configmodel.validate('checks.not_applicable', ['3.3']) assert said and 'DELETE' in said[0] def test_a_number_that_is_not_a_checklist_item_is_refused(): said = configmodel.validate('checks.not_applicable', {'9.9': {'any': _entry()}}) assert any('not a ticket-checklist item' in s for s in said) def test_a_reasonless_or_dateless_declaration_is_refused(): assert configmodel.validate( 'checks.not_applicable', {'3.3': {'any': _entry(not_applicable_because='')}}) assert configmodel.validate( 'checks.not_applicable', {'3.3': {'any': _entry(not_applicable_since='soon')}}) def test_a_condition_naming_no_declared_panel_type_is_refused(): """It would match nothing, ever, and matching nothing is exactly what it would look like it did.""" know = {'panel_types': {'types': {'insulated': {}, 'solid': {}}}} said = configmodel.validate('checks.not_applicable', {'3.3': {'sandwich': _entry()}}, know) assert any('no declared panel type' in s for s in said) assert configmodel.validate('checks.not_applicable', {'3.3': {'insulated': _entry()}}, know) == [] # `any` is always legal: it is the whole-plant axis, not a type. assert configmodel.validate('checks.not_applicable', {'3.3': {'any': _entry()}}, know) == [] def test_scoping_a_row_to_any_is_refused_at_the_door_too(): said = configmodel.validate('checks.only_applies_to', {'3.3': {'any': { 'scoped': True, 'scoped_because': 'x', 'scoped_since': '2026-08-31'}}}) assert any('not a scope' in s for s in said) def test_a_scope_needs_its_own_reason_and_date(): said = configmodel.validate('checks.only_applies_to', {'3.3': { 'insulated': {'scoped': True}}}) assert said and 'REQUIRED' in said[0] # --------------------------------- which FILE the plant/public layer reads def test_knowledge_source_keys_are_the_two_the_owner_named(): keys = [key for key, _label in configmodel.KNOWLEDGE_SOURCE_KEYS] assert keys == ['knowledge_sources.plant_file', 'knowledge_sources.public_file'] def test_a_missing_file_is_refused_never_written_quietly(tmp_path): """CLAUDE.md's rule for `_config_missing` / `_plant_missing`, carried to the per-job pointer this window writes: a chosen file that is not there must be a loud, named refusal.""" gone = str(tmp_path / 'nope.yaml') for key in ('knowledge_sources.plant_file', 'knowledge_sources.public_file'): said = configmodel.validate(key, gone) assert said, key assert repr(gone) in said[0] assert 'does not exist' in said[0] def test_a_real_file_is_accepted(tmp_path): real = tmp_path / 'plant.yaml' real.write_text(u'lifters: {}\n', encoding='utf-8') for key in ('knowledge_sources.plant_file', 'knowledge_sources.public_file'): assert configmodel.validate(key, str(real)) == [] def test_a_blank_value_is_never_refused_it_means_go_back_to_default(): for key in ('knowledge_sources.plant_file', 'knowledge_sources.public_file'): assert configmodel.validate(key, None) == [] assert configmodel.validate(key, '') == [] def test_knowledge_source_problems_is_silent_when_never_set(): """No layer mentions the key -- every install before this feature existed. Not a problem.""" assert configmodel.knowledge_source_problems(layers()) == [] def test_knowledge_source_problems_is_silent_when_explicitly_cleared(): """`known=True` with a `None` value is a decision made in the window -- distinct from `known=False`, and neither is a problem.""" job = {'knowledge_sources': {'plant_file': None}} problems = configmodel.knowledge_source_problems(layers(job=job)) assert problems == [] def test_knowledge_source_problems_names_a_pointer_that_is_gone(): job = {'knowledge_sources': {'plant_file': 'C:/nowhere/knowledge.yaml'}} problems = configmodel.knowledge_source_problems(layers(job=job)) assert len(problems) == 1 key, label, path = problems[0] assert key == 'knowledge_sources.plant_file' assert label == 'Plant knowledge file' assert path == 'C:/nowhere/knowledge.yaml' def test_knowledge_source_problems_is_silent_when_the_file_is_really_there( tmp_path): real = tmp_path / 'catalogue.yaml' real.write_text(u'inserts: {}\n', encoding='utf-8') job = {'knowledge_sources': {'public_file': str(real)}} assert configmodel.knowledge_source_problems(layers(job=job)) == []