25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
1295 lines
54 KiB
Python
1295 lines
54 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""The four-module Project Config window: its mapping, its fields, its XAML.
|
|
|
|
`vibe.configform` is everything about that window that does not need Revit:
|
|
which setting belongs in which of the four modules, what kind of control each
|
|
one deserves, what one click of a toggle means, and the mute rules. The window
|
|
itself is `ProjectConfig.pushbutton/` (found by `_button()` below, because the
|
|
ribbon moves) -- a `forms.WPFWindow`
|
|
and a XAML file -- and IT CANNOT BE OPENED ON A HOST MACHINE. So this suite
|
|
does the three things a host machine can:
|
|
|
|
1. asserts the mapping is total (`test_every_editable_setting_is_placed`),
|
|
which is the check that stops a setting added to `configmodel` from
|
|
shipping with nowhere on screen to change it;
|
|
2. asserts the field/control rules, including the two the owner asked for
|
|
by name -- a rig mode that is a button showing `static` and becomes
|
|
`rolling`, and a sling angle that is a box you type in;
|
|
3. parses `ProjectConfig.xaml` and `script.py` together and fails if they
|
|
have drifted -- every `Click=` handler in the XAML exists as a method,
|
|
and every `x:Name` the script looks up exists as an element. `wpf.Load
|
|
Component` binds those by name at load time, so a drift here is a
|
|
window that throws the moment it opens, on the owner's machine, with
|
|
nothing on a host machine having noticed.
|
|
|
|
WHAT ONLY A PRESS CAN PROVE, and is therefore not asserted anywhere here:
|
|
|
|
* that the XAML renders at all in Revit's WPF (a bad attribute value is a
|
|
load-time exception, not a parse error);
|
|
* that `script.load_ui` finds `ProjectConfig.xaml` in the bundle;
|
|
* that a click actually reaches `toggle_click` -- the handler wiring done
|
|
in code (`button.Click += ...`) is not visible to this suite;
|
|
* that the window is modal over Revit and that the output page comes to the
|
|
front after it closes.
|
|
"""
|
|
import ast
|
|
import io
|
|
import os
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from vibe import configform
|
|
from vibe import configmodel
|
|
from vibe import plantpath
|
|
|
|
|
|
EXTENSION = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _button():
|
|
"""Where the button folder is, found rather than spelled out.
|
|
|
|
The ribbon is folder structure, and folder structure moves: this button
|
|
has already been through `AI.tab/QC.panel/`, `WillTools.tab/
|
|
Reinforcing.panel/setup.stack/` and now `WillTools.tab/Setup.panel/
|
|
Config.pulldown/` in two days. A hard-coded path turns a ribbon
|
|
reorganisation into a failure in a test about XAML, which sends the next
|
|
person looking in the wrong file. This walk cost nothing and has now
|
|
absorbed three moves without an edit.
|
|
"""
|
|
for root, dirs, _files in os.walk(EXTENSION):
|
|
for name in dirs:
|
|
if name == 'ProjectConfig.pushbutton':
|
|
return os.path.join(root, name)
|
|
raise AssertionError(
|
|
'ProjectConfig.pushbutton is nowhere under {0}'.format(EXTENSION))
|
|
|
|
|
|
BUTTON = _button()
|
|
XAML = os.path.join(BUTTON, 'ProjectConfig.xaml')
|
|
SCRIPT = os.path.join(BUTTON, 'script.py')
|
|
|
|
|
|
# --------------------------------------------------------------- the fixture
|
|
|
|
def _tree(pairs):
|
|
out = {}
|
|
for key, value in pairs:
|
|
node = out
|
|
parts = key.split('.')
|
|
for part in parts[:-1]:
|
|
node = node.setdefault(part, {})
|
|
node[parts[-1]] = value
|
|
return out
|
|
|
|
|
|
def _sample(row):
|
|
kind = row['kind']
|
|
if kind == 'choice':
|
|
return (row['choices'] or ('fast',))[0]
|
|
if kind == 'bool':
|
|
return True
|
|
if kind in ('float', 'int'):
|
|
return 2 if kind == 'int' else 2.5
|
|
if kind == 'list':
|
|
return ['ANCHOR_FACE_4T']
|
|
if kind == 'map':
|
|
return {}
|
|
if kind == 'stages':
|
|
return [{'name': 'stripping', 'pci_case': 'stripping',
|
|
'strength': 'fci', 'rig_mode': 'static'},
|
|
{'name': 'yard', 'pci_case': 'yard', 'strength': 'fc',
|
|
'enabled': False}]
|
|
return 'something'
|
|
|
|
|
|
def layers(job=None, plant=None):
|
|
defaults = _tree([(row['key'], _sample(row))
|
|
for row in configmodel.SETTINGS])
|
|
defaults.setdefault('scope', {})['mark_prefixes'] = ['PNL']
|
|
defaults.setdefault('tolerances', {})['dimension_in'] = 0.375
|
|
return [('defaults', None, True, defaults),
|
|
('catalogue', 'C:/tool/catalogue.yaml', True, {}),
|
|
('plant', 'C:/plant/knowledge.yaml', True, plant or {}),
|
|
('job', 'C:/job/knowledge.yaml', True, job or {})]
|
|
|
|
|
|
def rows(job=None, plant=None, run_options=None, info=None):
|
|
return configform.resolve_all(configform.fields(info=info),
|
|
layers(job=job, plant=plant),
|
|
run_options or {})
|
|
|
|
|
|
def row_for(key, **kw):
|
|
for row in rows(**kw):
|
|
if row['key'] == key:
|
|
return row
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------------- the mapping
|
|
|
|
def test_every_editable_setting_is_placed_or_explicitly_left_out():
|
|
"""The contract that makes the not-shown pool honest.
|
|
|
|
A setting added to `configmodel.SETTINGS` as editable must land in one of
|
|
the four modules or in `NOT_SHOWN` with the reason it is not a control.
|
|
Failing here is the intended behaviour: somebody has to decide.
|
|
"""
|
|
assert configform.unmapped() == [], (
|
|
'these editable settings are in no module and have no not-shown '
|
|
'reason: {0}. Put each one in configform.MODULE_OF or in '
|
|
'configform.NOT_SHOWN with the sentence saying why a person does not '
|
|
'change it from this window.'.format(configform.unmapped()))
|
|
|
|
|
|
def test_the_four_modules_are_the_owners_four():
|
|
assert [key for key, _t, _n in configform.MODULES] == [
|
|
'file_path', 'lifting', 'ticketqc', 'job_knowledge']
|
|
|
|
|
|
def test_nothing_is_in_two_modules_at_once():
|
|
seen = {}
|
|
for field in configform.fields():
|
|
assert field['key'] not in seen or seen[field['key']] == \
|
|
field['module'], field['key']
|
|
seen[field['key']] = field['module']
|
|
|
|
|
|
def test_no_read_only_setting_reaches_the_window():
|
|
"""Plant and catalogue values are not in this window at all -- one plant
|
|
edit reaches every job, so it stays a deliberate act somewhere else."""
|
|
keys = set(field['key'] for field in configform.fields())
|
|
for row in configmodel.read_only():
|
|
assert row['key'] not in keys, row['key']
|
|
|
|
|
|
def test_every_not_shown_setting_carries_a_reason():
|
|
for label, key, why in configform.not_shown_rows():
|
|
assert why and len(why) > 20, key
|
|
assert label
|
|
|
|
|
|
def test_the_not_shown_pool_is_not_empty():
|
|
"""`剩下的都不在这里显示` -- if everything is shown, the design is gone."""
|
|
assert len(configform.not_shown_rows()) >= 8
|
|
|
|
|
|
# ---------------------------------------------------------------- the fields
|
|
|
|
def test_rig_mode_is_a_two_state_button_not_a_list():
|
|
"""The owner's example: a button reading `static` that becomes `rolling`."""
|
|
field = row_for('handling.rig_mode')
|
|
assert field['kind'] == 'toggle'
|
|
assert set(field['values']) == set(('rolling', 'static'))
|
|
assert configform.next_value(field, 'static') == 'rolling'
|
|
assert configform.next_value(field, 'rolling') == 'static'
|
|
|
|
|
|
def test_sling_angle_is_a_box_you_type_in():
|
|
field = row_for('handling.sling_angle_deg')
|
|
assert field['kind'] == 'number'
|
|
ok, value, _why = configform.parse(field, '52.5')
|
|
assert ok and value == 52.5
|
|
|
|
|
|
def test_a_number_box_refuses_a_word_rather_than_guessing():
|
|
field = row_for('handling.sling_angle_deg')
|
|
ok, _value, why = configform.parse(field, 'sixty')
|
|
assert not ok
|
|
assert 'not a number' in why
|
|
|
|
|
|
def test_a_blank_number_box_is_null_not_zero():
|
|
field = row_for('handling.dunnage.fraction')
|
|
ok, value, _why = configform.parse(field, ' ')
|
|
assert ok and value is None
|
|
|
|
|
|
def test_dunnage_mode_cycles_through_blank():
|
|
field = row_for('handling.dunnage.mode')
|
|
assert field['kind'] == 'toggle'
|
|
assert None in field['values']
|
|
seen = []
|
|
value = None
|
|
for _ in range(3):
|
|
value = configform.next_value(field, value)
|
|
seen.append(value)
|
|
assert seen == ['lifter_rows', 'fraction', None]
|
|
|
|
|
|
def test_a_toggle_on_a_value_no_layer_set_starts_at_the_top():
|
|
field = row_for('nomenclature.ticket_unit')
|
|
assert configform.next_value(field, 'something nobody wrote') == \
|
|
field['values'][0]
|
|
|
|
|
|
def test_run_options_are_toggles_and_say_they_have_no_layers():
|
|
field = row_for('recommend_positions')
|
|
assert field['source'] == 'run'
|
|
assert field['kind'] == 'toggle'
|
|
assert 'no layers' in field['layer']
|
|
assert configform.next_value(field, True) is False
|
|
|
|
|
|
def test_the_two_lifting_run_options_are_the_ones_the_owner_named():
|
|
lifting = [f['key'] for f in configform.run_fields()
|
|
if f['module'] == configform.LIFTING]
|
|
assert sorted(lifting) == ['expensive_geometry', 'recommend_positions']
|
|
|
|
|
|
def test_a_list_field_is_a_box_and_splits_on_commas():
|
|
field = row_for('scope.mark_prefixes')
|
|
assert field['kind'] == 'list'
|
|
ok, value, _why = configform.parse(field, 'PNL, BM ,')
|
|
assert ok and value == ['PNL', 'BM']
|
|
|
|
|
|
def test_a_path_field_keeps_a_share_path_exactly_as_typed():
|
|
field = row_for('handling.report_dir')
|
|
ok, value, _why = configform.parse(field, r'\\share\jobs\out')
|
|
assert ok and value == r'\\share\jobs\out'
|
|
|
|
|
|
def test_the_chip_says_which_layer_a_value_came_from():
|
|
field = row_for('handling.rig_mode',
|
|
job={'handling': {'rig_mode': 'rolling'}})
|
|
assert field['chip'] == 'J'
|
|
assert field['layer'] == 'job'
|
|
assert field['display'] == 'rolling'
|
|
|
|
|
|
def test_a_value_no_layer_sets_says_so_rather_than_showing_blank():
|
|
field = row_for('snapshot.detached_dir')
|
|
assert field['chip'] == '?'
|
|
assert field['display'] == '(not set)'
|
|
|
|
|
|
def test_every_field_carries_a_human_label_not_a_dotted_key():
|
|
for field in rows():
|
|
if field['source'] == 'info':
|
|
continue
|
|
assert field['label'], field['key']
|
|
assert field['label'] != field['key'], field['key']
|
|
|
|
|
|
def test_derived_paths_are_shown_and_not_editable():
|
|
given = [(configform.FILE_PATH, 'Job data folder', 'C:/job/_ticketqc',
|
|
'follows the model')]
|
|
field = [f for f in rows(info=given) if f['kind'] == 'info'][0]
|
|
assert field['source'] == 'info'
|
|
assert field['display'] == 'C:/job/_ticketqc'
|
|
assert field['chip'] == ''
|
|
|
|
|
|
def test_modules_come_back_in_the_owners_order_with_their_rows():
|
|
grouped = configform.by_module(rows())
|
|
assert [key for key, _t, _n, _r in grouped] == [
|
|
'file_path', 'lifting', 'ticketqc', 'job_knowledge']
|
|
for _key, _title, _note, group in grouped:
|
|
assert group, 'a module with no fields is a box with nothing in it'
|
|
|
|
|
|
# ---------------------------------------------------------------- the stages
|
|
|
|
def test_a_stage_row_is_its_name_its_rig_mode_and_whether_it_runs():
|
|
field = row_for('handling.stages')
|
|
assert field['kind'] == 'stages'
|
|
listed = configform.stage_rows(field['value'])
|
|
assert listed[0][1] == 'stripping'
|
|
assert listed[0][2] == 'static'
|
|
assert listed[0][3] is True
|
|
assert listed[1][3] is False
|
|
|
|
|
|
def test_changing_one_stage_hands_back_every_stage():
|
|
"""`handling.stages` is one list and lists replace: a write of one cell
|
|
writes all of them, so the edit is made against the whole list."""
|
|
field = row_for('handling.stages')
|
|
after = configform.set_stage(field['value'], 0, 'rig_mode', 'rolling')
|
|
assert len(after) == len(field['value'])
|
|
assert after[0]['rig_mode'] == 'rolling'
|
|
assert after[1] == field['value'][1]
|
|
assert field['value'][0]['rig_mode'] == 'static' # the original stands
|
|
|
|
|
|
def test_a_stage_with_no_rig_mode_shows_blank_not_an_empty_button():
|
|
assert configform.stage_display(None) == configform.BLANK
|
|
|
|
|
|
# ----------------------------------------------------------------- the mutes
|
|
|
|
def test_a_mute_without_a_reason_is_refused():
|
|
"""Enforced here and not only in configmodel.validate, because the loaded
|
|
build may predate that rule."""
|
|
ok, mutes, why = configform.add_mute({}, '3.3', ' ')
|
|
assert not ok
|
|
assert mutes == {}
|
|
assert 'REQUIRED' in why
|
|
|
|
|
|
def test_a_mute_without_a_number_is_refused():
|
|
ok, _mutes, why = configform.add_mute({}, '', 'because')
|
|
assert not ok
|
|
assert 'checklist item' in why
|
|
|
|
|
|
def test_a_mute_lands_with_its_reason_and_can_be_removed():
|
|
ok, mutes, _why = configform.add_mute({}, ' 3.3 ', ' no piece has one ',
|
|
when='2026-08-30')
|
|
assert ok
|
|
assert mutes == {'3.3': 'no piece has one (Project Config, 2026-08-30)'}
|
|
ok, mutes, _why = configform.remove_mute(mutes, '3.3')
|
|
assert ok and mutes == {}
|
|
|
|
|
|
# ------------------------------------------------- the reason, and its date
|
|
|
|
def test_the_reason_is_dated_for_you_and_the_sentence_is_still_yours():
|
|
"""The owner's own mutes read `"... (Will 2026-08-30)"`. The date is the
|
|
tool's job; the sentence is never written, defaulted or suggested."""
|
|
stamped = configform.stamp_reason('WWF does not exist on this job',
|
|
when='2026-08-30')
|
|
assert stamped.startswith('WWF does not exist on this job')
|
|
assert stamped.endswith('(Project Config, 2026-08-30)')
|
|
|
|
|
|
def test_a_reason_that_is_already_dated_is_not_dated_twice():
|
|
"""Re-muting an item must not stack `(Project Config, 08-30) (Project
|
|
Config, 08-31)` on the end of a sentence somebody wrote once."""
|
|
once = configform.stamp_reason('no piece has one', when='2026-08-30')
|
|
twice = configform.stamp_reason(once, when='2026-08-30')
|
|
assert twice == once
|
|
assert configform.stamp_reason('his own words (Will 2026-08-30)') == \
|
|
'his own words (Will 2026-08-30)'
|
|
|
|
|
|
def test_a_blank_reason_gets_no_date_and_stays_blank():
|
|
"""A date on its own is not a reason. If the stamp were added first, an
|
|
empty box would produce `(Project Config, 2026-08-30)` -- a mute that
|
|
looks documented and says nothing."""
|
|
assert configform.stamp_reason(' ') == ''
|
|
ok, mutes, why = configform.add_mute({}, '3.3', ' ')
|
|
assert not ok and mutes == {} and 'REQUIRED' in why
|
|
|
|
|
|
def test_nothing_in_this_module_offers_a_default_reason():
|
|
"""No placeholder, no 'muted via UI', no pre-filled sentence anywhere. A
|
|
reason somebody accepted without reading is the same as no reason on the
|
|
day it has to be argued with."""
|
|
ok, _mutes, _why = configform.add_mute({}, '3.3', 'because')
|
|
assert ok
|
|
# The reason box is created empty and the only thing ever put into it is
|
|
# what the person has already typed. Asserted on the assignment itself
|
|
# rather than by hunting for banned phrases in the file: this file is
|
|
# mostly comments, and a comment saying "no placeholder" would fail a
|
|
# phrase search while a real placeholder three lines down would not.
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert 'box.Text = self.mute_draft' in source
|
|
for other in re.findall(r'\.Text\s*=\s*([^\n]+)', source):
|
|
assert 'reason' not in other.lower() or 'draft' in other, other
|
|
|
|
|
|
def test_a_number_that_is_not_a_checklist_item_is_refused_at_the_click():
|
|
"""`configmodel.validate` catches this at Save. Catching it at the click
|
|
is better: a number that mutes nothing is how somebody believes a row is
|
|
off while it is on, and the sooner that is said the less of it is typed."""
|
|
known = configform.checklist_numbers()
|
|
ok, mutes, why = configform.add_mute({}, '9.9', 'because', known=known)
|
|
assert not ok and mutes == {}
|
|
assert 'mute nothing' in why
|
|
|
|
|
|
def test_a_caller_that_could_not_read_the_table_takes_the_number_on_trust():
|
|
"""`known=None` is a real answer -- see `checklist_items()`. Refusing
|
|
every mute because a module would not load is worse than accepting one."""
|
|
ok, mutes, _why = configform.add_mute({}, '9.9', 'because', known=None,
|
|
when='2026-08-30')
|
|
assert ok and '9.9' in mutes
|
|
|
|
|
|
# --------------------------------------------- the checklist, and whose mute
|
|
|
|
def test_the_rows_come_from_the_real_checklist_table_not_a_copy():
|
|
"""A HAND-KEPT SECOND LIST IS THE 5.3 DEFECT. `CHECKLIST_FOR_ENGINEER`
|
|
was a tuple holding `('5.5',)`, 5.3 was never added, and 5.3 printed a
|
|
red NOT RUN on every ticket until somebody spent a day on it. A copy
|
|
here would fail worse: a row this window never draws is a row nobody can
|
|
mute, and nothing anywhere would say why."""
|
|
from vibe import ticketpage
|
|
listed = [number for number, _n, _g in configform.checklist_items()]
|
|
assert listed == [item[0] for item in ticketpage.CHECKLIST_ITEMS]
|
|
assert len(listed) > 30
|
|
|
|
|
|
def test_every_checklist_row_is_offered_even_when_nothing_is_muted():
|
|
rows = configform.mute_table({}, {})
|
|
assert len(rows) == len(configform.checklist_items())
|
|
assert all(row['editable'] and not row['muted'] for row in rows)
|
|
assert configform.mute_summary(rows).endswith(
|
|
'Every row reports at full volume.')
|
|
|
|
|
|
def test_a_job_mute_is_this_windows_to_remove():
|
|
merged = {'5.4': {'value': 'no mesh here', 'layer': 'job',
|
|
'path': 'C:/job/knowledge.yaml', 'overrides': []}}
|
|
row = _mute_row_for('5.4', merged, {'5.4': 'no mesh here'})
|
|
assert row['muted'] and row['editable']
|
|
assert row['layer'] == 'job'
|
|
assert row['why_not'] is None
|
|
assert row['name'] # the checklist's own words, not the number
|
|
|
|
|
|
def test_a_plant_mute_is_shown_marked_and_not_removable_here():
|
|
"""The window writes the job layer, and the layers MERGE this map -- so a
|
|
job entry cannot take a plant mute away. A Remove button there would look
|
|
like it worked and change nothing, which is the failure this whole file
|
|
is against."""
|
|
merged = {'3.3': {'value': 'the plant never has one', 'layer': 'plant',
|
|
'path': 'C:/plant/knowledge.yaml', 'overrides': []}}
|
|
row = _mute_row_for('3.3', merged, {})
|
|
assert row['muted']
|
|
assert row['editable'] is False
|
|
assert 'plant' in row['why_not']
|
|
assert 'MERGE' in row['why_not']
|
|
assert row['reason'] == 'the plant never has one'
|
|
|
|
|
|
def test_a_plant_mute_is_never_absent_from_the_list():
|
|
"""Absent would be worse than not-editable: the report would go on
|
|
hiding a checklist row with nothing on this screen saying so."""
|
|
merged = {'3.3': {'value': 'plant', 'layer': 'plant', 'path': None,
|
|
'overrides': []}}
|
|
rows = configform.mute_table(merged, {})
|
|
assert [row['number'] for row in rows if row['muted']] == ['3.3']
|
|
assert '1 of the' in configform.mute_summary(rows)
|
|
assert 'cannot be changed here' in configform.mute_summary(rows)
|
|
|
|
|
|
def test_unmuting_a_job_mute_that_sits_on_a_plant_one_says_it_falls_back():
|
|
"""Removing the job entry does not unmute the item: the plant's entry
|
|
takes over. Saying "Unmute" and leaving it muted is the same lie in the
|
|
other direction."""
|
|
merged = {'3.3': {'value': 'this job', 'layer': 'job', 'path': None,
|
|
'overrides': [('plant', 'the plant never has one')]}}
|
|
row = _mute_row_for('3.3', merged, {'3.3': 'this job'})
|
|
assert row['editable'] is True
|
|
assert row['falls_back']
|
|
assert 'plant' in row['falls_back']
|
|
assert 'stays muted' in row['falls_back']
|
|
|
|
|
|
def test_a_muted_number_that_is_no_checklist_item_is_listed_and_says_so():
|
|
"""It mutes nothing, and nothing is what it looks like it did. A job file
|
|
edited by hand can carry one, so the screen has to show it rather than
|
|
quietly drop a line somebody wrote."""
|
|
merged = {'9.9': {'value': 'typed by hand', 'layer': 'job', 'path': None,
|
|
'overrides': []}}
|
|
rows = configform.mute_table(merged, {'9.9': 'typed by hand'})
|
|
odd = [row for row in rows if row['number'] == '9.9'][0]
|
|
assert odd is rows[-1] # after every real row, never in place
|
|
assert odd['known'] is False
|
|
assert 'mutes nothing' in odd['name']
|
|
|
|
|
|
def _mute_row_for(number, merged, job_mutes):
|
|
for row in configform.mute_table(merged, job_mutes):
|
|
if row['number'] == number:
|
|
return row
|
|
raise AssertionError('{0} is not in the mute table'.format(number))
|
|
|
|
|
|
# ------------------------------------------ what the window reads and writes
|
|
|
|
def test_the_mute_field_reads_the_merge_and_writes_the_job_layer_only():
|
|
"""THE BUG THIS FIXES. `configmodel.resolve` answers with the WINNING
|
|
layer's whole map, which for a key the layers merge is not what the run
|
|
reads: with the plant muting 3.3 and the job muting 5.4 it answers
|
|
`{'5.4': ...}`, so the screen showed one mute and hid the other.
|
|
|
|
The row now carries both halves and they are different things: `merged`
|
|
is what a run reads, `value` is what this file holds and is the only
|
|
thing a splice may replace."""
|
|
field = row_for('checks.muted',
|
|
job={'checks': {'muted': {'5.4': 'no mesh'}}},
|
|
plant={'checks': {'muted': {'3.3': 'no release rule'}}})
|
|
assert sorted(field['merged']) == ['3.3', '5.4']
|
|
assert field['merged']['3.3']['layer'] == 'plant'
|
|
assert field['merged']['5.4']['layer'] == 'job'
|
|
assert field['value'] == {'5.4': 'no mesh'}
|
|
|
|
|
|
def test_the_mute_chip_says_two_layers_rather_than_naming_one():
|
|
field = row_for('checks.muted',
|
|
job={'checks': {'muted': {'5.4': 'a'}}},
|
|
plant={'checks': {'muted': {'3.3': 'b'}}})
|
|
assert field['chip'] == '+'
|
|
assert field['layer'] == 'plant + job'
|
|
assert field['display'] == '2 muted (plant + job)'
|
|
|
|
|
|
def test_the_mute_display_survives_the_plain_map_a_pending_change_carries():
|
|
"""Two shapes reach `display`: the merged map with its layers, and this
|
|
file's own {number: reason}. The second is what `change_lines` and the
|
|
summary printed after the window closes are handed, where a traceback
|
|
has nowhere to go."""
|
|
field = row_for('checks.muted')
|
|
assert configform.display(field, {'3.3': 'because'}) == '1 muted: 3.3'
|
|
assert configform.display(field, {}) == 'nothing muted'
|
|
|
|
|
|
# ---------------------------------------------------- the key that is muted
|
|
|
|
def test_the_psi_tolerance_is_declared_inert_not_a_box_you_can_type_in():
|
|
"""Owner's ruling on drift D1: "不要删掉,要'静音'掉。静音配置应该在 config
|
|
里面。" Nothing compares a psi against this key, so a number box would be
|
|
a control that decides nothing -- and whoever typed in it would believe a
|
|
check had got stricter."""
|
|
field = row_for('tolerances.psi')
|
|
assert field['kind'] == 'inert'
|
|
assert field['source'] == 'inert'
|
|
assert field['module'] == configform.TICKETQC
|
|
assert 'MUTED, NOT DELETED' in field['note']
|
|
assert 'MUTED' in field['display']
|
|
|
|
|
|
def test_muting_a_key_is_not_muting_a_checklist_row():
|
|
"""They are different objects and must not be run together. A checklist
|
|
mute changes what a run reports, is a job fact and is the engineer's to
|
|
set -- so it is a control. An inert key changes no run on any job, so it
|
|
is a declaration."""
|
|
inert = row_for('tolerances.psi')
|
|
mutes = row_for('checks.muted')
|
|
assert inert['source'] == 'inert' and mutes['source'] == 'job'
|
|
assert inert['key'] not in configform.placement()
|
|
assert configform.placement()['checks.muted'] == 'Ticket QC'
|
|
|
|
|
|
def test_an_inert_key_is_still_shown_with_whatever_the_file_says():
|
|
"""Kept, not deleted -- so the number in the file stays visible. Hiding
|
|
it would make the ruling unreadable from the screen that records it."""
|
|
field = row_for('tolerances.psi',
|
|
job={'tolerances': {'psi': 50.0}})
|
|
assert '50.0' in field['display']
|
|
assert field['chip'] == 'J'
|
|
|
|
|
|
def test_removing_something_that_is_not_muted_says_so():
|
|
ok, _mutes, why = configform.remove_mute({'3.3': 'x'}, '9.9')
|
|
assert not ok and '9.9' in why
|
|
|
|
|
|
def test_the_mute_list_reads_back_sorted_with_its_reasons():
|
|
assert configform.mute_rows({'5.1': 'b', '3.3': 'a'}) == \
|
|
[('3.3', 'a'), ('5.1', 'b')]
|
|
|
|
|
|
def test_muting_does_not_mutate_the_map_it_was_given():
|
|
before = {'3.3': 'a'}
|
|
configform.add_mute(before, '4.1', 'b')
|
|
assert before == {'3.3': 'a'}
|
|
|
|
|
|
# ------------------------------------------------------------ the edit rules
|
|
|
|
def test_a_toggle_clicked_back_to_where_it_started_is_not_a_change():
|
|
assert configform.same('rolling', 'rolling') is True
|
|
|
|
|
|
def test_true_and_one_are_not_the_same_setting():
|
|
"""`1 == True` in Python. A write that turned a count into a boolean
|
|
because they compared equal is a file nobody asked for."""
|
|
assert configform.same(1, True) is False
|
|
assert configform.same(True, 1) is False
|
|
|
|
|
|
def test_change_lines_read_label_first_with_the_key_last():
|
|
field = row_for('handling.rig_mode')
|
|
line = configform.change_lines([(field, 'static', 'rolling')])[0]
|
|
assert line.startswith('Lift type')
|
|
assert line.endswith('(handling.rig_mode)')
|
|
|
|
|
|
def test_a_file_url_survives_a_space_in_the_job_folder():
|
|
"""Markdown ends a link at the first space, and job folders have spaces."""
|
|
url = configform.file_url(r'C:\My Job\_ticketqc\knowledge.yaml')
|
|
assert url == 'file:///C:/My%20Job/_ticketqc/knowledge.yaml'
|
|
assert ' ' not in url
|
|
|
|
|
|
# ------------------------------------------------------ the window and the XAML
|
|
|
|
def _xaml():
|
|
return io.open(XAML, encoding='utf-8').read()
|
|
|
|
|
|
def _script_ast():
|
|
return ast.parse(io.open(SCRIPT, encoding='utf-8').read())
|
|
|
|
|
|
def _window_class():
|
|
for node in _script_ast().body:
|
|
if isinstance(node, ast.ClassDef) and node.name == 'ConfigWindow':
|
|
return node
|
|
raise AssertionError('ConfigWindow is gone from script.py')
|
|
|
|
|
|
def _methods():
|
|
return set(node.name for node in _window_class().body
|
|
if isinstance(node, ast.FunctionDef))
|
|
|
|
|
|
def test_the_xaml_is_well_formed_xml():
|
|
from xml.etree import ElementTree
|
|
ElementTree.fromstring(_xaml().encode('utf-8'))
|
|
|
|
|
|
def test_every_click_handler_named_in_the_xaml_exists_on_the_window():
|
|
"""`wpf.LoadComponent` binds these by name when the window loads, so a
|
|
missing one is an exception the moment the owner presses the button."""
|
|
named = set(re.findall(r'Click="([A-Za-z_][A-Za-z0-9_]*)"', _xaml()))
|
|
assert named, 'the window has no buttons any more'
|
|
missing = sorted(named - _methods())
|
|
assert not missing, 'XAML calls {0}, ConfigWindow has no such method'.format(
|
|
missing)
|
|
|
|
|
|
def test_every_element_the_script_looks_up_is_named_in_the_xaml():
|
|
names = set(re.findall(r'x:Name="([A-Za-z_][A-Za-z0-9_]*)"', _xaml()))
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
for wanted in ('header', 'subheader', 'status', 'reason_box', 'rest_line',
|
|
'panel_file_path', 'panel_lifting', 'panel_ticketqc',
|
|
'panel_job_knowledge', 'group_file_path', 'group_lifting',
|
|
'group_ticketqc', 'group_job_knowledge'):
|
|
assert wanted in names, '{0} is not x:Name-d in the XAML'.format(wanted)
|
|
assert 'self.' + wanted in source, \
|
|
'{0} is named in the XAML and never used'.format(wanted)
|
|
|
|
|
|
def test_there_is_a_panel_for_each_module():
|
|
names = set(re.findall(r'x:Name="([A-Za-z_][A-Za-z0-9_]*)"', _xaml()))
|
|
for key, _title, _note in configform.MODULES:
|
|
assert 'panel_' + key in names
|
|
assert 'group_' + key in names
|
|
|
|
|
|
def test_the_window_has_a_save_and_a_way_out_to_the_file():
|
|
body = _xaml()
|
|
assert 'save_click' in body
|
|
assert 'openfile_click' in body
|
|
assert 'rest_line' in body
|
|
|
|
|
|
def test_the_header_wraps_because_that_is_the_defect_being_fixed():
|
|
"""Defect 1 was a non-wrapping Label in a fixed-height row. Every prose
|
|
element in this window must say TextWrapping."""
|
|
body = _xaml()
|
|
for name in ('header', 'subheader', 'status', 'rest_line'):
|
|
chunk = body.split('x:Name="{0}"'.format(name))[1].split('/>')[0]
|
|
assert 'TextWrapping="Wrap"' in chunk, name
|
|
|
|
|
|
def test_the_script_has_no_module_docstring():
|
|
"""The ribbon carries no hover text; documentation is in `#` comments."""
|
|
assert ast.get_docstring(_script_ast()) is None
|
|
|
|
|
|
@pytest.mark.parametrize('handler', ['toggle_click', 'stage_click',
|
|
'mute_start_click', 'mute_add_click',
|
|
'mute_cancel_click', 'mute_remove_click',
|
|
'mute_reason_changed',
|
|
'text_changed', 'save_click',
|
|
'cancel_click', 'openfile_click',
|
|
'fullview_click', 'section_toggled'])
|
|
def test_the_window_still_has_its_handlers(handler):
|
|
assert handler in _methods()
|
|
|
|
|
|
# ------------------------------------------------ every module folds, 2026-09-01
|
|
#
|
|
# "project config现在有点儿太复杂了。。。给弄成每个部分都可以折叠的吧。需要
|
|
# 调啥,再把那个部分给打开,调整,完事儿。" -- every module folds, and folded
|
|
# is the default. What a host machine can prove: the four modules are built
|
|
# from `Expander`, not `GroupBox`; every one starts folded absent session
|
|
# memory; a module carrying a live override says so on its own header before
|
|
# it is ever opened; and the fold/unfold handler is wired in `script.py` the
|
|
# same way every other control's handler is.
|
|
|
|
def test_the_four_modules_are_expanders_not_groupboxes():
|
|
body = _xaml()
|
|
for key, _title, _note in configform.MODULES:
|
|
chunk = body[body.index('x:Name="group_{0}"'.format(key)) - 20:]
|
|
chunk = chunk[:chunk.index('>') + 1]
|
|
assert '<Expander' in chunk, (key, chunk)
|
|
assert '<GroupBox x:Name="group_' not in body
|
|
|
|
|
|
def test_every_module_starts_collapsed_in_the_xaml():
|
|
"""The fallback for the very first paint of a fresh session -- before
|
|
`script.py` ever gets to override it from remembered state."""
|
|
body = _xaml()
|
|
for key, _title, _note in configform.MODULES:
|
|
chunk = body[body.index('x:Name="group_{0}"'.format(key)):]
|
|
chunk = chunk[:chunk.index('>') + 1]
|
|
assert 'IsExpanded="False"' in chunk, key
|
|
|
|
|
|
def test_the_fold_handler_is_wired_like_every_other_control():
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert '.Expanded += self.section_toggled' in source
|
|
assert '.Collapsed += self.section_toggled' in source
|
|
|
|
|
|
def test_the_header_is_built_before_the_fold_state():
|
|
"""Ordering, not just presence: a header set after `IsExpanded` would
|
|
still be correct on screen (WPF does not care), but a reader of this
|
|
file six months from now should not have to check that -- the header
|
|
always reflects what is ABOUT to be folded, never what a click just
|
|
revealed."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
header_at = source.index('box.Header = ')
|
|
expanded_at = source.index('box.IsExpanded = ')
|
|
assert header_at < expanded_at
|
|
|
|
|
|
# ---- `is_modified` / `modified_count` -- the fact a folded header states
|
|
|
|
def _row(kind='number', source='job', chip='D', is_set=None):
|
|
row = {'kind': kind, 'source': source, 'chip': chip}
|
|
if is_set is not None:
|
|
row['is_set'] = is_set
|
|
return row
|
|
|
|
|
|
def test_a_row_still_on_the_shipped_default_is_not_modified():
|
|
assert configform.is_modified(_row(chip='D')) is False
|
|
|
|
|
|
def test_a_row_no_layer_knows_is_not_modified():
|
|
"""Not known and not overridden are the same non-event for this
|
|
purpose, even though `configmodel.chip` prints them differently
|
|
elsewhere: neither is a decision a folded header has to surface."""
|
|
assert configform.is_modified(_row(chip='?')) is False
|
|
|
|
|
|
@pytest.mark.parametrize('chip', ['J', 'P', '!', '+', '*'])
|
|
def test_a_row_a_real_layer_set_is_modified(chip):
|
|
assert configform.is_modified(_row(chip=chip)) is True
|
|
|
|
|
|
def test_an_info_row_is_never_modified():
|
|
"""A derived path is a fact about where the tool put something, not a
|
|
decision -- see the `info` kind's own docstring at the top of this
|
|
module."""
|
|
assert configform.is_modified(_row(kind='info', chip='J')) is False
|
|
|
|
|
|
def test_an_inert_row_is_never_modified():
|
|
"""Declared-and-not-wired: whatever the file says under it changes no
|
|
run, so it is not the kind of override a folded header exists to
|
|
surface -- see the `inert` kind's own docstring."""
|
|
assert configform.is_modified(_row(kind='inert', chip='J')) is False
|
|
|
|
|
|
def test_a_run_option_is_modified_only_when_the_file_actually_names_it():
|
|
assert configform.is_modified(
|
|
_row(source='run', chip='-', is_set=False)) is False
|
|
assert configform.is_modified(
|
|
_row(source='run', chip='-', is_set=True)) is True
|
|
|
|
|
|
def test_modified_count_counts_only_the_modified_ones():
|
|
group = [_row(chip='D'), _row(chip='J'), _row(chip='P'),
|
|
_row(kind='info', chip='J'), _row(chip='?')]
|
|
assert configform.modified_count(group) == 2
|
|
|
|
|
|
# ---- session-only expansion memory
|
|
|
|
def test_a_module_is_collapsed_the_first_time_this_session_asks():
|
|
assert configform.remembered_expanded('a module nothing has touched') \
|
|
is False
|
|
|
|
|
|
def test_remembering_an_expansion_is_read_back():
|
|
configform.remember_expanded(configform.LIFTING, True)
|
|
try:
|
|
assert configform.remembered_expanded(configform.LIFTING) is True
|
|
finally:
|
|
configform.remember_expanded(configform.LIFTING, False)
|
|
|
|
|
|
def test_forgetting_an_expansion_folds_it_again():
|
|
configform.remember_expanded(configform.TICKETQC, True)
|
|
configform.remember_expanded(configform.TICKETQC, False)
|
|
assert configform.remembered_expanded(configform.TICKETQC) is False
|
|
|
|
|
|
# ------------------------------------------- what muting means, and the icon
|
|
|
|
def test_muting_does_not_stop_the_check_and_every_surface_says_so():
|
|
"""The owner's spec, 2026-08-30: the check "还是偷偷跑" -- it still runs --
|
|
and only its presentation changes. That is a CHANGE OF MEANING from what
|
|
muting used to be, so every sentence a person can read has to carry it.
|
|
An old sentence saying a muted row "leaves the checklist table" would now
|
|
be false, and a false sentence about muting is the exact thing muting was
|
|
introduced to stop."""
|
|
from vibe import configmodel as CM
|
|
surfaces = [CM.setting('checks.muted')['note'],
|
|
configform.PRESENT['checks.muted']['note'],
|
|
dict((k, n) for k, _t, n in configform.MODULES)['ticketqc']]
|
|
for body in surfaces:
|
|
assert 'run' in body.lower(), body[:60]
|
|
# And none of them may still claim the row goes away.
|
|
assert 'leaves the checklist' not in body, body[:60]
|
|
joined = ' '.join(surfaces).lower()
|
|
assert 'never quietened' in joined or 'never removes a fail' in joined
|
|
|
|
|
|
def test_a_failure_is_never_quietened_and_the_notes_still_say_so():
|
|
from vibe import configmodel as CM
|
|
body = (CM.setting('checks.muted')['note'] + ' ' +
|
|
configform.PRESENT['checks.muted']['note']).upper()
|
|
assert 'FAILURE IS NEVER QUIETENED' in body
|
|
|
|
|
|
def test_the_muted_state_is_its_own_word_and_not_a_colour_on_not_run():
|
|
"""A row that ran and passed quietly, and a row that could not run at
|
|
all, are different answers. If muted-and-fine were NOT RUN painted grey
|
|
then a real NOT RUN -- a defect in the tooling -- would be one styling
|
|
rule away from invisible."""
|
|
assert configform.MUTED_STATE
|
|
assert configform.MUTED_STATE != 'NOT RUN'
|
|
assert configform.MUTED_STATE not in ('OK', 'WARN', 'FAIL',
|
|
'FOR ENGINEER')
|
|
|
|
|
|
def test_the_muted_state_seam_with_ticketpage():
|
|
"""One word, two modules. `ticketpage` renders the checklist and this
|
|
file sets the mute, so the window would promise a word the report does
|
|
not print if they drifted.
|
|
|
|
THE SEAM IS NOW BUILT, and this test did its job on the way: while
|
|
`ticketpage` had no such constant it asserted the OTHER side -- that
|
|
`apply_muting` still took the row off the table -- precisely so it
|
|
would fire the day muting landed. It fired. What it holds now is the
|
|
equality it was always written for."""
|
|
from vibe import ticketpage
|
|
word = getattr(ticketpage, configform.MUTED_STATE_ATTR, None)
|
|
assert word is not None, (
|
|
'ticketpage has no {0}. The window sets a mute the report cannot '
|
|
'name.'.format(configform.MUTED_STATE_ATTR))
|
|
assert word == configform.MUTED_STATE, (
|
|
'ticketpage.{0} is {1!r} and configform.MUTED_STATE is {2!r}. '
|
|
'The window promises a word the report does not print.'.format(
|
|
configform.MUTED_STATE_ATTR, word, configform.MUTED_STATE))
|
|
# And the renderer really uses it: a muted row keeps its place and goes
|
|
# grey, rather than leaving the table the way it used to.
|
|
kept, notes = ticketpage.apply_muting(
|
|
[('3.3', "f'ci/f'c release ratio in band", 'B', 'NOT RUN', 'x')],
|
|
{'3.3': 'no release rule on this job'})
|
|
assert [row[0] for row in kept] == ['3.3']
|
|
assert kept[0][3] == configform.MUTED_STATE
|
|
assert ticketpage.state_color(kept[0][3]) == 'grey'
|
|
assert notes
|
|
|
|
|
|
def test_a_failure_survives_muting_in_the_renderer_as_it_always_has():
|
|
"""The invariant, pinned from this side too: whatever muting comes to
|
|
mean, a FAIL is never taken off the table."""
|
|
from vibe import ticketpage
|
|
kept, notes = ticketpage.apply_muting(
|
|
[('3.3', 'release ratio', 'B', 'FAIL', 'it is 0.4')],
|
|
{'3.3': 'no release rule on this job'})
|
|
assert [row[0] for row in kept] == ['3.3']
|
|
assert any('FAIL' in (note[3] or '').upper() or
|
|
'fail' in (note[3] or '') for note in notes)
|
|
|
|
|
|
def test_the_control_is_the_windows_volume_icon_he_asked_for():
|
|
""""就是 windows 的那个音量图标" -- Segoe MDL2 Assets, the two code points
|
|
the Windows volume flyout itself draws. BMP, so no surrogate pair, and
|
|
the font ships with the OS."""
|
|
assert configform.SPEAKER_FONT == 'Segoe MDL2 Assets'
|
|
assert configform.SPEAKER_ON == u''
|
|
assert configform.SPEAKER_MUTED == u''
|
|
assert len(configform.SPEAKER_ON) == 1 # no surrogate pair
|
|
assert len(configform.SPEAKER_MUTED) == 1
|
|
assert configform.SPEAKER_ON != configform.SPEAKER_MUTED
|
|
|
|
|
|
def test_the_speaker_says_what_the_click_will_do_not_what_the_state_is():
|
|
"""The state is the glyph and the colour. What a speaker cannot say on
|
|
its own is the thing people get wrong -- whether it stops the check."""
|
|
assert 'still runs' in configform.SPEAKER_HINT[False]
|
|
assert 'Unmute' in configform.SPEAKER_HINT[True]
|
|
|
|
|
|
def test_the_window_uses_the_icon_and_sets_the_font_explicitly():
|
|
"""With the window's default family those code points are private-use
|
|
and render as an empty box, and an empty box beside every checklist row
|
|
is worse than a word."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert 'configform.SPEAKER_MUTED' in source
|
|
assert 'configform.SPEAKER_ON' in source
|
|
assert 'configform.SPEAKER_FONT' in source
|
|
assert 'FontFamily' in source
|
|
|
|
|
|
# ------------------------------------------------------- no Chinese on screen
|
|
|
|
def _cjk(text):
|
|
return bool(re.search(u'[一-鿿 -〿-]', text))
|
|
|
|
|
|
def test_no_chinese_reaches_the_window():
|
|
""""另外 UI 里面不要有中文。啥玩意儿啊这是?" -- 2026-08-30, on finding a
|
|
`RUN_OPTIONS` note quoting a ruling in Chinese under a control.
|
|
|
|
A `note` is not a comment: `configform.run_fields()` and
|
|
`settings_fields()` copy it straight onto the screen under its control.
|
|
So the rule is a test, like the no-hover-text one: the verbatim Chinese
|
|
belongs in a `#` comment, which nothing renders, and the sentence on
|
|
screen is in English.
|
|
"""
|
|
from vibe import configmodel as CM
|
|
bad = []
|
|
for row in CM.SETTINGS:
|
|
for part in (row['label'], row.get('note'), row.get('why')):
|
|
if part and _cjk(part):
|
|
bad.append(('configmodel.SETTINGS', row['key']))
|
|
for row in CM.RUN_OPTIONS:
|
|
for part in (row['label'], row.get('note')):
|
|
if part and _cjk(part):
|
|
bad.append(('configmodel.RUN_OPTIONS', row['key']))
|
|
for field in configform.fields():
|
|
for part in (field.get('label'), field.get('note')):
|
|
if part and _cjk(part):
|
|
bad.append(('configform field', field['key']))
|
|
for _key, title, note in configform.MODULES:
|
|
if _cjk(title) or _cjk(note):
|
|
bad.append(('configform.MODULES', title))
|
|
for _label, key, why in configform.not_shown_rows():
|
|
if _cjk(why):
|
|
bad.append(('configform.NOT_SHOWN', key))
|
|
assert bad == [], (
|
|
'these strings are drawn in the Project Config window and carry '
|
|
'Chinese: {0}. Move the verbatim quote into a `#` comment and put '
|
|
'English on the screen.'.format(bad))
|
|
|
|
|
|
def test_no_chinese_in_the_xaml_or_in_a_string_the_window_prints():
|
|
"""The window's own chrome and every sentence it builds. Comments and
|
|
docstrings are exempt: pyRevit renders neither, and the owner's verbatim
|
|
words are the provenance this repo keeps everywhere."""
|
|
assert not _cjk(_xaml())
|
|
tree = _script_ast()
|
|
literals = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str) and _cjk(node.value):
|
|
literals.append((node.lineno, node.value[:40]))
|
|
docstrings = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Module)):
|
|
body = getattr(node, 'body', None) or []
|
|
if body and isinstance(body[0], ast.Expr) and \
|
|
isinstance(body[0].value, ast.Constant) and \
|
|
isinstance(body[0].value.value, str):
|
|
docstrings.add(body[0].value.lineno)
|
|
leaked = [row for row in literals if row[0] not in docstrings]
|
|
assert leaked == [], (
|
|
'these string literals in the window carry Chinese and are not '
|
|
'docstrings, so they can reach the screen: {0}'.format(leaked))
|
|
|
|
|
|
def test_the_window_draws_the_checklist_rather_than_asking_for_a_number():
|
|
"""THE DEFECT. He looked for this control twice and did not find it
|
|
twice, because it was a 60-pixel box you typed a checklist number into
|
|
under a heading called "Muted checklist items". A control you can only
|
|
work if you already know the answer is not a control. So the window
|
|
builds its rows from the real table and never asks for a number."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert 'configform.mute_table' in source
|
|
assert 'configform.mute_summary' in source
|
|
# The old add-row: a number box and an Add button, both gone.
|
|
assert 'self.mute_item' not in source
|
|
assert "Content = 'Add'" not in source
|
|
|
|
|
|
def test_the_window_writes_the_job_layer_of_the_mute_map_and_not_the_merge():
|
|
"""`self.mutes` is the job layer's own map -- the only thing a splice may
|
|
replace. Handing the MERGE to `remember` would copy the plant's entries
|
|
into the job file, where they would stop tracking the plant for good."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert 'configmodel.layer_map' in source or \
|
|
"row['value']" in source
|
|
assert 'self.remember(self.rows[\'checks.muted\'], dict(self.mutes))' in \
|
|
source
|
|
|
|
|
|
def test_the_mute_list_and_the_full_view_read_the_same_rows():
|
|
"""One table, two surfaces. The page's `set by` column and the window's
|
|
buttons cannot disagree about whose mute an entry is if they are built
|
|
from the same call."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert source.count('configform.mute_table') >= 2
|
|
|
|
|
|
def test_the_script_never_prints_while_a_window_is_open():
|
|
"""Defect 2. A modal dialog closing re-activates Revit's main window and
|
|
the output page goes behind it, so nothing may be printed before the
|
|
dialog returns -- `output.print_*` must not appear inside ConfigWindow.
|
|
"""
|
|
for node in ast.walk(_window_class()):
|
|
if not isinstance(node, ast.Attribute):
|
|
continue
|
|
value = getattr(node, 'value', None)
|
|
if isinstance(value, ast.Name) and value.id == 'output':
|
|
assert not node.attr.startswith('print'), (
|
|
'ConfigWindow calls output.{0} -- the page must not be '
|
|
'written while the modal window is up.'.format(node.attr))
|
|
|
|
|
|
# ------------------------------------- which FILE the plant/public layer reads
|
|
|
|
def test_the_two_knowledge_source_pickers_are_in_job_knowledge():
|
|
plant = row_for('knowledge_sources.plant_file')
|
|
public = row_for('knowledge_sources.public_file')
|
|
assert plant['module'] == configform.JOB_KNOWLEDGE
|
|
assert public['module'] == configform.JOB_KNOWLEDGE
|
|
assert plant['kind'] == 'filepick'
|
|
assert public['kind'] == 'filepick'
|
|
assert plant['source'] == 'job'
|
|
assert public['source'] == 'job'
|
|
assert plant['label'] and plant['label'] != plant['key']
|
|
assert public['label'] and public['label'] != public['key']
|
|
|
|
|
|
def test_a_filepick_field_parses_like_a_path():
|
|
"""Blank means "go back to the default discovery", the same as any other
|
|
path field -- Browse only ever fills the same box, so a chosen file and a
|
|
typed one are parsed one way."""
|
|
field = row_for('knowledge_sources.plant_file')
|
|
ok, value, _why = configform.parse(field, ' ')
|
|
assert ok and value is None
|
|
ok, value, _why = configform.parse(field, r'C:\plant\knowledge.yaml')
|
|
assert ok and value == r'C:\plant\knowledge.yaml'
|
|
|
|
|
|
def test_a_filepick_field_not_known_anywhere_says_not_set():
|
|
field = row_for('knowledge_sources.public_file')
|
|
assert field['chip'] == '?'
|
|
assert field['display'] == '(not set)'
|
|
|
|
|
|
def test_a_filepick_field_the_job_sets_shows_the_path_and_the_job_chip():
|
|
field = row_for('knowledge_sources.plant_file',
|
|
job={'knowledge_sources': {
|
|
'plant_file': 'C:/plant/knowledge.yaml'}})
|
|
assert field['chip'] == 'J'
|
|
assert field['display'] == 'C:/plant/knowledge.yaml'
|
|
|
|
|
|
# --------------------------------------------------- never set vs cleared
|
|
|
|
def test_knowledge_source_state_never_set():
|
|
res = {'known': False, 'value': None, 'layer': None}
|
|
said = configform.knowledge_source_state(res)
|
|
assert 'Never set' in said
|
|
|
|
|
|
def test_knowledge_source_state_cleared_is_not_the_same_sentence_as_never_set():
|
|
"""EMPTY AND ABSENT ARE DIFFERENT STATES. A job file that states the key
|
|
blank is a decision made in this window; a job file that never mentions
|
|
it is every install before this feature existed."""
|
|
cleared = configform.knowledge_source_state(
|
|
{'known': True, 'value': None, 'layer': 'job'})
|
|
never = configform.knowledge_source_state(
|
|
{'known': False, 'value': None, 'layer': None})
|
|
assert cleared != never
|
|
assert 'Cleared' in cleared
|
|
assert 'Never set' in never
|
|
|
|
|
|
def test_knowledge_source_state_set_names_the_layer_and_the_path():
|
|
said = configform.knowledge_source_state(
|
|
{'known': True, 'value': 'C:/plant/knowledge.yaml', 'layer': 'job'})
|
|
assert 'job' in said
|
|
assert 'C:/plant/knowledge.yaml' in said
|
|
|
|
|
|
# ------------------------------------------------- the window: Browse wiring
|
|
|
|
def test_the_window_offers_a_browse_button_filtered_to_yaml():
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert "kind == 'filepick'" in source
|
|
assert 'filepick_for' in source
|
|
assert 'filepick_click' in source
|
|
assert "forms.pick_file" in source
|
|
assert "file_ext='yaml'" in source
|
|
|
|
|
|
def test_browse_writes_into_the_same_box_save_reads():
|
|
"""Browse is a shortcut for typing, not a second mechanism -- the chosen
|
|
path has to reach `self.typed`/`self.controls` the same way a keystroke
|
|
does, or the missing-file refusal and the empty-vs-absent split would
|
|
have two code paths to keep in step instead of one."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
click = source[source.index('def filepick_click'):
|
|
source.index('def toggle_click')]
|
|
assert 'box.Text = chosen' in click
|
|
assert 'self.typed[key] = chosen' in click
|
|
|
|
|
|
def test_the_window_warns_loudly_on_a_missing_knowledge_source_pointer():
|
|
"""`build_notes` is what the window shows before anybody has touched a
|
|
single control -- the loud, named refusal for a pointer that has gone
|
|
missing since it was last set has to be there, not only inside `Save`."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert 'configmodel.knowledge_source_problems' in source
|
|
|
|
|
|
def test_the_window_shows_what_is_really_being_read_right_now():
|
|
"""`knowledge.sources(doc)` is the ground truth for where the plant and
|
|
public layers are read from today, independent of the picker's own
|
|
write-only state -- see `configmodel.KNOWLEDGE_SOURCE_KEYS`."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert 'knowledge.sources(doc)' in source
|
|
assert 'knowledge_source_info_rows' in source
|
|
|
|
|
|
# --------------------------------------------- job.plant: the plant dropdown
|
|
#
|
|
# Gitea will/bridge issue #2. `knowledge.detect_plant` resolves a plant in
|
|
# three levels and the owner's ruling is that when all three miss, the
|
|
# engineer picks the plant ONCE, here, and it is written to the job layer as
|
|
# `job.plant`.
|
|
|
|
def test_auto_detect_is_not_a_usable_plant_id():
|
|
"""The sentinel must never collide with a real plant folder name --
|
|
`plantpath._PLANT_ID_RE` requires the first character to be alphanumeric,
|
|
which `(` is not, but this is asserted directly rather than trusted."""
|
|
assert not plantpath.valid_plant_id(configform.AUTO_DETECT)
|
|
|
|
|
|
def test_plant_options_lists_auto_detect_first_then_the_plant_folders(
|
|
tmp_path, monkeypatch):
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(tmp_path))
|
|
for name in ('alpha', 'bravo'):
|
|
(tmp_path / name).mkdir()
|
|
(tmp_path / name / 'knowledge.yaml').write_text(u'plant:\n id: %s\n'
|
|
% name)
|
|
assert configform.plant_options() == (configform.AUTO_DETECT, 'alpha',
|
|
'bravo')
|
|
|
|
|
|
def test_plant_options_is_read_fresh_not_cached(tmp_path, monkeypatch):
|
|
"""A plant folder can be added mid-session (this file, like every other
|
|
`vibe` module, is held by pyRevit's engine until Revit restarts), so the
|
|
options list must be recomputed every call rather than baked into a
|
|
module-level tuple at import time."""
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(tmp_path))
|
|
assert configform.plant_options() == (configform.AUTO_DETECT,)
|
|
(tmp_path / 'charlie').mkdir()
|
|
(tmp_path / 'charlie' / 'knowledge.yaml').write_text(u'plant:\n id: c\n')
|
|
assert configform.plant_options() == (configform.AUTO_DETECT, 'charlie')
|
|
|
|
|
|
def test_job_plant_is_a_combo_in_job_knowledge():
|
|
field = row_for('job.plant')
|
|
assert field is not None
|
|
assert field['module'] == configform.JOB_KNOWLEDGE
|
|
assert field['kind'] == 'combo'
|
|
assert field['source'] == 'job'
|
|
assert field['label'] and field['label'] != field['key']
|
|
|
|
|
|
def test_job_plant_not_known_anywhere_says_not_set():
|
|
field = row_for('job.plant')
|
|
assert field['chip'] == '?'
|
|
assert field['display'] == '(not set)'
|
|
|
|
|
|
def test_job_plant_set_by_the_job_shows_the_id_and_the_job_chip():
|
|
field = row_for('job.plant', job={'job': {'plant': 'northgate'}})
|
|
assert field['chip'] == 'J'
|
|
assert field['value'] == 'northgate'
|
|
assert field['display'] == 'northgate'
|
|
|
|
|
|
def test_job_plant_cleared_is_known_but_none_and_reads_as_auto_detect():
|
|
"""EMPTY AND ABSENT ARE DIFFERENT STATES, the same distinction
|
|
`knowledge_source_state` keeps for the filepick rows: a job file that
|
|
explicitly names `job.plant: null` is a decision (auto-detect chosen on
|
|
purpose), not silence."""
|
|
field = row_for('job.plant', job={'job': {'plant': None}})
|
|
assert field['chip'] == 'J'
|
|
assert field['value'] is None
|
|
assert field['display'] == configform.AUTO_DETECT
|
|
|
|
|
|
def test_display_combo_none_is_auto_detect_and_known_true_by_default():
|
|
"""`change_lines` (the pending-edit summary) calls `display` with its
|
|
default `known=True`, so a pending edit TO auto-detect must render as the
|
|
word, never as `(not set)` -- that word means something else entirely
|
|
(nothing decides this, anywhere)."""
|
|
field = {'kind': 'combo', 'source': 'job'}
|
|
assert configform.display(field, None) == configform.AUTO_DETECT
|
|
assert configform.display(field, 'northgate') == 'northgate'
|
|
|
|
|
|
def test_job_plant_is_not_a_configmodel_setting():
|
|
"""`job.plant` is EXTRA, like the two knowledge_sources.* pickers --
|
|
`configmodel.SETTINGS` carries no row for it, and `unmapped()` /
|
|
`not_shown_rows()` (which only inspect `configmodel.editable()`) must
|
|
keep passing without ever mentioning it."""
|
|
assert configmodel.setting('job.plant') is None
|
|
assert 'job.plant' not in configform.unmapped()
|
|
assert 'job.plant' not in [key for _l, key, _w in configform.not_shown_rows()]
|
|
|
|
|
|
def test_job_plant_validate_blank_is_fine():
|
|
assert configmodel.validate('job.plant', None, {}) == []
|
|
|
|
|
|
def test_job_plant_validate_refuses_a_malformed_id():
|
|
said = configmodel.validate('job.plant', 'north/gate', {})
|
|
assert said and 'folder name' in said[0]
|
|
|
|
|
|
def test_job_plant_validate_refuses_a_plant_with_no_folder(tmp_path,
|
|
monkeypatch):
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(tmp_path))
|
|
said = configmodel.validate('job.plant', 'nosuchplant', {})
|
|
assert said and 'not a plant folder' in said[0]
|
|
|
|
|
|
def test_job_plant_validate_accepts_a_real_plant_folder(tmp_path, monkeypatch):
|
|
monkeypatch.setenv(plantpath.PLANT_ENV, str(tmp_path))
|
|
(tmp_path / 'northgate').mkdir()
|
|
(tmp_path / 'northgate' / 'knowledge.yaml').write_text(
|
|
u'plant:\n id: northgate\n')
|
|
assert configmodel.validate('job.plant', 'northgate', {}) == []
|
|
|
|
|
|
def test_the_window_builds_a_real_dropdown_not_a_toggle_for_the_plant():
|
|
"""A plant list is arbitrarily long, so `job.plant` must not reuse
|
|
`toggle_for`'s click-to-cycle control -- a real `ComboBox`, built fresh
|
|
from `configform.plant_options()`."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert "kind == 'combo'" in source
|
|
assert 'combo_for' in source
|
|
assert 'combo_changed' in source
|
|
assert 'configform.plant_options()' in source
|
|
assert 'C.ComboBox()' in source
|
|
|
|
|
|
def test_the_window_shows_the_current_plant_choice_beside_the_dropdown():
|
|
"""`knowledge.plant_choice(doc)` -- the SAME three-level resolution the
|
|
dropdown is about to change -- is read and shown beside it, so a person
|
|
sees how the job is recognised today before touching the control."""
|
|
source = io.open(SCRIPT, encoding='utf-8').read()
|
|
assert 'plant_choice_line' in source
|
|
assert 'knowledge.plant_choice(doc)' in source
|