add edit-config, a Textual TUI for editing a configuration
this tree UI allows for viewing and basic editing of a configuration section from a board. it does a decent job of displaying most of the settings, and editing is equally convenient, as in it tries to handle enums correctly, but doesn't validate pins or handle long binary strings well. saving is done in place --- if a config/storage section was opened, a config section (no padding) is what results. if a whole board was opened, the whole binary is rewritten with the new offset config section. this way, a whole board dump can be changed in place, or a new config can be made for use in e.g. concatenate to build an image many enhancements to come over time
This commit is contained in:
parent
9b43ac824d
commit
7d5052e811
22
README.md
22
README.md
@ -23,6 +23,28 @@ At some point we may publish packages to e.g. pypi.
|
||||
|
||||
As above, plus also `pip install -Ur requirements/requirements-dev.txt` to get linters and whatnot.
|
||||
|
||||
## Config Editor
|
||||
|
||||
[](https://asciinema.org/a/67hELtUNkKCit4dFwYeAUa2fo)
|
||||
|
||||
A terminal UI config editor, capable of viewing and editing existing configurations, can be launched via
|
||||
**edit-config**. It supports navigation both via the keyboard or the mouse.
|
||||
|
||||
Simple usage:
|
||||
|
||||
| Key(s) | Action |
|
||||
|-----------------------|--------------------------------------------------------|
|
||||
| Up, Down | Move up and down the config tree |
|
||||
| Left, Right | Scroll the tree left and right (when content is long) |
|
||||
| Space | Expand a tree node to show its children |
|
||||
| Enter | Expand a tree node, or edit a leaf node (bools toggle) |
|
||||
| Tab (in edit popup) | Cycle highlight between input field and buttons |
|
||||
| Enter (in edit popup) | Choose dropdown option or activate button |
|
||||
| S | Save the config to the opened file |
|
||||
| Q | Quit without saving |
|
||||
|
||||
A quick demonstration of the editor is available [on asciinema.org](https://asciinema.org/a/67hELtUNkKCit4dFwYeAUa2fo).
|
||||
|
||||
## Tools
|
||||
|
||||
In all cases, online help can be retrieved by providing the `-h` or ``--help`` flags to the below programs.
|
||||
|
50
gp2040ce_bintools/config_tree.css
Normal file
50
gp2040ce_bintools/config_tree.css
Normal file
@ -0,0 +1,50 @@
|
||||
Tree {
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
Button {
|
||||
border: round gray;
|
||||
content-align: center middle;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
EditScreen, MessageScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
EditScreen Label {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#edit-dialog {
|
||||
grid-size: 2;
|
||||
grid-rows: 1fr 3fr 1fr 2fr;
|
||||
padding: 0 1;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border: tall gray 100%;
|
||||
}
|
||||
|
||||
#message-dialog {
|
||||
padding: 0 1;
|
||||
grid-rows: 3fr 2fr;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border: tall gray 100%;
|
||||
}
|
||||
|
||||
#field-name, #field-input, #input-errors {
|
||||
column-span: 2;
|
||||
}
|
||||
|
||||
#field-input {
|
||||
border: solid white;
|
||||
}
|
265
gp2040ce_bintools/gui.py
Normal file
265
gp2040ce_bintools/gui.py
Normal file
@ -0,0 +1,265 @@
|
||||
"""GUI applications for working with binary files."""
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from google.protobuf import descriptor
|
||||
from google.protobuf.message import Message
|
||||
from rich.highlighter import ReprHighlighter
|
||||
from rich.text import Text
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Grid
|
||||
from textual.logging import TextualHandler
|
||||
from textual.screen import ModalScreen
|
||||
from textual.validation import Number
|
||||
from textual.widgets import Button, Footer, Header, Input, Label, Pretty, Select, Tree
|
||||
from textual.widgets.tree import TreeNode
|
||||
|
||||
from gp2040ce_bintools import core_parser, handler
|
||||
from gp2040ce_bintools.builder import write_new_config_to_filename
|
||||
from gp2040ce_bintools.storage import get_config_from_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditScreen(ModalScreen):
|
||||
"""Do an input prompt by way of an overlaid screen."""
|
||||
|
||||
def __init__(self, node: TreeNode, field_value: object, *args, **kwargs):
|
||||
"""Save the config field info for later usage."""
|
||||
logger.debug("constructing EditScreen for %s", node.label)
|
||||
self.node = node
|
||||
parent_config, field_descriptor = node.data
|
||||
self.parent_config = parent_config
|
||||
self.field_descriptor = field_descriptor
|
||||
self.field_value = field_value
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Build the pop-up window with this result."""
|
||||
if self.field_descriptor.type == descriptor.FieldDescriptor.TYPE_ENUM:
|
||||
options = [(d.name, v) for v, d in self.field_descriptor.enum_type.values_by_number.items()]
|
||||
self.input_field = Select(options, value=self.field_value, id='field-input')
|
||||
elif self.field_descriptor.type in (descriptor.FieldDescriptor.TYPE_INT32,
|
||||
descriptor.FieldDescriptor.TYPE_INT64,
|
||||
descriptor.FieldDescriptor.TYPE_UINT32,
|
||||
descriptor.FieldDescriptor.TYPE_UINT64):
|
||||
self.input_field = Input(value=repr(self.field_value), validators=[Number()], id='field-input')
|
||||
elif self.field_descriptor.type == descriptor.FieldDescriptor.TYPE_STRING:
|
||||
self.input_field = Input(value=self.field_value, id='field-input')
|
||||
else:
|
||||
# we don't handle whatever these are yet
|
||||
self.input_field = Label(repr(self.field_value), id='field-input')
|
||||
yield Grid(
|
||||
Label(self.field_descriptor.full_name, id="field-name"),
|
||||
self.input_field,
|
||||
Pretty('', id='input-errors', classes='hidden'),
|
||||
Button("Save", id='save-button'),
|
||||
Button("Cancel", id='cancel-button'),
|
||||
id='edit-dialog',
|
||||
)
|
||||
|
||||
@on(Input.Changed)
|
||||
def show_invalid_reasons(self, event: Input.Changed) -> None:
|
||||
"""Update the UI to show why validation failed."""
|
||||
if event.validation_result:
|
||||
error_field = self.query_one(Pretty)
|
||||
save_button = self.query_one('#save-button', Button)
|
||||
if not event.validation_result.is_valid:
|
||||
error_field.update(event.validation_result.failure_descriptions)
|
||||
error_field.classes = ''
|
||||
save_button.disabled = True
|
||||
else:
|
||||
error_field.update('')
|
||||
error_field.classes = 'hidden'
|
||||
save_button.disabled = False
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Process the button actions."""
|
||||
if event.button.id == 'save-button':
|
||||
logger.debug("calling _save")
|
||||
self._save()
|
||||
self.app.pop_screen()
|
||||
|
||||
def _save(self):
|
||||
"""Save the field value to the retained config item."""
|
||||
if not isinstance(self.input_field, Label):
|
||||
if self.field_descriptor.type in (descriptor.FieldDescriptor.TYPE_INT32,
|
||||
descriptor.FieldDescriptor.TYPE_INT64,
|
||||
descriptor.FieldDescriptor.TYPE_UINT32,
|
||||
descriptor.FieldDescriptor.TYPE_UINT64):
|
||||
field_value = int(self.input_field.value)
|
||||
else:
|
||||
field_value = self.input_field.value
|
||||
setattr(self.parent_config, self.field_descriptor.name, field_value)
|
||||
logger.debug("parent config post-change: %s", self.parent_config)
|
||||
self.node.set_label(pb_field_to_node_label(self.field_descriptor, field_value))
|
||||
|
||||
|
||||
class MessageScreen(ModalScreen):
|
||||
"""Simple screen for displaying messages."""
|
||||
|
||||
def __init__(self, text: str, *args, **kwargs):
|
||||
"""Store the message for later display."""
|
||||
self.text = text
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Build the pop-up window with the desired message displayed."""
|
||||
yield Grid(
|
||||
Label(self.text, id="message-text"),
|
||||
Button("OK", id='ok-button'),
|
||||
id='message-dialog',
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Process the button action (close the window)."""
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
class ConfigEditor(App):
|
||||
"""Display the GP2040-CE configuration as a tree."""
|
||||
|
||||
BINDINGS = [
|
||||
('s', 'save', "Save Config"),
|
||||
('q', 'quit', "Quit"),
|
||||
]
|
||||
CSS_PATH = "config_tree.css"
|
||||
TITLE = "GP2040-CE Configuration Editor"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize config."""
|
||||
self.config_filename = kwargs.pop('config_filename')
|
||||
self.whole_board = kwargs.pop('whole_board', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# disable normal logging and enable console logging if we're not headless
|
||||
logger.debug("reconfiguring logging...")
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG)
|
||||
root.removeHandler(handler)
|
||||
root.addHandler(TextualHandler())
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the UI."""
|
||||
yield Header()
|
||||
yield Footer()
|
||||
yield Tree("Root")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Load the configuration object into the tree view."""
|
||||
self.config = get_config_from_file(self.config_filename, whole_board=self.whole_board)
|
||||
tree = self.query_one(Tree)
|
||||
|
||||
def add_node(parent_node: TreeNode, parent_config: Message,
|
||||
field_descriptor: descriptor.FieldDescriptor, field_value: object) -> None:
|
||||
"""Add a node to the overall tree, recursively.
|
||||
|
||||
Args:
|
||||
parent_node: parent node to attach the new node(s) to
|
||||
parent_config: the Config object parent. parent_config + field_descriptor.name = this node
|
||||
field_descriptor: descriptor for the protobuf field
|
||||
field_value: data to add to the parent node as new node(s)
|
||||
"""
|
||||
# all nodes relate to their parent and retain info about themselves
|
||||
this_node = parent_node.add("")
|
||||
this_node.data = (parent_config, field_descriptor)
|
||||
this_node.set_label(pb_field_to_node_label(field_descriptor, field_value))
|
||||
|
||||
if field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE:
|
||||
# a message has stuff under it, recurse into it
|
||||
this_config = getattr(parent_config, field_descriptor.name)
|
||||
for child_field_descriptor, child_field_value in sorted(field_value.ListFields(),
|
||||
key=lambda f: f[0].name):
|
||||
add_node(this_node, this_config, child_field_descriptor, child_field_value)
|
||||
else:
|
||||
# leaf node, stop here
|
||||
this_node.allow_expand = False
|
||||
|
||||
tree.root.data = (None, self.config.DESCRIPTOR)
|
||||
tree.root.set_label(self.config_filename)
|
||||
for field_descriptor, field_value in sorted(self.config.ListFields(), key=lambda f: f[0].name):
|
||||
add_node(tree.root, self.config, field_descriptor, field_value)
|
||||
tree.root.expand()
|
||||
|
||||
def on_tree_node_selected(self, node_event: Tree.NodeSelected) -> None:
|
||||
"""Take the appropriate action for this type of node."""
|
||||
self._modify_node(node_event.node)
|
||||
|
||||
def action_save(self) -> None:
|
||||
"""Save the configuration."""
|
||||
write_new_config_to_filename(self.config, self.config_filename, inject=self.whole_board)
|
||||
self.push_screen(MessageScreen(f"Configuration saved to {self.config_filename}."))
|
||||
|
||||
def action_quit(self) -> None:
|
||||
"""Quit the application."""
|
||||
self.exit()
|
||||
|
||||
def _modify_node(self, node: TreeNode) -> None:
|
||||
"""Modify the selected node by context of what type of config item it is."""
|
||||
parent_config, field_descriptor = node.data
|
||||
|
||||
# don't do anything special with selecting expandable nodes, since the framework already expands them
|
||||
if (isinstance(field_descriptor, descriptor.Descriptor) or
|
||||
field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE):
|
||||
return
|
||||
|
||||
field_value = getattr(parent_config, field_descriptor.name)
|
||||
if field_descriptor.type == descriptor.FieldDescriptor.TYPE_BOOL:
|
||||
# toggle bools inline
|
||||
logger.debug("changing %s from %s...", field_descriptor.name, field_value)
|
||||
field_value = not field_value
|
||||
logger.debug("...to %s", field_value)
|
||||
setattr(parent_config, field_descriptor.name, field_value)
|
||||
node.data = (parent_config, field_descriptor)
|
||||
node.set_label(pb_field_to_node_label(field_descriptor, field_value))
|
||||
logger.debug(self.config)
|
||||
else:
|
||||
logger.debug("opening edit screen for %s", field_descriptor.name)
|
||||
self.push_screen(EditScreen(node, field_value))
|
||||
|
||||
|
||||
def pb_field_to_node_label(field_descriptor, field_value):
|
||||
"""Provide the pretty label for a tree node.
|
||||
|
||||
Args:
|
||||
field_descriptor: protobuf field for determining the type
|
||||
field_value: value to render
|
||||
Returns:
|
||||
prettified text representation of the field
|
||||
"""
|
||||
highlighter = ReprHighlighter()
|
||||
if field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE:
|
||||
label = Text.from_markup(f"[b]{field_descriptor.name}[/b]")
|
||||
elif field_descriptor.type == descriptor.FieldDescriptor.TYPE_ENUM:
|
||||
enum_selection = field_descriptor.enum_type.values_by_number[field_value].name
|
||||
label = Text.assemble(
|
||||
Text.from_markup(f"{field_descriptor.name} = "),
|
||||
highlighter(enum_selection),
|
||||
)
|
||||
else:
|
||||
label = Text.assemble(
|
||||
Text.from_markup(f"{field_descriptor.name} = "),
|
||||
highlighter(repr(field_value)),
|
||||
)
|
||||
|
||||
return label
|
||||
|
||||
|
||||
############
|
||||
# COMMANDS #
|
||||
############
|
||||
|
||||
|
||||
def edit_config():
|
||||
"""Edit the configuration in an interactive fashion."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Utilize a GUI to view and alter the contents of a GP2040-CE configuration.",
|
||||
parents=[core_parser],
|
||||
)
|
||||
parser.add_argument('--whole-board', action='store_true', help="indicate the binary file is a whole board dump")
|
||||
parser.add_argument('filename', help=".bin file of a GP2040-CE board's config + footer or entire storage section, "
|
||||
"or of a GP2040-CE's whole board dump if --whole-board is specified")
|
||||
args, _ = parser.parse_known_args()
|
||||
app = ConfigEditor(config_filename=args.filename, whole_board=args.whole_board)
|
||||
app.run()
|
@ -10,16 +10,18 @@ authors = [
|
||||
{name = "Brian S. Stephan", email = "bss@incorporeal.org"},
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["grpcio-tools"]
|
||||
dependencies = ["grpcio-tools", "textual"]
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["bandit", "decorator", "flake8", "flake8-blind-except", "flake8-builtins", "flake8-docstrings",
|
||||
"flake8-executable", "flake8-fixme", "flake8-isort", "flake8-logging-format", "flake8-mutable",
|
||||
"flake8-pyproject", "mypy", "pip-tools", "pytest", "pytest-cov", "setuptools-scm", "tox"]
|
||||
"flake8-pyproject", "mypy", "pip-tools", "pytest", "pytest-asyncio", "pytest-cov", "setuptools-scm",
|
||||
"tox"]
|
||||
|
||||
[project.scripts]
|
||||
concatenate = "gp2040ce_bintools.builder:concatenate"
|
||||
edit-config = "gp2040ce_bintools.gui:edit_config"
|
||||
visualize-storage = "gp2040ce_bintools.storage:visualize"
|
||||
|
||||
[tool.flake8]
|
||||
@ -39,6 +41,14 @@ ignore_missing_imports = true
|
||||
module = "google.protobuf.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
# there's a lot of class inheritance and so on going on in textual that I haven't figured out
|
||||
# e.g. assigning Select or Input to the same variable is valid but not type-safe, bindings
|
||||
# can just exit but mypy thinks they should return coroutines... better just to disable it for
|
||||
# now until I can figure things out and learn more about doing proper type checking
|
||||
[[tool.mypy.overrides]]
|
||||
module = "gp2040ce_bintools.gui"
|
||||
ignore_errors = true
|
||||
|
||||
[tool.pytest]
|
||||
python_files = ["*_tests.py", "tests.py", "test_*.py"]
|
||||
|
||||
|
@ -59,21 +59,30 @@ gitdb==4.0.10
|
||||
# via gitpython
|
||||
gitpython==3.1.31
|
||||
# via bandit
|
||||
grpcio==1.54.2
|
||||
grpcio==1.56.0
|
||||
# via grpcio-tools
|
||||
grpcio-tools==1.54.2
|
||||
grpcio-tools==1.56.0
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
importlib-metadata==6.7.0
|
||||
# via textual
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
isort==5.12.0
|
||||
# via flake8-isort
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
linkify-it-py==2.0.2
|
||||
# via markdown-it-py
|
||||
markdown-it-py[linkify,plugins]==2.2.0
|
||||
# via
|
||||
# mdit-py-plugins
|
||||
# rich
|
||||
# textual
|
||||
mccabe==0.7.0
|
||||
# via flake8
|
||||
mdit-py-plugins==0.4.0
|
||||
# via markdown-it-py
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
mypy==1.4.0
|
||||
mypy==1.4.1
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
mypy-extensions==1.0.0
|
||||
# via mypy
|
||||
@ -88,7 +97,7 @@ pbr==5.11.1
|
||||
# via stevedore
|
||||
pip-tools==6.13.0
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
platformdirs==3.7.0
|
||||
platformdirs==3.8.0
|
||||
# via
|
||||
# tox
|
||||
# virtualenv
|
||||
@ -110,16 +119,21 @@ pyproject-api==1.5.2
|
||||
# via tox
|
||||
pyproject-hooks==1.0.0
|
||||
# via build
|
||||
pytest==7.3.2
|
||||
pytest==7.4.0
|
||||
# via
|
||||
# gp2040ce-binary-tools (pyproject.toml)
|
||||
# pytest-asyncio
|
||||
# pytest-cov
|
||||
pytest-asyncio==0.21.0
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
pytest-cov==4.1.0
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
pyyaml==6.0
|
||||
# via bandit
|
||||
rich==13.4.2
|
||||
# via bandit
|
||||
# via
|
||||
# bandit
|
||||
# textual
|
||||
setuptools-scm==7.1.0
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
smmap==5.0.0
|
||||
@ -128,6 +142,8 @@ snowballstemmer==2.2.0
|
||||
# via pydocstyle
|
||||
stevedore==5.1.0
|
||||
# via bandit
|
||||
textual==0.28.1
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
tomli==2.0.1
|
||||
# via
|
||||
# build
|
||||
@ -145,10 +161,15 @@ typing-extensions==4.6.3
|
||||
# via
|
||||
# mypy
|
||||
# setuptools-scm
|
||||
# textual
|
||||
uc-micro-py==1.0.2
|
||||
# via linkify-it-py
|
||||
virtualenv==20.23.1
|
||||
# via tox
|
||||
wheel==0.40.0
|
||||
# via pip-tools
|
||||
zipp==3.15.0
|
||||
# via importlib-metadata
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# pip
|
||||
|
@ -4,12 +4,37 @@
|
||||
#
|
||||
# pip-compile --output-file=requirements/requirements.txt
|
||||
#
|
||||
grpcio==1.54.2
|
||||
grpcio==1.56.0
|
||||
# via grpcio-tools
|
||||
grpcio-tools==1.54.2
|
||||
grpcio-tools==1.56.0
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
importlib-metadata==6.7.0
|
||||
# via textual
|
||||
linkify-it-py==2.0.2
|
||||
# via markdown-it-py
|
||||
markdown-it-py[linkify,plugins]==2.2.0
|
||||
# via
|
||||
# mdit-py-plugins
|
||||
# rich
|
||||
# textual
|
||||
mdit-py-plugins==0.4.0
|
||||
# via markdown-it-py
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
protobuf==4.23.3
|
||||
# via grpcio-tools
|
||||
pygments==2.15.1
|
||||
# via rich
|
||||
rich==13.4.2
|
||||
# via textual
|
||||
textual==0.28.1
|
||||
# via gp2040ce-binary-tools (pyproject.toml)
|
||||
typing-extensions==4.6.3
|
||||
# via textual
|
||||
uc-micro-py==1.0.2
|
||||
# via linkify-it-py
|
||||
zipp==3.15.0
|
||||
# via importlib-metadata
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# setuptools
|
||||
|
123
tests/test_gui.py
Normal file
123
tests/test_gui.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Test the Textual GUI."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from textual.widgets import Tree
|
||||
|
||||
from gp2040ce_bintools.gui import ConfigEditor
|
||||
from gp2040ce_bintools.storage import get_config_from_file
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
proto_path = os.path.join(HERE, 'test-files', 'pb2-files')
|
||||
sys.path.append(proto_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_tree_building():
|
||||
"""Test some basics of the config tree being built."""
|
||||
app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
|
||||
async with app.run_test() as pilot:
|
||||
check_node = pilot.app.query_one(Tree).root.children[2]
|
||||
assert "boardVersion = 'v0.7.2'" in check_node.label
|
||||
parent_config, field_descriptor = check_node.data
|
||||
assert parent_config == pilot.app.config
|
||||
assert field_descriptor == pilot.app.config.DESCRIPTOR.fields_by_name['boardVersion']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_toggle():
|
||||
"""Test that we can navigate a bit and toggle a bool."""
|
||||
app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
|
||||
async with app.run_test() as pilot:
|
||||
tree = pilot.app.query_one(Tree)
|
||||
display_node = tree.root.children[3]
|
||||
invert_node = display_node.children[11]
|
||||
|
||||
assert 'False' in invert_node.label
|
||||
app._modify_node(invert_node)
|
||||
assert 'True' in invert_node.label
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_edit_via_input_field():
|
||||
"""Test that we can change an int via UI and see it reflected in the config."""
|
||||
app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
|
||||
async with app.run_test() as pilot:
|
||||
tree = pilot.app.query_one(Tree)
|
||||
display_node = tree.root.children[3]
|
||||
i2cspeed_node = display_node.children[10]
|
||||
assert pilot.app.config.displayOptions.i2cSpeed == 400000
|
||||
|
||||
tree.root.expand_all()
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
tree.select_node(i2cspeed_node)
|
||||
tree.action_select_cursor()
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.click('Input#field-input')
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.press('backspace', 'backspace', 'backspace', 'backspace', 'backspace', 'backspace', '5')
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.click('Button#save-button')
|
||||
assert pilot.app.config.displayOptions.i2cSpeed == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_edit_via_input_field_enum():
|
||||
"""Test that we can change an enum via the UI and see it reflected in the config."""
|
||||
app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
|
||||
async with app.run_test() as pilot:
|
||||
tree = pilot.app.query_one(Tree)
|
||||
gamepad_node = tree.root.children[5]
|
||||
dpadmode_node = gamepad_node.children[0]
|
||||
assert pilot.app.config.gamepadOptions.dpadMode == 0
|
||||
|
||||
tree.root.expand_all()
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
tree.select_node(dpadmode_node)
|
||||
tree.action_select_cursor()
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.click('Select#field-input')
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.press('down', 'down', 'enter')
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.click('Button#save-button')
|
||||
assert pilot.app.config.gamepadOptions.dpadMode == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_edit_via_input_field_string():
|
||||
"""Test that we can change a string via the UI and see it reflected in the config."""
|
||||
app = ConfigEditor(config_filename=os.path.join(HERE, 'test-files/test-config.bin'))
|
||||
async with app.run_test() as pilot:
|
||||
tree = pilot.app.query_one(Tree)
|
||||
version_node = tree.root.children[2]
|
||||
assert pilot.app.config.boardVersion == 'v0.7.2'
|
||||
|
||||
# tree.root.expand_all()
|
||||
# await pilot.wait_for_scheduled_animations()
|
||||
tree.select_node(version_node)
|
||||
tree.action_select_cursor()
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.click('Input#field-input')
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.press('backspace', '-', 'h', 'i')
|
||||
await pilot.wait_for_scheduled_animations()
|
||||
await pilot.click('Button#save-button')
|
||||
assert pilot.app.config.boardVersion == 'v0.7.-hi'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save(config_binary, tmp_path):
|
||||
"""Test that the tree builds and things are kind of where they should be."""
|
||||
new_filename = os.path.join(tmp_path, 'config-copy.bin')
|
||||
with open(new_filename, 'wb') as file:
|
||||
file.write(config_binary)
|
||||
|
||||
app = ConfigEditor(config_filename=new_filename)
|
||||
async with app.run_test() as pilot:
|
||||
pilot.app.config.boardVersion = 'v0.7.2-bss-wuz-here'
|
||||
await pilot.press('s')
|
||||
|
||||
config = get_config_from_file(new_filename)
|
||||
assert config.boardVersion == 'v0.7.2-bss-wuz-here'
|
Loading…
x
Reference in New Issue
Block a user