Files
willtools-demo/WillTools.extension/tests/test_button_tooltips.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

255 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""No button may carry hover text. This is the test that keeps it that way.
The owner, twice: hovering a button puts a wall of words on screen, and he
does not want them. The words come from two places and pyRevit merges them,
so trimming one and leaving the other looks fixed and is not:
* `bundle.yaml`'s `tooltip:` -- read by
`pyrevit.extensions.genericcomps.GenericUIComponent._read_bundle_metadata`;
* the script's module docstring -- read by
`GenericUICommand._read_bundle_metadata_from_python_script`, but ONLY
`if not self.meta`, i.e. only when the button has no `bundle.yaml` at all.
That `if not self.meta` is why the docstring half has to be guarded even
though every button here does have a `bundle.yaml`: the day someone trims a
`bundle.yaml` down to nothing, or a new button ships without one, every
docstring in this extension becomes hover text again. Guard both, and the
regression cannot come back through either door.
`bundle.yaml`'s `author:` is guarded for the same reason.
`pyrevit.loader.uimaker._make_button_tooltip` appends an `Author(s):` block
whenever the button declares one, so an author line is three more lines of
hover text.
WHAT IS STILL LEFT ON SCREEN, AND WHY THIS TEST CANNOT REMOVE IT
-----------------------------------------------------------------
`_make_button_tooltip` unconditionally appends
Bundle Name:
<Name> (pushbutton)
That text is pyRevit's, not ours -- there is no bundle setting that
suppresses it. With `tooltip:` and `author:` gone, those two lines under the
button's own title are the whole of the hover text, which is the floor this
repository can reach without patching the pyRevit install.
Verified against the pyrevitlib shipped with pyRevit 5.0.1 at
`%APPDATA%\\pyRevit-Master\\pyrevitlib\\pyrevit\\`.
"""
import ast
import io
import os
import pytest
from vibe import yamlkit
_EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# The ribbon, and the whole of what this suite checks.
#
# There was a second root here until 2026-08-30: `parked/`, a folder outside
# every `*.tab` holding buttons taken off the ribbon but kept whole. The
# 2026-08-30 restructure emptied it -- Job Recon and Takeoff came back onto
# the ribbon inside `Setup.panel/Config.pulldown` -- and the folder was
# deleted with them.
#
# The constant went with it rather than staying as a tolerated-empty path.
# `os.walk` over a directory that does not exist raises nothing and yields
# nothing, so a `PARKED` left pointing at thin air is a guard that passes by
# checking zero buttons, which is precisely the failure
# `test_the_walk_found_the_buttons` exists to make impossible. If parking
# comes back, bring the constant back WITH a count of its own.
TAB = os.path.join(_EXT, 'WillTools.tab')
# Anything longer than this in a lone string statement at the top of a script
# is prose someone meant as documentation. Note the guard rejects an empty
# docstring too -- see `_docstring_of`.
_ADVICE = (
"Documentation belongs in `#` comments under the coding cookie, where "
"pyRevit cannot render it. Move the prose down and delete the string.")
def _buttons(where):
"""Every `*.pushbutton` folder under `where`, as (name, path) pairs.
The walk is recursive on purpose: buttons live at two depths, some
directly on a panel and some inside a `*.pulldown` folder under one.
(Before 2026-08-30 the nested ones were inside `*.stack` folders instead;
the walk never cared which, and that is the point of walking.)
"""
found = []
for root, dirs, _files in os.walk(where):
for name in dirs:
if name.endswith('.pushbutton'):
found.append((name, os.path.join(root, name)))
found.sort()
return found
BUTTONS = _buttons(TAB)
# The ones that are NOT a direct child of a `*.panel` -- today, the five rows
# behind Config and the two behind DesignHub. Counted separately below.
NESTED = [b for b in BUTTONS
if not os.path.basename(os.path.dirname(b[1])).endswith('.panel')]
def _ids(pair):
return pair[0]
def test_the_walk_found_the_buttons():
"""If the layout moves, this suite must fail loudly rather than pass by
finding nothing to check.
Every test below is parametrized over `BUTTONS`. An empty `BUTTONS` is not
a failure in pytest -- it is a clean run of zero tests, which looks exactly
like a pass. This is the test that stops that.
Two counts, because one number cannot say both things.
The first is the whole ribbon: twelve buttons as of 2026-08-30, four large
ones on Tools, five behind Config, two behind DesignHub and GOD_MOD alone
on Lab. Rename or move `WillTools.tab` and this goes to zero.
The second is the nested ones: seven of the twelve are a row inside a
`*.pulldown` rather than a direct child of a `*.panel`. That count exists
because the first cannot see the failure that actually happened before --
a walk that stops recursing, or a container folder renamed to something
the walk no longer descends into, still finds the five top-level buttons
and looks healthy. Watch both and neither failure is quiet.
"""
assert len(BUTTONS) >= 12, (
'expected at least 12 pushbuttons under {0}, found {1}. If the ribbon '
'really shrank, lower this number; if the tab folder moved, fix TAB '
'-- do not let a tooltip guard pass because it checked nothing.'
.format(TAB, len(BUTTONS)))
assert len(NESTED) >= 7, (
'expected at least 7 of the {0} pushbuttons under {1} to sit inside a '
'container (a `*.pulldown` today) rather than directly on a `*.panel`, '
'found {2}. If the ribbon really was flattened, lower this number; if '
'the walk stopped descending, fix `_buttons` -- a guard that only ever '
'reaches the top level checks the buttons nobody was worried about.'
.format(len(BUTTONS), TAB, len(NESTED)))
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
def test_bundle_declares_no_tooltip(button):
name, path = button
meta_file = os.path.join(path, 'bundle.yaml')
if not os.path.isfile(meta_file):
return # no bundle.yaml is no tooltip
meta = yamlkit.parse(io.open(meta_file, encoding='utf-8').read()) or {}
tooltip = meta.get('tooltip')
assert not tooltip, (
'{0} has hover text again: bundle.yaml declares\n'
' tooltip: {1!r}\n'
'Every button on this ribbon is meant to hover with nothing on it '
'(owner, 2026-08-30). Delete the `tooltip:` key from\n'
' {2}\n'
'{3}'.format(name, tooltip, meta_file, _ADVICE))
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
def test_bundle_declares_no_author(button):
name, path = button
meta_file = os.path.join(path, 'bundle.yaml')
if not os.path.isfile(meta_file):
return
meta = yamlkit.parse(io.open(meta_file, encoding='utf-8').read()) or {}
author = meta.get('author') or meta.get('authors')
assert not author, (
'{0} has hover text again: bundle.yaml declares an author ({1!r}), '
'and pyRevit prints an "Author(s):" block on hover for every button '
'that has one. Delete the `author:` key from\n'
' {2}'.format(name, author, meta_file))
def _docstring_of(source):
"""What pyRevit's own reader would get.
`coreutils.ScriptFileParser.get_docstring` is `ast.get_docstring` plus a
truthiness check, so an empty or whitespace-only docstring reads as no
docstring at all -- pyRevit renders nothing for it, no placeholder. This
guard is stricter on purpose and rejects the empty string too: a `""" """`
left at the top of a file is an invitation for the next agent to write a
paragraph inside it, and that paragraph goes straight to the ribbon.
"""
return ast.get_docstring(ast.parse(source))
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
def test_script_declares_no_author(button):
"""`__author__` (or `__authors__`) in `script.py` is the same trap as the
module docstring, one door further in.
`GenericUICommand._read_bundle_metadata_from_python_script` only reads a
script's dunder metadata `if not self.meta` -- exactly like the
docstring case above -- so on every button here, which all have a
`bundle.yaml`, an `__author__` line sits inert today. It stops being
inert the day a `bundle.yaml` is trimmed to nothing or a new button
ships without one: then pyRevit reads `__author__` (or `__authors__`)
straight off the script and prints the same "Author(s):" block that
`test_bundle_declares_no_author` guards against on the bundle side.
pyRevit 5.0.1 additionally has a bug at `genericcomps.py:733` where
`__author__` is read and then immediately overwritten by `__authors__`,
so on that version specifically the line is discarded either way. That
is a bug, not a guarantee, and a later pyRevit fixing it is exactly the
kind of change that would make an unremoved `__author__` line start
talking again with nobody touching this file. Guard the source, not the
version-specific bug.
"""
name, path = button
script = os.path.join(path, 'script.py')
assert os.path.isfile(script), 'no script.py under {0}'.format(path)
source = io.open(script, encoding='utf-8').read()
tree = ast.parse(source)
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if getattr(target, 'id', None) in ('__author__', '__authors__'):
assert False, (
'{0} declares {1} in script.py again:\n'
' {2}:{3}\n'
'It reads as dead code today only because every button '
'has a bundle.yaml -- pyRevit reads a script dunder for '
'metadata only `if not self.meta`. Trim a bundle.yaml to '
'nothing, or ship a button without one, and this becomes '
'an "Author(s):" block on hover. Delete the {1} line '
'from\n'
' {2}'.format(name, target.id, script, node.lineno))
@pytest.mark.parametrize('button', BUTTONS, ids=_ids)
def test_script_has_no_module_docstring(button):
name, path = button
# `script.py` is what pyRevit parses for metadata (`self.script_file`);
# side modules in the same folder are never read for a docstring.
script = os.path.join(path, 'script.py')
assert os.path.isfile(script), 'no script.py under {0}'.format(path)
source = io.open(script, encoding='utf-8').read()
body = ast.parse(source).body
opener = body[0] if body else None
value = getattr(opener, 'value', None)
# `ast.Constant`, not the `ast.Str` alias -- the alias warns on 3.12 and
# is gone on 3.14, and this suite runs on host CPython.
is_string_stmt = (isinstance(opener, ast.Expr)
and isinstance(value, ast.Constant)
and isinstance(value.value, str))
assert not is_string_stmt, (
'{0} has a module docstring again:\n'
' {1}:{2}\n'
' {3!r}\n'
"pyRevit renders the module docstring as the button's hover text "
'whenever the bundle has no `tooltip:` -- and for a button with no '
'`bundle.yaml` at all, it is the only thing it renders. The owner '
'wants nothing on hover.\n'
'{4}'.format(name, script, opener.lineno,
_docstring_of(source), _ADVICE))