Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions agent-notes/improve-integration-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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

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.
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.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
Expand Down
95 changes: 95 additions & 0 deletions tests/support.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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"))

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()
Loading