tg-tree-wizard 1.0.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Артем Самарин
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: tg-tree-wizard
3
+ Version: 1.0.0
4
+ Summary: Лёгкий движок древовидных inline-опросников (wizard) для aiogram 3
5
+ Author: Артем Самарин
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Артем Самарин
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/ваш-ник/tg-tree-wizard
29
+ Project-URL: Repository, https://github.com/ваш-ник/tg-tree-wizard
30
+ Project-URL: Issues, https://github.com/ваш-ник/tg-tree-wizard/issues
31
+ Keywords: aiogram,telegram,telegram-bot,inline-keyboard,fsm,wizard
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Framework :: AsyncIO
40
+ Classifier: Topic :: Communications :: Chat
41
+ Requires-Python: >=3.10
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: aiogram>=3.0
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest>=8.0; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # tg-tree-wizard
50
+
51
+ Лёгкий движок древовидных inline-опросников для aiogram 3.
52
+ Дерево описывается данными (`Node`/`Option`), а не отдельным хендлером на
53
+ каждый уровень; "назад" и переходы обрабатываются двумя общими хендлерами
54
+ независимо от глубины дерева.
55
+
56
+ ## Сколько кода экономит
57
+
58
+ Замер на дереве из 6 уровней (язык → формат → цель → индивидуально/группа →
59
+ возраст → уровень владения), с 2-7 вариантами ответа на каждом —
60
+ навигационная часть кода, без учёта бизнес-логики финального шага
61
+ (сохранение заявки и т.п. — она одинакова в обоих случаях):
62
+
63
+ | | Строк кода |
64
+ |---|---|
65
+ | Вручную (клавиатура + парсинг callback_data + кнопка "назад" на каждый уровень) | ~169 |
66
+ | `tg-tree-wizard` (`linear_wizard` + подключение роутера) | ~26 |
67
+
68
+ Разница держится линейно — не растёт и не убывает в зависимости от того,
69
+ сколько таких деревьев в проекте: каждое новое дерево стоит фиксированные
70
+ ~26 строк вместо ~169, потому что логика навигации, "назад" и защита от
71
+ превышения лимита `callback_data` уже написаны и покрыты тестами один раз,
72
+ внутри самой библиотеки.
73
+
74
+ ## Структура пакета
75
+
76
+ ```
77
+ tg_tree_wizard/
78
+ ├── pyproject.toml # метаданные пакета, как его ставить
79
+ ├── src/tg_tree_wizard/
80
+ │ ├── __init__.py # публичный API (что видно снаружи через import)
81
+ │ ├── core.py # чистая логика дерева, БЕЗ aiogram
82
+ │ └── aiogram_adapter.py # клавиатуры + хендлеры aiogram поверх core.py
83
+ ├── tests/
84
+ │ └── test_core.py # тесты ядра, без сети и без Telegram
85
+ └── examples/
86
+ ├── simulate_flow.py # прогон через настоящий aiogram Dispatcher, без сети
87
+ └── language_school_bot.py # реальный бот — запускается с настоящим токеном
88
+ ```
89
+
90
+ Почему `core.py` отдельно от `aiogram_adapter.py`: это приём, который
91
+ делает библиотеку тестируемой. Ядро ничего не знает про Telegram — значит,
92
+ его логику (переходы, "назад", ошибки) можно проверить обычным `pytest` за
93
+ доли секунды, без сети и без живого бота. Адаптер — тонкий слой, который
94
+ только рендерит клавиатуры и дёргает ядро.
95
+
96
+ ## Установка
97
+
98
+ ```bash
99
+ cd tg_tree_wizard
100
+ pip install -e . # editable-режим: правите код — сразу видно в проекте
101
+ ```
102
+
103
+ `-e` (editable) значит, что пакет ставится "по ссылке" на исходники, а не
104
+ копируется — удобно, пока сами дорабатываете библиотеку.
105
+
106
+ ## Тесты ядра (без Telegram)
107
+
108
+ ```bash
109
+ pip install pytest
110
+ pytest tests/ -v
111
+ ```
112
+
113
+ ## Прогон через настоящий aiogram, но без сети
114
+
115
+ ```bash
116
+ python examples/simulate_flow.py
117
+ ```
118
+
119
+ Использует настоящий `Dispatcher` и `MemoryStorage`, подменяет только
120
+ реальный HTTP-запрос к `api.telegram.org` — так что проверяется всё,
121
+ кроме собственно доставки сообщений.
122
+
123
+ ## Запуск с реальным ботом
124
+
125
+ ```bash
126
+ export BOT_TOKEN=ваш_настоящий_токен
127
+ python examples/language_school_bot.py
128
+ ```
129
+
130
+ Дальше пишите `/survey` в бота и проходите дерево — это уже 100% реальная
131
+ проверка через живой Telegram.
132
+
133
+ ## Как описать своё дерево
134
+
135
+ **Короткий способ (рекомендуется для большинства случаев)** — `linear_wizard`.
136
+ Подходит, когда вопросы идут по порядку и лишь изредка нужно свернуть в
137
+ сторону:
138
+
139
+ ```python
140
+ from tg_tree_wizard import linear_wizard, TreeWizard
141
+
142
+ TREE, ROOT = linear_wizard([
143
+ ("lang", "Выберите язык:", ["Английский", "Немецкий"]), # label == value
144
+ ("delivery", "Формат:", [("Очно", "offline"), ("Онлайн", "online")]),
145
+ ("topping", "Начинка:", [
146
+ ("Пепперони", "pepperoni", "spicy"), # явный переход = ветвление
147
+ ("Маргарита", "margherita"), # без 3-го элемента = следующий шаг по порядку
148
+ ]),
149
+ ("spicy", "Поострее?", ["Да", "Нет"]),
150
+ ])
151
+
152
+ wizard = TreeWizard(TREE, root=ROOT, on_finish=my_finish_callback)
153
+ dp.include_router(wizard.router)
154
+ ```
155
+
156
+ Правила для варианта ответа:
157
+ - `"Текст"` — метка и значение совпадают, переход на следующий шаг по списку;
158
+ - `("Текст", "значение")` — то же самое, но значение отдельно от текста кнопки;
159
+ - `("Текст", "значение", "id_узла")` — явный переход, для ветвления или
160
+ для перехода не на следующий, а на произвольный узел (в т.ч. в обход
161
+ промежуточных шагов).
162
+
163
+ Последний шаг в списке — финальный по умолчанию (после него опрос
164
+ завершается), если явно не переопределить переход у его вариантов.
165
+
166
+ Полный способ — `Node`/`Option` напрямую. Нужен, когда переходов в
167
+ дерево больше, чем шагов, и связь "шаг → следующий по умолчанию" не
168
+ работает (сложные ветвящиеся графы, несколько независимых корней и т.п.):
169
+
170
+ ```python
171
+ from tg_tree_wizard import Node, Option, TreeWizard
172
+
173
+ TREE = {
174
+ "start": Node(
175
+ text="Первый вопрос:",
176
+ options=(
177
+ Option("Вариант A", "a", "next_node_id"),
178
+ Option("Вариант B", "b", None), # None = опрос завершается
179
+ ),
180
+ ),
181
+ "next_node_id": Node(...),
182
+ }
183
+
184
+ wizard = TreeWizard(TREE, root="start", on_finish=my_finish_callback)
185
+ ```
186
+
187
+ `TreeWizard(...)` сам вызывает `validate_tree` при создании — если где-то
188
+ опечатались в id узла, бот упадёт при старте с понятной ошибкой.
@@ -0,0 +1,140 @@
1
+ # tg-tree-wizard
2
+
3
+ Лёгкий движок древовидных inline-опросников для aiogram 3.
4
+ Дерево описывается данными (`Node`/`Option`), а не отдельным хендлером на
5
+ каждый уровень; "назад" и переходы обрабатываются двумя общими хендлерами
6
+ независимо от глубины дерева.
7
+
8
+ ## Сколько кода экономит
9
+
10
+ Замер на дереве из 6 уровней (язык → формат → цель → индивидуально/группа →
11
+ возраст → уровень владения), с 2-7 вариантами ответа на каждом —
12
+ навигационная часть кода, без учёта бизнес-логики финального шага
13
+ (сохранение заявки и т.п. — она одинакова в обоих случаях):
14
+
15
+ | | Строк кода |
16
+ |---|---|
17
+ | Вручную (клавиатура + парсинг callback_data + кнопка "назад" на каждый уровень) | ~169 |
18
+ | `tg-tree-wizard` (`linear_wizard` + подключение роутера) | ~26 |
19
+
20
+ Разница держится линейно — не растёт и не убывает в зависимости от того,
21
+ сколько таких деревьев в проекте: каждое новое дерево стоит фиксированные
22
+ ~26 строк вместо ~169, потому что логика навигации, "назад" и защита от
23
+ превышения лимита `callback_data` уже написаны и покрыты тестами один раз,
24
+ внутри самой библиотеки.
25
+
26
+ ## Структура пакета
27
+
28
+ ```
29
+ tg_tree_wizard/
30
+ ├── pyproject.toml # метаданные пакета, как его ставить
31
+ ├── src/tg_tree_wizard/
32
+ │ ├── __init__.py # публичный API (что видно снаружи через import)
33
+ │ ├── core.py # чистая логика дерева, БЕЗ aiogram
34
+ │ └── aiogram_adapter.py # клавиатуры + хендлеры aiogram поверх core.py
35
+ ├── tests/
36
+ │ └── test_core.py # тесты ядра, без сети и без Telegram
37
+ └── examples/
38
+ ├── simulate_flow.py # прогон через настоящий aiogram Dispatcher, без сети
39
+ └── language_school_bot.py # реальный бот — запускается с настоящим токеном
40
+ ```
41
+
42
+ Почему `core.py` отдельно от `aiogram_adapter.py`: это приём, который
43
+ делает библиотеку тестируемой. Ядро ничего не знает про Telegram — значит,
44
+ его логику (переходы, "назад", ошибки) можно проверить обычным `pytest` за
45
+ доли секунды, без сети и без живого бота. Адаптер — тонкий слой, который
46
+ только рендерит клавиатуры и дёргает ядро.
47
+
48
+ ## Установка
49
+
50
+ ```bash
51
+ cd tg_tree_wizard
52
+ pip install -e . # editable-режим: правите код — сразу видно в проекте
53
+ ```
54
+
55
+ `-e` (editable) значит, что пакет ставится "по ссылке" на исходники, а не
56
+ копируется — удобно, пока сами дорабатываете библиотеку.
57
+
58
+ ## Тесты ядра (без Telegram)
59
+
60
+ ```bash
61
+ pip install pytest
62
+ pytest tests/ -v
63
+ ```
64
+
65
+ ## Прогон через настоящий aiogram, но без сети
66
+
67
+ ```bash
68
+ python examples/simulate_flow.py
69
+ ```
70
+
71
+ Использует настоящий `Dispatcher` и `MemoryStorage`, подменяет только
72
+ реальный HTTP-запрос к `api.telegram.org` — так что проверяется всё,
73
+ кроме собственно доставки сообщений.
74
+
75
+ ## Запуск с реальным ботом
76
+
77
+ ```bash
78
+ export BOT_TOKEN=ваш_настоящий_токен
79
+ python examples/language_school_bot.py
80
+ ```
81
+
82
+ Дальше пишите `/survey` в бота и проходите дерево — это уже 100% реальная
83
+ проверка через живой Telegram.
84
+
85
+ ## Как описать своё дерево
86
+
87
+ **Короткий способ (рекомендуется для большинства случаев)** — `linear_wizard`.
88
+ Подходит, когда вопросы идут по порядку и лишь изредка нужно свернуть в
89
+ сторону:
90
+
91
+ ```python
92
+ from tg_tree_wizard import linear_wizard, TreeWizard
93
+
94
+ TREE, ROOT = linear_wizard([
95
+ ("lang", "Выберите язык:", ["Английский", "Немецкий"]), # label == value
96
+ ("delivery", "Формат:", [("Очно", "offline"), ("Онлайн", "online")]),
97
+ ("topping", "Начинка:", [
98
+ ("Пепперони", "pepperoni", "spicy"), # явный переход = ветвление
99
+ ("Маргарита", "margherita"), # без 3-го элемента = следующий шаг по порядку
100
+ ]),
101
+ ("spicy", "Поострее?", ["Да", "Нет"]),
102
+ ])
103
+
104
+ wizard = TreeWizard(TREE, root=ROOT, on_finish=my_finish_callback)
105
+ dp.include_router(wizard.router)
106
+ ```
107
+
108
+ Правила для варианта ответа:
109
+ - `"Текст"` — метка и значение совпадают, переход на следующий шаг по списку;
110
+ - `("Текст", "значение")` — то же самое, но значение отдельно от текста кнопки;
111
+ - `("Текст", "значение", "id_узла")` — явный переход, для ветвления или
112
+ для перехода не на следующий, а на произвольный узел (в т.ч. в обход
113
+ промежуточных шагов).
114
+
115
+ Последний шаг в списке — финальный по умолчанию (после него опрос
116
+ завершается), если явно не переопределить переход у его вариантов.
117
+
118
+ Полный способ — `Node`/`Option` напрямую. Нужен, когда переходов в
119
+ дерево больше, чем шагов, и связь "шаг → следующий по умолчанию" не
120
+ работает (сложные ветвящиеся графы, несколько независимых корней и т.п.):
121
+
122
+ ```python
123
+ from tg_tree_wizard import Node, Option, TreeWizard
124
+
125
+ TREE = {
126
+ "start": Node(
127
+ text="Первый вопрос:",
128
+ options=(
129
+ Option("Вариант A", "a", "next_node_id"),
130
+ Option("Вариант B", "b", None), # None = опрос завершается
131
+ ),
132
+ ),
133
+ "next_node_id": Node(...),
134
+ }
135
+
136
+ wizard = TreeWizard(TREE, root="start", on_finish=my_finish_callback)
137
+ ```
138
+
139
+ `TreeWizard(...)` сам вызывает `validate_tree` при создании — если где-то
140
+ опечатались в id узла, бот упадёт при старте с понятной ошибкой.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tg-tree-wizard"
7
+ version = "1.0.0"
8
+ description = "Лёгкий движок древовидных inline-опросников (wizard) для aiogram 3"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Артем Самарин" },
14
+ ]
15
+ keywords = ["aiogram", "telegram", "telegram-bot", "inline-keyboard", "fsm", "wizard"]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Framework :: AsyncIO",
25
+ "Topic :: Communications :: Chat",
26
+ ]
27
+ dependencies = [
28
+ "aiogram>=3.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8.0"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/ваш-ник/tg-tree-wizard"
36
+ Repository = "https://github.com/ваш-ник/tg-tree-wizard"
37
+ Issues = "https://github.com/ваш-ник/tg-tree-wizard/issues"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.setuptools.package-data]
43
+ tg_tree_wizard = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ from .core import Node, Option, WizardState, TreeError, StaleChoiceError, linear_wizard
2
+ from .aiogram_adapter import TreeWizard, WizardStates, pack_buttons, check_callback_data_limits
3
+
4
+ __all__ = [
5
+ "Node",
6
+ "Option",
7
+ "WizardState",
8
+ "TreeError",
9
+ "StaleChoiceError",
10
+ "linear_wizard",
11
+ "TreeWizard",
12
+ "WizardStates",
13
+ "pack_buttons",
14
+ "check_callback_data_limits",
15
+ ]
@@ -0,0 +1,182 @@
1
+ """
2
+ Тонкий адаптер над core.py: рендер клавиатур, хендлеры aiogram,
3
+ хранение WizardState в FSMContext. Логики дерева здесь нет —
4
+ она уже проверена в core.py, тут только Telegram I/O.
5
+ """
6
+
7
+ from aiogram import Router, F
8
+ from aiogram.filters import StateFilter
9
+ from aiogram.fsm.context import FSMContext
10
+ from aiogram.fsm.state import State, StatesGroup
11
+ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
12
+
13
+ from .core import Node, WizardState, choose, go_back, validate_tree, StaleChoiceError, TreeError
14
+
15
+
16
+ class WizardStates(StatesGroup):
17
+ active = State() # один State на весь опрос, независимо от глубины дерева
18
+
19
+
20
+ # Официальный лимит Telegram Bot API на длину callback_data.
21
+ TELEGRAM_CALLBACK_DATA_LIMIT_BYTES = 64
22
+
23
+
24
+ def check_callback_data_limits(tree: dict[str, Node], callback_prefix: str) -> None:
25
+ """
26
+ Проверяет, что callback_data ни для одного узла/варианта не превысит
27
+ лимит Telegram в 64 байта, и что id узлов не содержат ':' (используется
28
+ как разделитель в callback_data и сломает парсинг).
29
+
30
+ В отличие от исходного бота, где в callback_data кодировался весь
31
+ пройденный путь (и лимит рос вместе с глубиной дерева), здесь в
32
+ callback_data передаётся только текущий узел + индекс варианта — путь
33
+ хранится в FSMContext. Поэтому лимит практически не зависит от глубины
34
+ дерева, но всё ещё зависит от длины id узлов и callback_prefix.
35
+ """
36
+ if ":" in callback_prefix:
37
+ raise TreeError(f"callback_prefix {callback_prefix!r} не может содержать ':'")
38
+
39
+ back_data = f"{callback_prefix}_back"
40
+ back_len = len(back_data.encode("utf-8"))
41
+ if back_len > TELEGRAM_CALLBACK_DATA_LIMIT_BYTES:
42
+ raise TreeError(
43
+ f"callback_data кнопки 'Назад' ({back_data!r}) занимает {back_len} байт, "
44
+ f"это больше лимита Telegram в {TELEGRAM_CALLBACK_DATA_LIMIT_BYTES}. "
45
+ f"Сократите callback_prefix."
46
+ )
47
+
48
+ for node_id, node in tree.items():
49
+ if ":" in node_id:
50
+ raise TreeError(
51
+ f"id узла {node_id!r} содержит ':' — это разделитель в callback_data, "
52
+ f"переименуйте узел."
53
+ )
54
+
55
+ max_index = len(node.options) - 1
56
+ candidate = f"{callback_prefix}:{node_id}:{max_index}"
57
+ length = len(candidate.encode("utf-8"))
58
+ if length > TELEGRAM_CALLBACK_DATA_LIMIT_BYTES:
59
+ raise TreeError(
60
+ f"callback_data для узла {node_id!r} займёт {length} байт "
61
+ f"({candidate!r}), это больше лимита Telegram в "
62
+ f"{TELEGRAM_CALLBACK_DATA_LIMIT_BYTES}. Сократите id узла "
63
+ f"или callback_prefix."
64
+ )
65
+
66
+
67
+ def pack_buttons(
68
+ buttons: list[InlineKeyboardButton],
69
+ max_row_length: int = 30,
70
+ ) -> list[list[InlineKeyboardButton]]:
71
+ """Авто-раскладка кнопок по рядам с учётом длины текста."""
72
+ rows: list[list[InlineKeyboardButton]] = []
73
+ current_row: list[InlineKeyboardButton] = []
74
+ current_length = 0
75
+
76
+ for button in buttons:
77
+ button_length = len(button.text) + 5
78
+ if current_row and current_length + button_length > max_row_length:
79
+ rows.append(current_row)
80
+ current_row = [button]
81
+ current_length = button_length
82
+ else:
83
+ current_row.append(button)
84
+ current_length += button_length
85
+
86
+ if current_row:
87
+ rows.append(current_row)
88
+ return rows
89
+
90
+
91
+ class TreeWizard:
92
+ """
93
+ Собирает Router для дерева. Использование:
94
+
95
+ wizard = TreeWizard(TREE, root="lang", callback_prefix="wz")
96
+ dp.include_router(wizard.router)
97
+
98
+ @dp.message(Command("survey"))
99
+ async def cmd_survey(message: Message, state: FSMContext):
100
+ await wizard.start(message, state)
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ tree: dict[str, Node],
106
+ root: str,
107
+ callback_prefix: str = "wz",
108
+ max_row_length: int = 30,
109
+ on_finish=None, # async def on_finish(message_or_call, state, answers): ...
110
+ ):
111
+ validate_tree(tree, root) # падаем при старте бота, а не в рантайме
112
+ check_callback_data_limits(tree, callback_prefix)
113
+ self.tree = tree
114
+ self.root = root
115
+ self.prefix = callback_prefix
116
+ self.max_row_length = max_row_length
117
+ self.on_finish = on_finish
118
+ self.router = Router()
119
+ self._register_handlers()
120
+
121
+ def _build_keyboard(self, node_id: str, node: Node) -> InlineKeyboardMarkup:
122
+ buttons = [
123
+ InlineKeyboardButton(
124
+ text=opt.label, callback_data=f"{self.prefix}:{node_id}:{i}"
125
+ )
126
+ for i, opt in enumerate(node.options)
127
+ ]
128
+ rows = pack_buttons(buttons, self.max_row_length)
129
+ if node_id != self.root:
130
+ rows.append(
131
+ [InlineKeyboardButton(text="⬅️ Назад", callback_data=f"{self.prefix}_back")]
132
+ )
133
+ return InlineKeyboardMarkup(inline_keyboard=rows)
134
+
135
+ async def _render(self, target, node_id: str):
136
+ node = self.tree[node_id]
137
+ kb = self._build_keyboard(node_id, node)
138
+ if isinstance(target, CallbackQuery):
139
+ await target.message.edit_text(node.text, reply_markup=kb)
140
+ else:
141
+ await target.answer(node.text, reply_markup=kb)
142
+
143
+ async def start(self, message: Message, state: FSMContext):
144
+ wz = WizardState.start(self.root)
145
+ await state.set_state(WizardStates.active)
146
+ await state.update_data(**wz.to_dict())
147
+ await self._render(message, self.root)
148
+
149
+ def _register_handlers(self):
150
+ @self.router.callback_query(
151
+ StateFilter(WizardStates.active), F.data.startswith(f"{self.prefix}:")
152
+ )
153
+ async def handle_choice(call: CallbackQuery, state: FSMContext):
154
+ _, node_id, idx_str = call.data.split(":")
155
+ data = await state.get_data()
156
+ wz = WizardState.from_dict(data)
157
+
158
+ try:
159
+ new_wz, opt, is_final = choose(self.tree, wz, node_id, int(idx_str))
160
+ except StaleChoiceError:
161
+ await call.answer("Это меню устарело, начните заново.", show_alert=True)
162
+ return
163
+
164
+ await state.update_data(**new_wz.to_dict())
165
+
166
+ if is_final:
167
+ if self.on_finish:
168
+ await self.on_finish(call, state, new_wz.answers)
169
+ await state.clear()
170
+ else:
171
+ await self._render(call, opt.next_node)
172
+ await call.answer()
173
+
174
+ @self.router.callback_query(
175
+ StateFilter(WizardStates.active), F.data == f"{self.prefix}_back"
176
+ )
177
+ async def handle_back(call: CallbackQuery, state: FSMContext):
178
+ data = await state.get_data()
179
+ wz = go_back(WizardState.from_dict(data))
180
+ await state.update_data(**wz.to_dict())
181
+ await self._render(call, wz.current_node)
182
+ await call.answer()
@@ -0,0 +1,186 @@
1
+ """
2
+ Ядро дерева-опросника. Никаких импортов aiogram здесь нет и не должно быть —
3
+ это чистая логика, которую можно тестировать без Telegram и без сети.
4
+ """
5
+
6
+ from dataclasses import dataclass, field, replace
7
+
8
+
9
+ class TreeError(Exception):
10
+ """Ошибка в описании дерева (обнаруживается один раз, при старте бота)."""
11
+
12
+
13
+ class StaleChoiceError(Exception):
14
+ """Пользователь нажал на кнопку из уже неактуального (старого) сообщения."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Option:
19
+ label: str
20
+ value: str
21
+ next_node: str | None = None # None = после выбора опрос завершается
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Node:
26
+ text: str
27
+ options: tuple[Option, ...] = field(default_factory=tuple)
28
+
29
+
30
+ def validate_tree(tree: dict[str, Node], root: str) -> None:
31
+ """
32
+ Проверяет дерево один раз при старте бота, а не в рантайме на живом
33
+ пользователе. Ловит: отсутствующий root, ссылки на несуществующие
34
+ узлы, узлы без вариантов ответа (тупик).
35
+ """
36
+ if root not in tree:
37
+ raise TreeError(f"Корневой узел {root!r} отсутствует в дереве")
38
+
39
+ for node_id, node in tree.items():
40
+ if not node.options:
41
+ raise TreeError(f"Узел {node_id!r} не содержит вариантов ответа")
42
+ for opt in node.options:
43
+ if opt.next_node is not None and opt.next_node not in tree:
44
+ raise TreeError(
45
+ f"Узел {node_id!r}: вариант {opt.label!r} ссылается "
46
+ f"на несуществующий узел {opt.next_node!r}"
47
+ )
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Answer:
52
+ node: str
53
+ label: str
54
+ value: str
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class WizardState:
59
+ """
60
+ Всё состояние одного прохождения опроса. Неизменяемое (frozen) —
61
+ каждая операция возвращает НОВЫЙ WizardState, старый не трогается.
62
+ Это то, что вы будете класть целиком в FSMContext.data.
63
+ """
64
+ stack: tuple[str, ...]
65
+ answers: tuple[Answer, ...] = field(default_factory=tuple)
66
+
67
+ @classmethod
68
+ def start(cls, root: str) -> "WizardState":
69
+ return cls(stack=(root,), answers=())
70
+
71
+ @property
72
+ def current_node(self) -> str:
73
+ return self.stack[-1]
74
+
75
+ def to_dict(self) -> dict:
76
+ """Для сохранения в FSMContext.data (там нужны сериализуемые типы)."""
77
+ return {
78
+ "stack": list(self.stack),
79
+ "answers": [a.__dict__ for a in self.answers],
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict) -> "WizardState":
84
+ return cls(
85
+ stack=tuple(data["stack"]),
86
+ answers=tuple(Answer(**a) for a in data["answers"]),
87
+ )
88
+
89
+
90
+ def choose(
91
+ tree: dict[str, Node],
92
+ state: WizardState,
93
+ node_id: str,
94
+ option_index: int,
95
+ ) -> tuple[WizardState, Option, bool]:
96
+ """
97
+ Обрабатывает выбор пользователя.
98
+ Возвращает (новое_состояние, выбранная_опция, завершён_ли_опрос).
99
+ """
100
+ if state.current_node != node_id:
101
+ # Пользователь нажал кнопку из уже неактуального сообщения
102
+ # (например, после /start заново или параллельного диалога).
103
+ raise StaleChoiceError(
104
+ f"Ожидался узел {state.current_node!r}, получен {node_id!r}"
105
+ )
106
+
107
+ node = tree[node_id]
108
+ if not (0 <= option_index < len(node.options)):
109
+ raise TreeError(f"Индекс {option_index} вне диапазона для узла {node_id!r}")
110
+
111
+ opt = node.options[option_index]
112
+ new_answers = state.answers + (Answer(node_id, opt.label, opt.value),)
113
+
114
+ if opt.next_node is None:
115
+ return replace(state, answers=new_answers), opt, True
116
+
117
+ new_stack = state.stack + (opt.next_node,)
118
+ return replace(state, stack=new_stack, answers=new_answers), opt, False
119
+
120
+
121
+ def go_back(state: WizardState) -> WizardState:
122
+ """Отменяет последний шаг. Если мы уже в корне — ничего не делает."""
123
+ if len(state.stack) <= 1:
124
+ return state
125
+ return WizardState(stack=state.stack[:-1], answers=state.answers[:-1])
126
+
127
+
128
+ # Один вариант ответа в коротком синтаксисе linear_wizard:
129
+ # "Текст кнопки" -> label == value, переход на следующий шаг
130
+ # ("Текст кнопки", "значение") -> явное значение, переход на следующий шаг
131
+ # ("Текст кнопки", "значение", "узел") -> явное значение + явный переход (ветвление)
132
+ ShortOption = str | tuple[str, str] | tuple[str, str, str]
133
+
134
+ # Один шаг: (id_узла, текст_вопроса, список_вариантов)
135
+ Step = tuple[str, str, list[ShortOption]]
136
+
137
+
138
+ def linear_wizard(steps: list[Step]) -> tuple[dict[str, Node], str]:
139
+ """
140
+ Короткий способ описать дерево для типового случая — линейной цепочки
141
+ вопросов, где почти все варианты ведут на следующий шаг по порядку.
142
+ Возвращает (tree, root_id), готовые для validate_tree()/TreeWizard().
143
+
144
+ Последний шаг по умолчанию — финальный (next_node=None у всех его
145
+ вариантов, если не переопределено явно).
146
+
147
+ Точечное ветвление — там, где часть вариантов уходит не на следующий
148
+ шаг, а куда-то ещё (или сразу в финал, минуя промежуточные шаги) —
149
+ делается явным третьим элементом кортежа с id нужного узла.
150
+
151
+ Пример:
152
+ TREE, ROOT = linear_wizard([
153
+ ("size", "Выберите размер:", ["Маленькая", "Средняя", "Большая"]),
154
+ ("topping", "Начинка:", [
155
+ ("Пепперони", "pepperoni", "spicy"), # ветка в сторону
156
+ "Маргарита", # обычный переход дальше
157
+ ]),
158
+ ("spicy", "Поострее?", ["Да", "Нет"]),
159
+ ("confirm", "Подтвердить заказ?", ["Да"]),
160
+ ])
161
+ """
162
+ if not steps:
163
+ raise TreeError("Нужен хотя бы один шаг")
164
+
165
+ node_ids = [step[0] for step in steps]
166
+ tree: dict[str, Node] = {}
167
+
168
+ for i, (node_id, text, options) in enumerate(steps):
169
+ default_next = node_ids[i + 1] if i + 1 < len(node_ids) else None
170
+ built_options = []
171
+
172
+ for opt in options:
173
+ if isinstance(opt, str):
174
+ label, value, next_node = opt, opt, default_next
175
+ elif len(opt) == 2:
176
+ label, value = opt
177
+ next_node = default_next
178
+ elif len(opt) == 3:
179
+ label, value, next_node = opt
180
+ else:
181
+ raise TreeError(f"Некорректный вариант ответа: {opt!r}")
182
+ built_options.append(Option(label, value, next_node))
183
+
184
+ tree[node_id] = Node(text=text, options=tuple(built_options))
185
+
186
+ return tree, node_ids[0]
File without changes
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: tg-tree-wizard
3
+ Version: 1.0.0
4
+ Summary: Лёгкий движок древовидных inline-опросников (wizard) для aiogram 3
5
+ Author: Артем Самарин
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Артем Самарин
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/ваш-ник/tg-tree-wizard
29
+ Project-URL: Repository, https://github.com/ваш-ник/tg-tree-wizard
30
+ Project-URL: Issues, https://github.com/ваш-ник/tg-tree-wizard/issues
31
+ Keywords: aiogram,telegram,telegram-bot,inline-keyboard,fsm,wizard
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Framework :: AsyncIO
40
+ Classifier: Topic :: Communications :: Chat
41
+ Requires-Python: >=3.10
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: aiogram>=3.0
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest>=8.0; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # tg-tree-wizard
50
+
51
+ Лёгкий движок древовидных inline-опросников для aiogram 3.
52
+ Дерево описывается данными (`Node`/`Option`), а не отдельным хендлером на
53
+ каждый уровень; "назад" и переходы обрабатываются двумя общими хендлерами
54
+ независимо от глубины дерева.
55
+
56
+ ## Сколько кода экономит
57
+
58
+ Замер на дереве из 6 уровней (язык → формат → цель → индивидуально/группа →
59
+ возраст → уровень владения), с 2-7 вариантами ответа на каждом —
60
+ навигационная часть кода, без учёта бизнес-логики финального шага
61
+ (сохранение заявки и т.п. — она одинакова в обоих случаях):
62
+
63
+ | | Строк кода |
64
+ |---|---|
65
+ | Вручную (клавиатура + парсинг callback_data + кнопка "назад" на каждый уровень) | ~169 |
66
+ | `tg-tree-wizard` (`linear_wizard` + подключение роутера) | ~26 |
67
+
68
+ Разница держится линейно — не растёт и не убывает в зависимости от того,
69
+ сколько таких деревьев в проекте: каждое новое дерево стоит фиксированные
70
+ ~26 строк вместо ~169, потому что логика навигации, "назад" и защита от
71
+ превышения лимита `callback_data` уже написаны и покрыты тестами один раз,
72
+ внутри самой библиотеки.
73
+
74
+ ## Структура пакета
75
+
76
+ ```
77
+ tg_tree_wizard/
78
+ ├── pyproject.toml # метаданные пакета, как его ставить
79
+ ├── src/tg_tree_wizard/
80
+ │ ├── __init__.py # публичный API (что видно снаружи через import)
81
+ │ ├── core.py # чистая логика дерева, БЕЗ aiogram
82
+ │ └── aiogram_adapter.py # клавиатуры + хендлеры aiogram поверх core.py
83
+ ├── tests/
84
+ │ └── test_core.py # тесты ядра, без сети и без Telegram
85
+ └── examples/
86
+ ├── simulate_flow.py # прогон через настоящий aiogram Dispatcher, без сети
87
+ └── language_school_bot.py # реальный бот — запускается с настоящим токеном
88
+ ```
89
+
90
+ Почему `core.py` отдельно от `aiogram_adapter.py`: это приём, который
91
+ делает библиотеку тестируемой. Ядро ничего не знает про Telegram — значит,
92
+ его логику (переходы, "назад", ошибки) можно проверить обычным `pytest` за
93
+ доли секунды, без сети и без живого бота. Адаптер — тонкий слой, который
94
+ только рендерит клавиатуры и дёргает ядро.
95
+
96
+ ## Установка
97
+
98
+ ```bash
99
+ cd tg_tree_wizard
100
+ pip install -e . # editable-режим: правите код — сразу видно в проекте
101
+ ```
102
+
103
+ `-e` (editable) значит, что пакет ставится "по ссылке" на исходники, а не
104
+ копируется — удобно, пока сами дорабатываете библиотеку.
105
+
106
+ ## Тесты ядра (без Telegram)
107
+
108
+ ```bash
109
+ pip install pytest
110
+ pytest tests/ -v
111
+ ```
112
+
113
+ ## Прогон через настоящий aiogram, но без сети
114
+
115
+ ```bash
116
+ python examples/simulate_flow.py
117
+ ```
118
+
119
+ Использует настоящий `Dispatcher` и `MemoryStorage`, подменяет только
120
+ реальный HTTP-запрос к `api.telegram.org` — так что проверяется всё,
121
+ кроме собственно доставки сообщений.
122
+
123
+ ## Запуск с реальным ботом
124
+
125
+ ```bash
126
+ export BOT_TOKEN=ваш_настоящий_токен
127
+ python examples/language_school_bot.py
128
+ ```
129
+
130
+ Дальше пишите `/survey` в бота и проходите дерево — это уже 100% реальная
131
+ проверка через живой Telegram.
132
+
133
+ ## Как описать своё дерево
134
+
135
+ **Короткий способ (рекомендуется для большинства случаев)** — `linear_wizard`.
136
+ Подходит, когда вопросы идут по порядку и лишь изредка нужно свернуть в
137
+ сторону:
138
+
139
+ ```python
140
+ from tg_tree_wizard import linear_wizard, TreeWizard
141
+
142
+ TREE, ROOT = linear_wizard([
143
+ ("lang", "Выберите язык:", ["Английский", "Немецкий"]), # label == value
144
+ ("delivery", "Формат:", [("Очно", "offline"), ("Онлайн", "online")]),
145
+ ("topping", "Начинка:", [
146
+ ("Пепперони", "pepperoni", "spicy"), # явный переход = ветвление
147
+ ("Маргарита", "margherita"), # без 3-го элемента = следующий шаг по порядку
148
+ ]),
149
+ ("spicy", "Поострее?", ["Да", "Нет"]),
150
+ ])
151
+
152
+ wizard = TreeWizard(TREE, root=ROOT, on_finish=my_finish_callback)
153
+ dp.include_router(wizard.router)
154
+ ```
155
+
156
+ Правила для варианта ответа:
157
+ - `"Текст"` — метка и значение совпадают, переход на следующий шаг по списку;
158
+ - `("Текст", "значение")` — то же самое, но значение отдельно от текста кнопки;
159
+ - `("Текст", "значение", "id_узла")` — явный переход, для ветвления или
160
+ для перехода не на следующий, а на произвольный узел (в т.ч. в обход
161
+ промежуточных шагов).
162
+
163
+ Последний шаг в списке — финальный по умолчанию (после него опрос
164
+ завершается), если явно не переопределить переход у его вариантов.
165
+
166
+ Полный способ — `Node`/`Option` напрямую. Нужен, когда переходов в
167
+ дерево больше, чем шагов, и связь "шаг → следующий по умолчанию" не
168
+ работает (сложные ветвящиеся графы, несколько независимых корней и т.п.):
169
+
170
+ ```python
171
+ from tg_tree_wizard import Node, Option, TreeWizard
172
+
173
+ TREE = {
174
+ "start": Node(
175
+ text="Первый вопрос:",
176
+ options=(
177
+ Option("Вариант A", "a", "next_node_id"),
178
+ Option("Вариант B", "b", None), # None = опрос завершается
179
+ ),
180
+ ),
181
+ "next_node_id": Node(...),
182
+ }
183
+
184
+ wizard = TreeWizard(TREE, root="start", on_finish=my_finish_callback)
185
+ ```
186
+
187
+ `TreeWizard(...)` сам вызывает `validate_tree` при создании — если где-то
188
+ опечатались в id узла, бот упадёт при старте с понятной ошибкой.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/tg_tree_wizard/__init__.py
5
+ src/tg_tree_wizard/aiogram_adapter.py
6
+ src/tg_tree_wizard/core.py
7
+ src/tg_tree_wizard/py.typed
8
+ src/tg_tree_wizard.egg-info/PKG-INFO
9
+ src/tg_tree_wizard.egg-info/SOURCES.txt
10
+ src/tg_tree_wizard.egg-info/dependency_links.txt
11
+ src/tg_tree_wizard.egg-info/requires.txt
12
+ src/tg_tree_wizard.egg-info/top_level.txt
13
+ tests/test_adapter.py
14
+ tests/test_core.py
@@ -0,0 +1,4 @@
1
+ aiogram>=3.0
2
+
3
+ [dev]
4
+ pytest>=8.0
@@ -0,0 +1 @@
1
+ tg_tree_wizard
@@ -0,0 +1,72 @@
1
+ import sys
2
+ import os
3
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
4
+
5
+ import pytest
6
+ from tg_tree_wizard.core import Node, Option, TreeError
7
+ from tg_tree_wizard.aiogram_adapter import (
8
+ TreeWizard,
9
+ check_callback_data_limits,
10
+ TELEGRAM_CALLBACK_DATA_LIMIT_BYTES,
11
+ )
12
+
13
+
14
+ def make_node(n_options: int = 2) -> Node:
15
+ return Node(
16
+ text="x",
17
+ options=tuple(Option(f"opt{i}", f"v{i}", None) for i in range(n_options)),
18
+ )
19
+
20
+
21
+ def test_normal_tree_passes_callback_limit_check():
22
+ tree = {"lang": make_node(), "delivery": make_node()}
23
+ check_callback_data_limits(tree, callback_prefix="wz") # не должно кинуть
24
+
25
+
26
+ def test_long_node_id_exceeds_limit_and_is_caught():
27
+ long_id = "a" * 60 # заведомо длинный id узла
28
+ tree = {long_id: make_node()}
29
+ with pytest.raises(TreeError):
30
+ check_callback_data_limits(tree, callback_prefix="wz")
31
+
32
+
33
+ def test_colon_in_node_id_is_rejected():
34
+ tree = {"bad:id": make_node()}
35
+ with pytest.raises(TreeError):
36
+ check_callback_data_limits(tree, callback_prefix="wz")
37
+
38
+
39
+ def test_treewizard_raises_at_construction_not_at_runtime():
40
+ """
41
+ Ключевое свойство: ошибка ловится при СОЗДАНИИ TreeWizard (то есть при
42
+ старте бота), а не когда до этого узла случайно дойдёт живой пользователь.
43
+ """
44
+ long_id = "b" * 60
45
+ tree = {long_id: make_node()}
46
+ with pytest.raises(TreeError):
47
+ TreeWizard(tree, root=long_id)
48
+
49
+
50
+ def test_realistic_short_ids_stay_well_under_limit():
51
+ """
52
+ Проверка на здравый смысл: обычные короткие id узлов (как в примерах
53
+ из README) даже близко не подходят к лимиту, независимо от того,
54
+ сколько шагов в дереве — потому что путь не кодируется в callback_data.
55
+ """
56
+ tree = {
57
+ "lang": make_node(7), # 7 вариантов языков, как в исходном боте
58
+ "delivery": make_node(2),
59
+ "goal": make_node(7),
60
+ "group": make_node(2),
61
+ "age": make_node(4),
62
+ "level": make_node(7),
63
+ }
64
+ check_callback_data_limits(tree, callback_prefix="wz")
65
+
66
+ max_len = 0
67
+ for node_id, node in tree.items():
68
+ candidate = f"wz:{node_id}:{len(node.options) - 1}"
69
+ max_len = max(max_len, len(candidate.encode("utf-8")))
70
+
71
+ assert max_len < TELEGRAM_CALLBACK_DATA_LIMIT_BYTES
72
+ print(f"Максимальная длина callback_data в этом дереве: {max_len} байт из {TELEGRAM_CALLBACK_DATA_LIMIT_BYTES}")
@@ -0,0 +1,147 @@
1
+ import sys
2
+ import os
3
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
4
+
5
+ import pytest
6
+ from tg_tree_wizard.core import (
7
+ linear_wizard,
8
+ Node, Option, WizardState, TreeError, StaleChoiceError,
9
+ validate_tree, choose, go_back,
10
+ )
11
+
12
+
13
+ # Небольшое тестовое дерево: язык -> формат -> финал
14
+ TREE = {
15
+ "lang": Node(
16
+ text="Выберите язык:",
17
+ options=(
18
+ Option("Английский", "en", "delivery"),
19
+ Option("Немецкий", "de", "delivery"),
20
+ ),
21
+ ),
22
+ "delivery": Node(
23
+ text="Выберите формат:",
24
+ options=(
25
+ Option("Очно", "offline", None),
26
+ Option("Онлайн", "online", None),
27
+ ),
28
+ ),
29
+ }
30
+
31
+
32
+ def test_valid_tree_passes_validation():
33
+ validate_tree(TREE, root="lang") # не должно кинуть исключение
34
+
35
+
36
+ def test_tree_with_missing_root_fails():
37
+ with pytest.raises(TreeError):
38
+ validate_tree(TREE, root="does_not_exist")
39
+
40
+
41
+ def test_tree_with_broken_reference_fails():
42
+ broken = {
43
+ "lang": Node(text="x", options=(Option("a", "a", "nowhere"),)),
44
+ }
45
+ with pytest.raises(TreeError):
46
+ validate_tree(broken, root="lang")
47
+
48
+
49
+ def test_happy_path_reaches_final_and_records_answers():
50
+ state = WizardState.start("lang")
51
+ assert state.current_node == "lang"
52
+
53
+ state, opt, is_final = choose(TREE, state, "lang", 0) # выбрали "Английский"
54
+ assert opt.value == "en"
55
+ assert is_final is False
56
+ assert state.current_node == "delivery"
57
+
58
+ state, opt, is_final = choose(TREE, state, "delivery", 1) # "Онлайн"
59
+ assert opt.value == "online"
60
+ assert is_final is True
61
+
62
+ assert [a.value for a in state.answers] == ["en", "online"]
63
+
64
+
65
+ def test_back_restores_previous_node_and_drops_last_answer():
66
+ state = WizardState.start("lang")
67
+ state, _, _ = choose(TREE, state, "lang", 0)
68
+ assert state.current_node == "delivery"
69
+
70
+ state = go_back(state)
71
+ assert state.current_node == "lang"
72
+ assert state.answers == () # ответ про язык тоже откатился
73
+
74
+
75
+ def test_back_at_root_is_noop():
76
+ state = WizardState.start("lang")
77
+ state2 = go_back(state)
78
+ assert state2 == state
79
+
80
+
81
+ def test_stale_choice_from_old_message_is_rejected():
82
+ """
83
+ Пользователь дошёл до 'delivery', но нажал кнопку со старого
84
+ сообщения 'lang' (например, открыл старый чат). Это должно
85
+ падать с понятной ошибкой, а не молча ломать состояние.
86
+ """
87
+ state = WizardState.start("lang")
88
+ state, _, _ = choose(TREE, state, "lang", 0) # теперь мы на delivery
89
+
90
+ with pytest.raises(StaleChoiceError):
91
+ choose(TREE, state, "lang", 1) # а жмём на устаревшую кнопку lang
92
+
93
+
94
+ def test_state_roundtrips_through_dict_like_fsmcontext_would_store_it():
95
+ """Ровно то, что будет происходить в FSMContext.data на реальном боте."""
96
+ state = WizardState.start("lang")
97
+ state, _, _ = choose(TREE, state, "lang", 0)
98
+
99
+ as_dict = state.to_dict()
100
+ restored = WizardState.from_dict(as_dict)
101
+
102
+ assert restored == state
103
+
104
+
105
+ def test_linear_wizard_plain_strings_chain_to_next_step():
106
+ tree, root = linear_wizard([
107
+ ("a", "Шаг A", ["X", "Y"]),
108
+ ("b", "Шаг B", ["Z"]),
109
+ ])
110
+ assert root == "a"
111
+ validate_tree(tree, root)
112
+
113
+ state = WizardState.start(root)
114
+ state, opt, is_final = choose(tree, state, "a", 0)
115
+ assert opt.label == opt.value == "X"
116
+ assert is_final is False
117
+ assert state.current_node == "b"
118
+
119
+ state, opt, is_final = choose(tree, state, "b", 0)
120
+ assert is_final is True # последний шаг - финальный по умолчанию
121
+
122
+
123
+ def test_linear_wizard_explicit_next_node_enables_branching():
124
+ tree, root = linear_wizard([
125
+ ("topping", "Начинка", [
126
+ ("Пепперони", "pepperoni", "spicy"),
127
+ ("Маргарита", "margherita", "confirm"),
128
+ ]),
129
+ ("spicy", "Острота", ["Да", "Нет"]),
130
+ ("confirm", "Финал", ["OK"]),
131
+ ])
132
+ validate_tree(tree, root)
133
+
134
+ state = WizardState.start(root)
135
+ state, _, is_final = choose(tree, state, "topping", 0)
136
+ assert state.current_node == "spicy"
137
+ assert is_final is False
138
+
139
+ state2 = WizardState.start(root)
140
+ state2, _, is_final2 = choose(tree, state2, "topping", 1)
141
+ assert state2.current_node == "confirm"
142
+ assert is_final2 is False
143
+
144
+
145
+ def test_linear_wizard_rejects_empty_steps():
146
+ with pytest.raises(TreeError):
147
+ linear_wizard([])