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.
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
# -*- 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)) == []
|
||||
Reference in New Issue
Block a user