wapyt 0.2.0.dev1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/PKG-INFO +2 -1
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/README.md +1 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/pyproject.toml +1 -1
- wapyt-0.3.0/tests/test_config_none.py +74 -0
- wapyt-0.3.0/tests/test_mediaplayer.py +127 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_new_widget_configs.py +5 -0
- wapyt-0.3.0/tests/test_quiet_imports.py +18 -0
- wapyt-0.3.0/tests/test_source_warnings.py +18 -0
- wapyt-0.3.0/tests/test_theme_system.py +64 -0
- wapyt-0.3.0/tests/test_themeeditor.py +120 -0
- wapyt-0.3.0/tests/test_themeeditor_js.py +112 -0
- wapyt-0.3.0/tests/test_widget_base.py +220 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/__init__.py +14 -1
- wapyt-0.3.0/wapyt/_base.py +116 -0
- wapyt-0.3.0/wapyt/_config.py +21 -0
- wapyt-0.3.0/wapyt/assets/__pycache__/__init__.cpython-313.pyc +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/layout.js +44 -0
- wapyt-0.3.0/wapyt/assets/mediaplayer.js +1211 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/terminal.js +58 -9
- wapyt-0.3.0/wapyt/assets/themeeditor.js +923 -0
- wapyt-0.3.0/wapyt/assets/vendor-hlsjs.LICENSE.md +28 -0
- wapyt-0.3.0/wapyt/assets/vendor-hlsjs.light.min.js +1 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/wapyt.css +910 -221
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/cardpanel/cardpanel.py +10 -51
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/chart/chart.py +6 -27
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/chart/chart_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/chat/chat.py +7 -38
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/chat/chat_config.py +1 -6
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/contextmenu/contextmenu.py +6 -9
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/contextmenu/contextmenu_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/datatable/datatable.py +7 -27
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/datatable/datatable_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/form/form.py +4 -24
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/form/form_config.py +3 -4
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/kanban/kanban.py +6 -31
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/kanban/kanban_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/layout/layout.py +26 -1
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/layout/layout_config.py +1 -2
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/listbox/listbox.py +6 -31
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/listbox/listbox_config.py +1 -3
- wapyt-0.3.0/wapyt/mediaplayer/__init__.py +4 -0
- wapyt-0.3.0/wapyt/mediaplayer/mediaplayer.py +339 -0
- wapyt-0.3.0/wapyt/mediaplayer/mediaplayer_config.py +301 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/menubar/menubar.py +6 -31
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/modal/modal.py +7 -16
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/pagination/pagination.py +6 -31
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/pagination/pagination_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/popup/popup.py +7 -17
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/popup/popup_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/progressbar/progressbar.py +5 -17
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/progressbar/progressbar_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/pytincture-assets.json +19 -4
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/resourceboard/resourceboard.py +5 -23
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/scheduler/scheduler.py +8 -26
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/scheduler/scheduler_config.py +2 -4
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/sidebar/sidebar.py +7 -33
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/sidebar/sidebar_config.py +8 -5
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/tabwidget/tabwidget.py +6 -27
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/terminal/terminal.py +9 -28
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/terminal/terminal_config.py +8 -3
- wapyt-0.3.0/wapyt/themeeditor/__init__.py +14 -0
- wapyt-0.3.0/wapyt/themeeditor/themeeditor.py +149 -0
- wapyt-0.3.0/wapyt/themeeditor/themeeditor_config.py +173 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/toolbar/toolbar.py +6 -24
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/toolbar/toolbar_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/tree/tree.py +7 -27
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/tree/tree_config.py +1 -3
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/window/window.py +8 -19
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt.egg-info/PKG-INFO +2 -1
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt.egg-info/SOURCES.txt +21 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/MANIFEST.in +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/setup.cfg +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_chart.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_chat_stream.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_contextmenu.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_datatable_classes.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_datatable_csv.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_datatable_editing.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_datatable_frozen.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_filetransfer_csrf.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_buttons.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_clear.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_fields.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_fieldsets.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_files.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_focus.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_options_icons.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_properties.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_range.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_form_validators.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_jsnull.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_kanban.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_layout_splitters.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_listbox.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_menubar.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_message.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_modal_events.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_pagination.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_popup.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_progressbar.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_release_metadata.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_scheduler.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_sidebar_groups.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_toolbar.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_toolbar_dropdowns.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_tree_check_move.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/tests/test_window.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/_runtime.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/cardpanel.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/chart.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/chat.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/contextmenu.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/datatable.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/filetransfer.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/fonts/.keep +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/form.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/icons.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/kanban.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/listbox.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/menubar.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/message.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/modal.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/pagination.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/popup.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/progressbar.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/resourceboard.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/scheduler.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/sidebar.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/tabwidget.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/toolbar.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/tooltip.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/tree.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/vendor-chartjs.LICENSE.md +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/vendor-chartjs.min.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/assets/window.js +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/cardpanel/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/cardpanel/cardpanel_config.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/chart/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/chat/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/contextmenu/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/datatable/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/filetransfer/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/filetransfer/filetransfer.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/form/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/kanban/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/layout/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/listbox/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/menubar/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/menubar/menubar_config.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/message/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/message/message.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/modal/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/pagination/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/popup/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/progressbar/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/resourceboard/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/resourceboard/resourceboard_config.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/scheduler/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/sidebar/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/tabwidget/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/terminal/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/toolbar/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/tree/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt/window/__init__.py +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt.egg-info/dependency_links.txt +0 -0
- {wapyt-0.2.0.dev1 → wapyt-0.3.0}/wapyt.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wapyt
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: DHTMLX-free pyTincture widgetset: layouts, forms, tables, trees, chat, terminal, menus, Kanban and more, declared in Python and rendered in the browser.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: homepage, https://github.com/WAwesome-AI/wa_pytincture_widgetset
|
|
@@ -38,6 +38,7 @@ Description-Content-Type: text/markdown
|
|
|
38
38
|
- `Popup` and tooltips: An anchored popover for pickers, help and small forms; styled, keyboard-reachable tooltips that replace native `title` text across the widgets.
|
|
39
39
|
- `ProgressBar` / `progress_html`: Determinate or indeterminate bars with active/done/error/paused states, as a live widget or static markup.
|
|
40
40
|
- `Pagination`: First/previous/next/last, a page box or numbered buttons, a page-size selector and a "1–50 of 1,234" summary; copes with totals the server could not count.
|
|
41
|
+
- `MediaPlayer`: Video and audio with a queue — the picture in place, an audio bar that can dock to the foot of the window, or a compact inline player for a chat message; HLS through bundled hls.js, WebVTT subtitles, items whose URL is resolved only when reached, exclusive groups, keyboard and OS media keys, buttons of the app's own in the bar (`MediaAction`, `on_action`: "add to playlist", "save"), and any media source: files from disk, a CDN, cookies, Bearer headers (`MediaItem(headers=…)`), expiring signed URLs (`refresh()`), live streams (`live=True`) and a Quality menu.
|
|
41
42
|
|
|
42
43
|
## Runtime Architecture
|
|
43
44
|
- Layouts and widgets are declared in Python but render as DOM nodes through the JavaScript bundles under `wapyt/assets/`. Each helper (e.g., `Layout.add_chat`) forwards config dictionaries directly to the matching JS constructor.
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
- `Popup` and tooltips: An anchored popover for pickers, help and small forms; styled, keyboard-reachable tooltips that replace native `title` text across the widgets.
|
|
27
27
|
- `ProgressBar` / `progress_html`: Determinate or indeterminate bars with active/done/error/paused states, as a live widget or static markup.
|
|
28
28
|
- `Pagination`: First/previous/next/last, a page box or numbered buttons, a page-size selector and a "1–50 of 1,234" summary; copes with totals the server could not count.
|
|
29
|
+
- `MediaPlayer`: Video and audio with a queue — the picture in place, an audio bar that can dock to the foot of the window, or a compact inline player for a chat message; HLS through bundled hls.js, WebVTT subtitles, items whose URL is resolved only when reached, exclusive groups, keyboard and OS media keys, buttons of the app's own in the bar (`MediaAction`, `on_action`: "add to playlist", "save"), and any media source: files from disk, a CDN, cookies, Bearer headers (`MediaItem(headers=…)`), expiring signed URLs (`refresh()`), live streams (`live=True`) and a Quality menu.
|
|
29
30
|
|
|
30
31
|
## Runtime Architecture
|
|
31
32
|
- Layouts and widgets are declared in Python but render as DOM nodes through the JavaScript bundles under `wapyt/assets/`. Each helper (e.g., `Layout.add_chat`) forwards config dictionaries directly to the matching JS constructor.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wapyt"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "DHTMLX-free pyTincture widgetset: layouts, forms, tables, trees, chat, terminal, menus, Kanban and more, declared in Python and rendered in the browser."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Every config follows one None convention: unset means "not sent"."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import pkgutil
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import wapyt
|
|
12
|
+
from wapyt._config import drop_none
|
|
13
|
+
|
|
14
|
+
# Fields whose None means "none" (hide it), so JS must see null. Keep short.
|
|
15
|
+
ALLOWED_NULLS = {
|
|
16
|
+
("FormConfig", "cancelText"),
|
|
17
|
+
("FormConfig", "submitText"),
|
|
18
|
+
("SidebarConfig", "title"),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Classes that cannot be built from defaults alone get minimal arguments.
|
|
22
|
+
ARGS = {
|
|
23
|
+
"MenuItem": {"id": "m"},
|
|
24
|
+
"ScheduleItem": {"id": "s", "start": "2026-10-08T09:00"},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _config_classes():
|
|
29
|
+
for info in pkgutil.walk_packages(wapyt.__path__, "wapyt."):
|
|
30
|
+
module = importlib.import_module(info.name)
|
|
31
|
+
for name, cls in inspect.getmembers(module, inspect.isclass):
|
|
32
|
+
if cls.__module__ == module.__name__ and dataclasses.is_dataclass(cls) and hasattr(cls, "to_dict"):
|
|
33
|
+
yield cls
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build(cls):
|
|
37
|
+
if cls.__name__ in ARGS:
|
|
38
|
+
return cls(**ARGS[cls.__name__])
|
|
39
|
+
required = {
|
|
40
|
+
f.name: "x"
|
|
41
|
+
for f in dataclasses.fields(cls)
|
|
42
|
+
if f.init and f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING
|
|
43
|
+
}
|
|
44
|
+
return cls(**required)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _nulls(value, path=""):
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
for key, item in value.items():
|
|
50
|
+
if item is None:
|
|
51
|
+
yield path + key
|
|
52
|
+
else:
|
|
53
|
+
yield from _nulls(item, f"{path}{key}.")
|
|
54
|
+
elif isinstance(value, list):
|
|
55
|
+
for index, item in enumerate(value):
|
|
56
|
+
yield from _nulls(item, f"{path}[{index}].")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
CLASSES = sorted(set(_config_classes()), key=lambda c: (c.__module__, c.__name__))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_found_the_configs():
|
|
63
|
+
assert len(CLASSES) > 40
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("cls", CLASSES, ids=lambda c: c.__name__)
|
|
67
|
+
def test_defaults_send_no_nulls(cls):
|
|
68
|
+
payload = _build(cls).to_dict()
|
|
69
|
+
unexpected = [p for p in _nulls(payload) if (cls.__name__, p) not in ALLOWED_NULLS]
|
|
70
|
+
assert unexpected == []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_drop_none_is_shallow():
|
|
74
|
+
assert drop_none({"a": None, "b": 0, "c": False, "d": {"e": None}}) == {"b": 0, "c": False, "d": {"e": None}}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""CPython tests for the MediaPlayer config (no browser needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import wapyt
|
|
7
|
+
from wapyt import MediaItem, MediaPlayerConfig, MediaTextTrack
|
|
8
|
+
from wapyt.mediaplayer.mediaplayer import resolve_patch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_exported_with_a_layout_helper():
|
|
12
|
+
from wapyt.layout.layout import Layout
|
|
13
|
+
assert {"MediaPlayer", "MediaPlayerConfig", "MediaItem", "MediaTextTrack"} <= set(wapyt.__all__)
|
|
14
|
+
assert hasattr(Layout, "add_mediaplayer")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_defaults_are_minimal():
|
|
18
|
+
assert MediaPlayerConfig().to_dict() == {"mode": "auto"}
|
|
19
|
+
assert MediaItem("/a.mp4").to_dict() == {"src": "/a.mp4"}
|
|
20
|
+
assert MediaTextTrack("/a.vtt").to_dict() == {"src": "/a.vtt"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_an_item_without_src_is_allowed():
|
|
24
|
+
# Resolved when it becomes current (the "resolve" event).
|
|
25
|
+
assert MediaItem(title="Later", data={"id": 7}).to_dict() == {"title": "Later", "data": {"id": 7}}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_options_are_camel_cased():
|
|
29
|
+
payload = MediaPlayerConfig(
|
|
30
|
+
items=[MediaItem("/a.m3u8", title="A", subtitle="S", album="Al", poster="/p.jpg", type="hls",
|
|
31
|
+
kind="video", start=12.5, data={"id": 1},
|
|
32
|
+
text_tracks=[MediaTextTrack("/a.vtt", "English", "en", kind="captions", default=True)])],
|
|
33
|
+
mode="audio", autoplay=True, controls=False, muted=True, volume=0.5, rate=1.5, repeat="all",
|
|
34
|
+
shuffle=True, dock="bottom", group="chat", keyboard=False, media_session=False, queue_line=False,
|
|
35
|
+
show_shuffle=False, show_repeat=False, time_update_interval=0, preload="none",
|
|
36
|
+
cross_origin="anonymous", hls_config={"maxBufferLength": 30}, height=64, empty_text="—",
|
|
37
|
+
aria_label="Music",
|
|
38
|
+
).to_dict()
|
|
39
|
+
assert payload["items"] == [{"src": "/a.m3u8", "title": "A", "subtitle": "S", "album": "Al", "poster": "/p.jpg",
|
|
40
|
+
"type": "hls", "kind": "video", "start": 12.5, "data": {"id": 1},
|
|
41
|
+
"textTracks": [{"src": "/a.vtt", "label": "English", "language": "en",
|
|
42
|
+
"kind": "captions", "default": True}]}]
|
|
43
|
+
for key, value in {"mode": "audio", "autoplay": True, "controls": False, "muted": True, "volume": 0.5,
|
|
44
|
+
"rate": 1.5, "repeat": "all", "shuffle": True, "dock": "bottom", "group": "chat",
|
|
45
|
+
"keyboard": False, "mediaSession": False, "queueLine": False, "showShuffle": False,
|
|
46
|
+
"showRepeat": False, "timeUpdateInterval": 0, "preload": "none",
|
|
47
|
+
"crossOrigin": "anonymous", "hlsConfig": {"maxBufferLength": 30}, "height": 64,
|
|
48
|
+
"emptyText": "—", "ariaLabel": "Music"}.items():
|
|
49
|
+
assert payload[key] == value, key
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_dict_items_pass_through():
|
|
53
|
+
assert MediaPlayerConfig(items=[{"src": "/x.mp3"}]).to_dict()["items"] == [{"src": "/x.mp3"}]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize("bad", [dict(mode="theatre"), dict(repeat="twice"), dict(dock="top"), dict(preload="all"),
|
|
57
|
+
dict(cross_origin="yes"), dict(volume=1.5), dict(rate=8),
|
|
58
|
+
dict(time_update_interval=-1),
|
|
59
|
+
dict(items=[MediaItem("/a.mp4")], start_index=1)])
|
|
60
|
+
def test_invalid_configs(bad):
|
|
61
|
+
with pytest.raises(ValueError):
|
|
62
|
+
MediaPlayerConfig(**bad).to_dict()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize("bad", [dict(kind="podcast"), dict(start=-1), dict(data={1, 2})])
|
|
66
|
+
def test_invalid_items(bad):
|
|
67
|
+
with pytest.raises(ValueError):
|
|
68
|
+
MediaItem("/a.mp4", **bad).to_dict()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_invalid_text_tracks():
|
|
72
|
+
with pytest.raises(ValueError):
|
|
73
|
+
MediaTextTrack("/a.vtt", kind="karaoke").to_dict()
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
MediaTextTrack("").to_dict()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_resolve_patch_shape():
|
|
79
|
+
assert resolve_patch("/s.m3u8", type="hls") == {"src": "/s.m3u8", "type": "hls"}
|
|
80
|
+
assert resolve_patch(error="gone") == {"error": "gone"}
|
|
81
|
+
assert resolve_patch("/a", text_tracks=[MediaTextTrack("/a.vtt", "En")]) == {
|
|
82
|
+
"src": "/a", "textTracks": [{"src": "/a.vtt", "label": "En"}]}
|
|
83
|
+
with pytest.raises(ValueError):
|
|
84
|
+
resolve_patch("/a", kind="slideshow")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_assets_are_in_the_manifest_in_order():
|
|
88
|
+
import json
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
manifest = json.loads((Path(wapyt.__file__).parent / "pytincture-assets.json").read_text())
|
|
91
|
+
paths = [a["path"] for a in manifest["assets"]]
|
|
92
|
+
assert paths.index("wapyt/assets/vendor-hlsjs.light.min.js") < paths.index("wapyt/assets/mediaplayer.js")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_actions_are_the_apps_own_buttons():
|
|
96
|
+
from wapyt import MediaAction
|
|
97
|
+
payload = MediaPlayerConfig(actions=[
|
|
98
|
+
MediaAction("playlist", "mdi-playlist-plus", "Add to playlist"),
|
|
99
|
+
MediaAction("like", "mdi-heart-outline", "Like", pressed=False, disabled=True),
|
|
100
|
+
]).to_dict()
|
|
101
|
+
assert payload["actions"] == [
|
|
102
|
+
{"id": "playlist", "icon": "mdi-playlist-plus", "label": "Add to playlist"},
|
|
103
|
+
{"id": "like", "icon": "mdi-heart-outline", "label": "Like", "pressed": False, "disabled": True},
|
|
104
|
+
]
|
|
105
|
+
assert MediaAction("x").to_dict() == {"id": "x", "icon": "mdi-dots-horizontal", "label": "x"}
|
|
106
|
+
with pytest.raises(ValueError):
|
|
107
|
+
MediaAction("").to_dict()
|
|
108
|
+
with pytest.raises(ValueError):
|
|
109
|
+
MediaPlayerConfig(actions=[MediaAction("a"), MediaAction("a")]).to_dict()
|
|
110
|
+
assert "actions" not in MediaPlayerConfig().to_dict()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_headers_and_live_for_any_media_server():
|
|
114
|
+
item = MediaItem("/live/channel.m3u8", headers={"Authorization": "Bearer abc", "X-Api-Key": "k"}, live=True)
|
|
115
|
+
assert item.to_dict() == {"src": "/live/channel.m3u8", "headers": {"Authorization": "Bearer abc", "X-Api-Key": "k"}, "live": True}
|
|
116
|
+
for bad in ({"Bad Name": "x"}, {"X": "two\r\nlines"}, {"X": 3}):
|
|
117
|
+
with pytest.raises(ValueError):
|
|
118
|
+
MediaItem("/a.m3u8", headers=bad).to_dict()
|
|
119
|
+
assert resolve_patch("/s.m3u8", headers={"Authorization": "Bearer t"}, live=False) == {
|
|
120
|
+
"src": "/s.m3u8", "headers": {"Authorization": "Bearer t"}, "live": False}
|
|
121
|
+
with pytest.raises(ValueError):
|
|
122
|
+
resolve_patch("/s.m3u8", headers={"A": "b\nc"})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_quality_menu_can_be_turned_off():
|
|
126
|
+
assert MediaPlayerConfig(show_quality=False).to_dict() == {"mode": "auto", "showQuality": False}
|
|
127
|
+
assert "showQuality" not in MediaPlayerConfig().to_dict()
|
|
@@ -56,6 +56,11 @@ def test_terminal_clipboard_is_on_by_default_and_can_be_turned_off():
|
|
|
56
56
|
assert roundtrip(TerminalConfig(clipboard=False))["clipboard"] is False
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def test_terminal_osc52_is_on_by_default_and_can_be_turned_off():
|
|
60
|
+
assert roundtrip(TerminalConfig())["osc52"] is True
|
|
61
|
+
assert roundtrip(TerminalConfig(osc52=False))["osc52"] is False
|
|
62
|
+
|
|
63
|
+
|
|
59
64
|
def test_terminal_extra_passes_through():
|
|
60
65
|
payload = roundtrip(TerminalConfig(extra={"customFlag": 7}))
|
|
61
66
|
assert payload["customFlag"] == 7
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Importing a widget module must not write to stdout or log above DEBUG."""
|
|
2
|
+
import importlib
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
MODULES = ["wapyt.chat.chat", "wapyt.cardpanel.cardpanel"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("name", MODULES)
|
|
12
|
+
def test_import_is_quiet(name, capsys, caplog):
|
|
13
|
+
sys.modules.pop(name, None)
|
|
14
|
+
with caplog.at_level(logging.INFO):
|
|
15
|
+
importlib.import_module(name)
|
|
16
|
+
out = capsys.readouterr()
|
|
17
|
+
assert out.out == "" and out.err == ""
|
|
18
|
+
assert not [r for r in caplog.records if r.name == name]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Every wapyt module compiles without warnings (no invalid escapes and the like)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
ROOT = pathlib.Path(__file__).resolve().parents[1] / "wapyt"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("path", sorted(ROOT.rglob("*.py")), ids=lambda p: str(p.relative_to(ROOT)))
|
|
13
|
+
def test_compiles_without_warnings(path):
|
|
14
|
+
# An invalid escape such as "\;" in a normal string is a SyntaxWarning
|
|
15
|
+
# today (on every import, in every app) and an error in a future Python.
|
|
16
|
+
with warnings.catch_warnings():
|
|
17
|
+
warnings.simplefilter("error")
|
|
18
|
+
compile(path.read_text(encoding="utf-8"), str(path), "exec")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wapyt.setTheme in layout.js: "system" follows prefers-color-scheme. Run the
|
|
3
|
+
real script under Node with a fake document and matchMedia (skipped when
|
|
4
|
+
node is missing).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
SCRIPT = Path(__file__).resolve().parents[1] / "wapyt" / "assets" / "layout.js"
|
|
16
|
+
|
|
17
|
+
HARNESS = """
|
|
18
|
+
const attrs = {};
|
|
19
|
+
globalThis.document = { documentElement: {
|
|
20
|
+
setAttribute: (k, v) => { attrs[k] = String(v); },
|
|
21
|
+
getAttribute: (k) => (k in attrs ? attrs[k] : null),
|
|
22
|
+
} };
|
|
23
|
+
const listeners = [];
|
|
24
|
+
const query = { matches: process.argv[2] === "dark",
|
|
25
|
+
addEventListener: (type, fn) => listeners.push(fn) };
|
|
26
|
+
globalThis.matchMedia = () => query;
|
|
27
|
+
require("vm").runInThisContext(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
28
|
+
const log = [];
|
|
29
|
+
const snap = (label) => log.push([label, attrs["data-wapyt-theme"], attrs["data-wapyt-theme-mode"],
|
|
30
|
+
globalThis.wapyt.getTheme()]);
|
|
31
|
+
const osTo = (dark) => { query.matches = dark; listeners.forEach((fn) => fn({ matches: dark })); };
|
|
32
|
+
for (const step of JSON.parse(process.argv[3])) {
|
|
33
|
+
if (step[0] === "set") globalThis.wapyt.setTheme(step[1]);
|
|
34
|
+
else osTo(step[1] === "dark");
|
|
35
|
+
snap(step.join(" "));
|
|
36
|
+
}
|
|
37
|
+
console.log(JSON.stringify(log));
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(os_scheme: str, steps):
|
|
42
|
+
node = shutil.which("node")
|
|
43
|
+
if not node:
|
|
44
|
+
pytest.skip("node is not installed")
|
|
45
|
+
out = subprocess.run([node, "-e", HARNESS, str(SCRIPT), os_scheme, json.dumps(steps)],
|
|
46
|
+
capture_output=True, text=True, check=True)
|
|
47
|
+
return [(theme, mode, info) for _label, theme, mode, info in json.loads(out.stdout)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_system_resolves_and_follows_the_os():
|
|
51
|
+
result = run("dark", [["set", "system"], ["os", "light"], ["os", "dark"]])
|
|
52
|
+
assert [r[0] for r in result] == ["dark", "light", "dark"]
|
|
53
|
+
assert all(r[1] == "system" for r in result)
|
|
54
|
+
assert result[1][2] == {"mode": "system", "resolved": "light"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_explicit_theme_ignores_the_os():
|
|
58
|
+
result = run("light", [["set", "system"], ["set", "light"], ["os", "dark"]])
|
|
59
|
+
assert result[-1][:2] == ("light", "light")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_back_to_system_picks_up_the_current_os_setting():
|
|
63
|
+
result = run("light", [["set", "dark"], ["os", "light"], ["set", "System"]])
|
|
64
|
+
assert result[-1][:2] == ("light", "system")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""ThemeEditor config, theme validation and the wrapper's calls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import wapyt.themeeditor.themeeditor as te_module
|
|
10
|
+
from wapyt import ThemeEditor, ThemeEditorConfig, ThemePalette
|
|
11
|
+
from wapyt.themeeditor import check_theme
|
|
12
|
+
from wapyt.themeeditor.themeeditor_config import normalize_hex
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_default_config():
|
|
16
|
+
assert ThemeEditorConfig().to_dict() == {
|
|
17
|
+
"previewMode": True, "apply": True, "showShape": True, "showFont": True,
|
|
18
|
+
"showExport": True, "title": "Theme",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_title_none_is_sent_as_null():
|
|
23
|
+
assert ThemeEditorConfig(title=None).to_dict()["title"] is None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_palettes_mix_built_ins_and_custom():
|
|
27
|
+
brand = ThemePalette("brand", "Brand", light={"accent": "#0A7"}, dark={"accent": "#0c9"})
|
|
28
|
+
payload = ThemeEditorConfig(palettes=["default", brand], value={"palette": "brand"}).to_dict()
|
|
29
|
+
assert payload["palettes"] == ["default", {"id": "brand", "label": "Brand",
|
|
30
|
+
"light": {"accent": "#00aa77"}, "dark": {"accent": "#00cc99"}}]
|
|
31
|
+
assert payload["value"]["palette"] == "brand"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("palettes, message", [
|
|
35
|
+
(["nope"], "unknown palette"),
|
|
36
|
+
([], "at least one"),
|
|
37
|
+
([ThemePalette("ocean")], "built-in"),
|
|
38
|
+
([ThemePalette("x", light={"shadow": "#000"})], "unknown theme token"),
|
|
39
|
+
])
|
|
40
|
+
def test_bad_palettes(palettes, message):
|
|
41
|
+
with pytest.raises(ValueError, match=message):
|
|
42
|
+
ThemeEditorConfig(palettes=palettes).to_dict()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_bad_mode():
|
|
46
|
+
with pytest.raises(ValueError):
|
|
47
|
+
ThemeEditorConfig(mode="sepia").to_dict()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_check_theme_normalises():
|
|
51
|
+
assert check_theme({"palette": "forest", "dark": {"text": "#FFF"}, "radius": 7.6}) == {
|
|
52
|
+
"palette": "forest", "light": {}, "dark": {"text": "#ffffff"}, "radius": 8, "font": "system"}
|
|
53
|
+
assert check_theme(None)["palette"] == "default"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize("theme, message", [
|
|
57
|
+
({"palette": "mystery"}, "unknown palette"),
|
|
58
|
+
({"light": {"accent": "blue"}}, "not a hex"),
|
|
59
|
+
({"light": {"glow": "#000"}}, "unknown theme token"),
|
|
60
|
+
({"radius": 40}, "radius"),
|
|
61
|
+
({"radius": True}, "radius"),
|
|
62
|
+
({"colours": {}}, "unknown theme keys"),
|
|
63
|
+
])
|
|
64
|
+
def test_check_theme_rejects(theme, message):
|
|
65
|
+
with pytest.raises(ValueError, match=message):
|
|
66
|
+
check_theme(theme)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_custom_palette_ids_are_accepted_when_named():
|
|
70
|
+
assert check_theme({"palette": "brand"}, ["brand"])["palette"] == "brand"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("raw, hex_", [("#ABC", "#aabbcc"), ("123456", "#123456"), (" #0f0f0f ", "#0f0f0f")])
|
|
74
|
+
def test_normalize_hex(raw, hex_):
|
|
75
|
+
assert normalize_hex(raw) == hex_
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FakeEditor:
|
|
79
|
+
def __init__(self):
|
|
80
|
+
self.calls = []
|
|
81
|
+
|
|
82
|
+
def __getattr__(self, name):
|
|
83
|
+
return lambda *args: self.calls.append((name, args)) or {"palette": "default"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture
|
|
87
|
+
def editor(monkeypatch):
|
|
88
|
+
monkeypatch.setattr(te_module, "_to_js", lambda value: json.loads(json.dumps(value)))
|
|
89
|
+
w = object.__new__(ThemeEditor)
|
|
90
|
+
w.config = ThemeEditorConfig(palettes=["default", ThemePalette("brand")])
|
|
91
|
+
w.editor = FakeEditor()
|
|
92
|
+
return w
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_set_theme_validates_then_calls_js(editor):
|
|
96
|
+
editor.set_theme({"palette": "brand", "light": {"accent": "#ABC"}})
|
|
97
|
+
name, (theme, opts) = editor.editor.calls[-1]
|
|
98
|
+
assert name == "setTheme" and theme["light"] == {"accent": "#aabbcc"} and opts == {"silent": False}
|
|
99
|
+
with pytest.raises(ValueError):
|
|
100
|
+
editor.set_theme({"palette": "unknown"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_set_mode_checks_its_argument(editor):
|
|
104
|
+
editor.set_mode("dark")
|
|
105
|
+
assert editor.editor.calls[-1] == ("setMode", ("dark",))
|
|
106
|
+
with pytest.raises(ValueError):
|
|
107
|
+
editor.set_mode("dusk")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_apply_theme_registers_palettes(monkeypatch):
|
|
111
|
+
calls = []
|
|
112
|
+
ns = SimpleNamespace(registerPalette=lambda spec: calls.append(("register", spec)),
|
|
113
|
+
apply=lambda theme: calls.append(("apply", theme)) or theme)
|
|
114
|
+
monkeypatch.setattr(te_module, "_theme_js", lambda: ns)
|
|
115
|
+
monkeypatch.setattr(te_module, "_to_js", lambda value: value)
|
|
116
|
+
result = te_module.apply_theme({"palette": "brand"}, palettes=[ThemePalette("brand", light={"accent": "#000"})])
|
|
117
|
+
assert calls[0][0] == "register" and calls[1][0] == "apply"
|
|
118
|
+
assert result["palette"] == "brand"
|
|
119
|
+
te_module.apply_theme(None)
|
|
120
|
+
assert calls[-1] == ("apply", None)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The theme model in themeeditor.js, run under Node (skipped without node):
|
|
3
|
+
it must agree with the Python lists and with wapyt.css, and every built-in
|
|
4
|
+
palette must meet its own contrast targets.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from wapyt.themeeditor import FONTS, PALETTES, THEME_TOKENS
|
|
17
|
+
|
|
18
|
+
ASSETS = Path(__file__).resolve().parents[1] / "wapyt" / "assets"
|
|
19
|
+
|
|
20
|
+
HARNESS = r"""
|
|
21
|
+
globalThis.document = { documentElement: { style: { setProperty(){}, removeProperty(){} },
|
|
22
|
+
setAttribute(){}, removeAttribute(){}, getAttribute(){ return null; } } };
|
|
23
|
+
require("vm").runInThisContext(require("fs").readFileSync(process.argv[1], "utf8"));
|
|
24
|
+
const T = globalThis.wapyt.theme;
|
|
25
|
+
const accents = ["#2563eb", "#0e7490", "#facc15", "#ff00ff", "#111111", "#eeeeee", "#16a34a", "#9333ea"];
|
|
26
|
+
const derived = {};
|
|
27
|
+
for (const a of accents) for (const m of ["light", "dark"]) {
|
|
28
|
+
const d = T.deriveAccent(a, m);
|
|
29
|
+
derived[a + "/" + m] = { ...d, ratio: T.contrast(a, d["on-accent"]) };
|
|
30
|
+
}
|
|
31
|
+
console.log(JSON.stringify({
|
|
32
|
+
tokens: T.TOKENS.map((t) => ({ key: t.key, light: t.light, dark: t.dark, on: t.on || null, min: t.min || null })),
|
|
33
|
+
fonts: T.FONTS.map((f) => f.id),
|
|
34
|
+
palettes: T.palettes().map((p) => ({ id: p.id, ...T.resolve({ palette: p.id }) })),
|
|
35
|
+
defaults: T.DEFAULT,
|
|
36
|
+
normalized: T.normalize({ palette: "nope", light: { accent: "#ABC", bogus: "#000", text: "red" }, radius: 99 }),
|
|
37
|
+
ratioWhiteBlack: T.contrast("#ffffff", "#000000"),
|
|
38
|
+
derived,
|
|
39
|
+
}));
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture(scope="module")
|
|
44
|
+
def model():
|
|
45
|
+
node = shutil.which("node")
|
|
46
|
+
if not node:
|
|
47
|
+
pytest.skip("node is not installed")
|
|
48
|
+
out = subprocess.run([node, "-e", HARNESS, str(ASSETS / "themeeditor.js")],
|
|
49
|
+
capture_output=True, text=True, check=True)
|
|
50
|
+
return json.loads(out.stdout)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _lum(hex_):
|
|
54
|
+
def ch(v):
|
|
55
|
+
c = int(v, 16) / 255
|
|
56
|
+
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
57
|
+
r, g, b = (ch(hex_[i:i + 2]) for i in (1, 3, 5))
|
|
58
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ratio(a, b):
|
|
62
|
+
hi, lo = sorted((_lum(a), _lum(b)), reverse=True)
|
|
63
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_lists_match_python(model):
|
|
67
|
+
assert [t["key"] for t in model["tokens"]] == THEME_TOKENS
|
|
68
|
+
assert [p["id"] for p in model["palettes"]] == PALETTES
|
|
69
|
+
assert model["fonts"] == FONTS
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_defaults_match_the_stylesheet(model):
|
|
73
|
+
css = (ASSETS / "wapyt.css").read_text()
|
|
74
|
+
root = css[css.index(":root {"):css.index("}", css.index(":root {"))]
|
|
75
|
+
declared = dict(re.findall(r"(--wapyt-[a-z-]+):\s*(#[0-9a-fA-F]{6})", root))
|
|
76
|
+
for tok in model["tokens"]:
|
|
77
|
+
for mode in ("light", "dark"):
|
|
78
|
+
assert declared[tok[mode]].lower() == model["defaults"][mode][tok["key"]], (tok["key"], mode)
|
|
79
|
+
assert "--wapyt-radius: 6px" in root
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_contrast_maths(model):
|
|
83
|
+
assert round(model["ratioWhiteBlack"], 2) == 21.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize("mode", ["light", "dark"])
|
|
87
|
+
def test_built_in_palettes_meet_their_targets(model, mode):
|
|
88
|
+
failures = []
|
|
89
|
+
for p in model["palettes"]:
|
|
90
|
+
v = p[mode]
|
|
91
|
+
for tok in model["tokens"]:
|
|
92
|
+
if not tok["on"] or tok["on"] == "tint":
|
|
93
|
+
continue
|
|
94
|
+
r = ratio(v[tok["key"]], v[tok["on"]])
|
|
95
|
+
if r < tok["min"]:
|
|
96
|
+
failures.append(f"{p['id']}/{mode}: {tok['key']} on {tok['on']} = {r:.2f} < {tok['min']}")
|
|
97
|
+
r = ratio(v["text"], v["surface"])
|
|
98
|
+
if r < 4.5:
|
|
99
|
+
failures.append(f"{p['id']}/{mode}: text on surface = {r:.2f}")
|
|
100
|
+
assert failures == []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_normalize_drops_bad_values(model):
|
|
104
|
+
n = model["normalized"]
|
|
105
|
+
assert n["palette"] == "default"
|
|
106
|
+
assert n["light"] == {"accent": "#aabbcc"}
|
|
107
|
+
assert n["radius"] == 24
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_derived_accent_text_is_readable(model):
|
|
111
|
+
assert all(d["ratio"] >= 4.5 for d in model["derived"].values()), {
|
|
112
|
+
k: round(d["ratio"], 2) for k, d in model["derived"].items() if d["ratio"] < 4.5}
|