From f7fb79ef9188753667cef7fb8ffad7632bbbd21c Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 29 Oct 2026 12:07:28 -0600 Subject: [PATCH 1/2] Document integration contracts and establish composition CI --- .github/workflows/ci.yml | 30 +++++++ README.md | 82 ++++++++++++++++++ agent-notes/improve-integration-contract.md | 33 +++++++ tests/support.py | 95 +++++++++++++++++++++ tests/test_application.py | 20 +++++ 5 files changed, 260 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 agent-notes/improve-integration-contract.md create mode 100644 tests/support.py create mode 100644 tests/test_application.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52e2263 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + version: "0.13.1" + enable-cache: false + - name: Test shared composition contracts + run: uv run --no-project --python 3.13 --with babel==2.17.0 python -m unittest discover -s tests -v + - name: Build source and wheel distributions + run: uv build diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b12723 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# app_elements + +Reusable desktop UI composition for applications built with app_framework, +gui_builder and wx_utils. app_framework owns application services; app_elements +exposes their common menus, actions and settings panels. Product-specific +document, feed, podcast and account settings remain in the consuming application. + +## Application contract + +The application must be importable as `application`. Bootstrap it with +`app_framework.bootstrap_application(application)` before showing these controls. +The framework initializes configuration and translations before importing the +main window; it initializes optional services before returning to the event loop. +Importing controls earlier can capture untranslated labels or incomplete feature +flags. Set manifest capabilities before importing menus or panels: their class +declarations select optional controls at import time. + +`HelpMenu` from `app_elements.menu` supplies documentation (F1), About, and optional +website, issue reporting, activation and frozen-application update actions. It +uses the framework's manifest capability checks. The application supplies `name`, +`version`, `author`, `locale`, and any enabled services' required attributes. +About also uses `website`. Actions use the same application module as bootstrap. + +## Settings panels + +Embed panels in gui_builder/wx_utils forms as normal declarative children: + +```python +from app_elements.i18n import LanguageSelectionPanel +from wx_utils.forms import AutoSizedDialog + +class OptionsDialog(AutoSizedDialog): + language = LanguageSelectionPanel(sizer_type="horizontal") + + def set_config_values(self): + self.language.set_config_values() +``` + +The caller owns OK/Cancel: call `set_config_values()` only after OK, then save +the application's configuration using its normal persistence mechanism. Rendering +a panel must not save settings. These methods update configuration; they do not +apply audio changes or switch the running application's language. + +| Panel | Application requirements | Saved setting | +| --- | --- | --- | +| `i18n.LanguageSelectionPanel` | `name`, resolved `locale`, `config`, registered application translations | `UI.language`; restart required | +| `audio.DeviceSelectionPanel` | initialized sound output, `config`; `record_sound` enables input selection | `audio.device`, `audio.input_device` | +| `startup.RunAtStartupPanel` | Windows, `name`, stable executable/script location | Current user's Windows Run registry value | + +Startup registration changes the registry immediately when its save method runs; +it is not part of configuration-file persistence. Do not import `startup` on +other platforms. Keep portable applications in a stable location if enabling it. + +## Documentation files + +`interface.view_documentation()` expects `readme.html` under the embedded-data +directory in frozen applications, or under `../documentation` relative to the +application path in source runs. Locale subdirectories use identifiers such as +`fr_CA` or `fr`. Products own building and packaging those HTML files. + +## Development and CI + +Controlled composition tests use Python's standard-library unittest and Babel +2.17.0. They import the production modules and replace desktop-service boundaries +with doubles; they do not require wxPython, private repositories or credentials. +Run from the repository root: + +```text +uv run --no-project --python 3.13 --with babel==2.17.0 python -m unittest discover -s tests -v +uv build +``` + +The test command deliberately uses `--no-project`: it does not resolve or change +the desktop application's dependency environment. `uv build` invokes the +existing Hatchling backend to produce source and wheel distributions without +installing runtime dependencies. There is no tracked project lock or type-check +authority here. CI runs these two commands on one Linux/Python 3.13 runner for +pull requests (including forks) and pushes to master. It needs no private token. + +These checks establish controlled composition and package-build results. They +do not establish native wx behavior, Windows startup execution, frozen-product +integration, or live screen-reader output; those require consumer verification. diff --git a/agent-notes/improve-integration-contract.md b/agent-notes/improve-integration-contract.md new file mode 100644 index 0000000..bf82a17 --- /dev/null +++ b/agent-notes/improve-integration-contract.md @@ -0,0 +1,33 @@ +# Goal + +Deliver the accepted integration-documentation, tests and CI improvement as its +own PR. Q authorized implementation and separate PRs with "Make it so!". + +# State + +Based on origin/master in an isolated worktree; main checkout edits are preserved. +This PR adds a README, controlled unittest harness, two baseline contract tests, +and a Linux CI job for the documented test command and existing Hatchling build. +No runtime dependency, public API or application source changes. + +# Decisions and why + +Use stdlib unittest and a pinned Babel test dependency without resolving desktop +runtime dependencies. Tests explicitly double wx/services and make no native or +live AT claims. The five repair PRs will target this branch so they use this one +workflow and harness. Each adds its own focused regression file. No merge/release +authorized. Q requested missing tests and CI as part of resolving all findings. + +Correction to the initial investigation: ChoiceField.set_value already calls +set_items (gui_builder/fields.py:1083). There is no population bug. The language +repair addresses regional locale identity and labels only. + +# Surprises + +No tracked CI/test command existed. The README establishes the commands before +the workflow invokes them. No project lock is needed for stdlib tests with the +explicit Babel version; build uses the existing build-system requirements. + +# Next step + +Publish this PR and read its CI result, then deliver the five focused repair PRs. diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..9040808 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,95 @@ +"""Load real app_elements modules with controlled desktop-service boundaries. + +These doubles exercise composition and configuration, not native wx behavior. +Each test import has fresh fields and restores sys.modules on exit. +""" + +import importlib +import sys +import types +from contextlib import contextmanager +from unittest.mock import Mock, patch + + +class Panel: + def __init__(self, *args, **kwargs): + pass + + def render(self, *args, **kwargs): + pass + + +@contextmanager +def load_component(name, **application_values): + application = types.ModuleType("application") + application.name = "Example" + application.locale = "en_US" + application.config = {} + application.__dict__.update(application_values) + + def module(name, **values): + result = types.ModuleType(name) + result.__dict__.update(values) + return result + + helpers = module( + "app_framework.helpers", + has_sound_recording=lambda app: bool(getattr(app, "record_sound", False)), + has_issue_reporter=lambda app: False, + has_activation=lambda app: False, + is_autoupdating=lambda app: False, + ) + configuration = module( + "app_framework.configuration", get_configuration_value=Mock(return_value="Default") + ) + shutdown = module("app_framework.shutdown", shutdown=Mock()) + background = module( + "app_framework.background", Task=Mock(), asynchronous=lambda func: func + ) + framework = module( + "app_framework", helpers=helpers, configuration=configuration, shutdown=shutdown + ) + fields = module("gui_builder.fields", ComboBox=Mock(), CheckBox=Mock(), MenuItem=Mock()) + # A distinct control per declaration, just as a bound form has distinct fields. + fields.ComboBox.side_effect = lambda **kwargs: Mock() + fields.CheckBox.side_effect = lambda **kwargs: Mock() + fields.MenuItem.side_effect = lambda **kwargs: Mock() + forms = module("gui_builder.forms", Menu=Panel) + gui = module("gui_builder", fields=fields, forms=forms, OK=1) + wx_forms = module("wx_utils.forms", AutoSizedPanel=Panel) + popups = module("wx_utils.popups", warning_message=Mock(), message_box=Mock()) + paths = module( + "platform_utils.paths", is_frozen=Mock(return_value=False), + app_path=Mock(), embedded_data_path=Mock(), get_executable=Mock() + ) + translation = module( + "i18n_core", DEFAULT_LOCALE="en_US", get_available_locales=Mock(return_value=[]) + ) + + def install_translation(domain, module): + module._ = module.__ = lambda text: text + + translation.install_module_translation = install_translation + registry = module( + "winreg", HKEY_CURRENT_USER=1, KEY_READ=2, KEY_SET_VALUE=4, REG_SZ=1, + OpenKey=Mock(), CreateKeyEx=Mock(), QueryValueEx=Mock(), + SetValueEx=Mock(), DeleteValue=Mock() + ) + modules = { + "application": application, "app_framework": framework, + "app_framework.helpers": helpers, "app_framework.configuration": configuration, + "app_framework.shutdown": shutdown, "app_framework.background": background, + "gui_builder": gui, "gui_builder.fields": fields, "gui_builder.forms": forms, + "wx_utils": module("wx_utils", forms=wx_forms, popups=popups), + "wx_utils.forms": wx_forms, "wx_utils.popups": popups, + "platform_utils": module("platform_utils", paths=paths), + "platform_utils.paths": paths, "i18n_core": translation, + "wx": module("wx"), "winreg": registry, + } + with patch.dict(sys.modules, modules): + # Ensure the production module and its application binding are re-evaluated. + for key in list(sys.modules): + if key == "app_elements" or key.startswith("app_elements."): + del sys.modules[key] + with patch("builtins.__", lambda text: text, create=True): + yield importlib.import_module(name), application, modules diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..e1ee16e --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,20 @@ +import unittest + +from support import load_component + + +class ApplicationContractTests(unittest.TestCase): + def test_discovery_returns_the_bootstrapped_application_module(self): + with load_component("app_elements") as (elements, application, _): + self.assertIs(elements.find_application_module(), application) + + def test_minimal_help_menu_has_documentation_and_about(self): + with load_component("app_elements.menu") as (menu, _, _): + self.assertTrue(hasattr(menu.HelpMenu, "documentation")) + self.assertTrue(hasattr(menu.HelpMenu, "about")) + self.assertFalse(hasattr(menu.HelpMenu, "activate")) + self.assertFalse(hasattr(menu.HelpMenu, "report_issue")) + + +if __name__ == "__main__": + unittest.main() From 05362e8529f105608b6e6cd8d6a987447124ef00 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 29 Oct 2026 12:09:57 -0600 Subject: [PATCH 2/2] Include integration documentation in package metadata --- agent-notes/improve-integration-contract.md | 5 +++++ pyproject.toml | 1 + tests/test_application.py | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/agent-notes/improve-integration-contract.md b/agent-notes/improve-integration-contract.md index bf82a17..b0643b6 100644 --- a/agent-notes/improve-integration-contract.md +++ b/agent-notes/improve-integration-contract.md @@ -5,6 +5,11 @@ own PR. Q authorized implementation and separate PRs with "Make it so!". # State +PR #1 is open. GitHub's repository Actions API reported enabled=false, explaining +the absent checks. Enabled Actions as part of the requested CI work, preserving +its allowed-actions policy. The next substantive push activates PR CI. README +is also wired into distribution metadata. Three baseline tests are present. + Based on origin/master in an isolated worktree; main checkout edits are preserved. This PR adds a README, controlled unittest harness, two baseline contract tests, and a Linux CI job for the documented test command and existing Hatchling build. diff --git a/pyproject.toml b/pyproject.toml index c29e57d..751f3ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ name = "app_elements" version = "0.4.2" description = "Reuseable elements of desktop applications" +readme = "README.md" authors = [ { name = "Christopher Toth", email = "q@q-continuum.net" } ] diff --git a/tests/test_application.py b/tests/test_application.py index e1ee16e..4de9e91 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -15,6 +15,11 @@ def test_minimal_help_menu_has_documentation_and_about(self): self.assertFalse(hasattr(menu.HelpMenu, "activate")) self.assertFalse(hasattr(menu.HelpMenu, "report_issue")) + def test_exit_uses_the_framework_shutdown_owner(self): + with load_component("app_elements.interface") as (interface, application, modules): + interface.exit() + modules["app_framework.shutdown"].shutdown.assert_called_once_with(application) + if __name__ == "__main__": unittest.main()