feat: confirm training reset and polish bot messages
This commit is contained in:
1 parent
92771c9d9f
commit
1dffdcf32d
7 files changed
+236
-59
No files matched your search
@@ -66,7 +66,7 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
| Настроить стиль | В режиме «Обучение» присылает скриншоты постов | Бот распознаёт тексты, накапливает их и обновляет ToV |
|
||||
| Голосом | В режиме «Генерация» отправляет голосовое | STT расшифровывает → обычный текстовый запрос |
|
||||
| Узнать статус / продлить | Входит в режим «Подписка» и нажимает «Продлить подписку» | Текущий тип периода, остаток дней и Stars-инвойс на 100 звёзд |
|
||||
| Очистить данные | В режиме «Обучение» вызывает `/clear` | Очищает стиль и историю, не меняя подписку или триал |
|
||||
| Очистить данные | В режиме «Обучение» нажимает «Сбросить данные» или вводит `/clear`, затем подтверждает | Очищает стиль и историю, не меняя подписку или триал |
|
||||
|
||||
### Индикаторы и обратная связь
|
||||
- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды.
|
||||
@@ -132,7 +132,7 @@ update_tone_from_image → _handle_image_message
|
||||
| `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) |
|
||||
| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет |
|
||||
| `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления |
|
||||
| `clear_command` | `/clear` — сброс пользовательских данных без изменения подписки и триала; только в обучении |
|
||||
| `clear_command` | `/clear` и кнопка «Сбросить данные» показывают inline-подтверждение; сброс только по confirm в режиме обучения |
|
||||
| `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text |
|
||||
| `help_navigation` | Callback `help:<section>` → `edit_message_text(rich_message=...)` — переключение разделов |
|
||||
| `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел |
|
||||
|
||||
@@ -30,7 +30,7 @@ import user_store
|
||||
|
||||
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
|
||||
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
|
||||
from keyboards import MAIN_MENU_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
|
||||
from keyboards import MAIN_MENU_VALUE, TRAINING_RESET_VALUE, main_menu_keyboard, mode_keyboard, resolve_button_text
|
||||
from payments import send_subscription_invoice
|
||||
from user_store import (
|
||||
add_post_samples,
|
||||
@@ -92,10 +92,20 @@ MODE_COMMANDS = {
|
||||
}
|
||||
MODE_COPY = {
|
||||
"lobby": ("Главное меню", "Выбери режим работы в меню ниже."),
|
||||
"training": ("Обучение", "Пришли скриншоты своих постов, чтобы настроить стиль."),
|
||||
"generation": ("Генерация", "Опиши тему поста, попроси идею или ответь на мой вариант с правкой."),
|
||||
"subscription": ("Подписка", "Ниже показаны текущий статус и остаток дней; кнопка откроет продление."),
|
||||
"help": ("Помощь", "Выбери нужный раздел справки в сообщении выше."),
|
||||
"training": (
|
||||
"Обучение",
|
||||
"Пришли скриншоты своих постов, чтобы настроить стиль.\n"
|
||||
"Кнопка «Сбросить данные» очистит обучение после подтверждения.",
|
||||
),
|
||||
"generation": (
|
||||
"Генерация",
|
||||
"Напиши запрос или отправь голосовое.\nДля правки ответь на мой вариант.",
|
||||
),
|
||||
"subscription": (
|
||||
"Подписка",
|
||||
"Проверь статус ниже.\nКнопка откроет оплату или продление.",
|
||||
),
|
||||
"help": ("Помощь", "Выбери раздел в сообщении выше."),
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +124,7 @@ async def enter_mode(message: Message, state: FSMContext, mode: str) -> None:
|
||||
if mode == "help":
|
||||
await send_rich_message(message, help_view(None), "помощи")
|
||||
await message.answer(
|
||||
f"Режим «{title}». {description}",
|
||||
f"Режим «{title}».\n{description}",
|
||||
reply_markup=mode_keyboard(mode),
|
||||
)
|
||||
if mode == "subscription" and message.from_user is not None:
|
||||
@@ -174,7 +184,11 @@ async def report_error(message: Message, stage: str, exc: Exception) -> None:
|
||||
operation_id = current_operation_id() or "-"
|
||||
user_id = message.from_user.id if message.from_user else "-"
|
||||
try:
|
||||
await message.answer(f"❌ Не получилось: {stage}. Попробуй ещё раз позже.")
|
||||
await message.answer(
|
||||
"❌ <b>Не удалось выполнить действие</b>\n\n"
|
||||
f"{html.escape(stage.capitalize())}. Попробуй ещё раз позже.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
if is_development_environment():
|
||||
await message.answer(
|
||||
f"🧪 Dev-диагностика: {stage}. ID: <code>{operation_id}</code>. "
|
||||
@@ -339,7 +353,7 @@ async def _show_subscription_info(
|
||||
)
|
||||
|
||||
if mode == "subscription":
|
||||
text += f"\nПродление на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐."
|
||||
text += f"\n\nПродление на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐."
|
||||
markup = subscription_renew_keyboard()
|
||||
else:
|
||||
markup = mode_keyboard(mode)
|
||||
@@ -433,13 +447,15 @@ async def style_command(message: Message, state: FSMContext) -> None:
|
||||
samples = await asyncio.to_thread(get_post_samples, message.from_user.id)
|
||||
if not tone and not samples:
|
||||
await message.answer(
|
||||
"Пока не настроен. Пришли скриншоты своих опубликованных постов — "
|
||||
"я распознаю текст, сохраню образцы и построю правила твоего тона.",
|
||||
"🎓 <b>Стиль ещё не настроен</b>\n\n"
|
||||
"Пришли скриншоты опубликованных постов.\n"
|
||||
"Я сохраню примеры и составлю правила Tone of Voice.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=mode_keyboard("training"),
|
||||
)
|
||||
return
|
||||
await message.answer(
|
||||
f"<b>Твой стиль</b>\n\n{tone or 'Правила пока не сформированы.'}",
|
||||
f"🎨 <b>Твой стиль</b>\n\n{html.escape(tone or 'Правила пока не сформированы.')}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=mode_keyboard("training"),
|
||||
)
|
||||
@@ -447,18 +463,63 @@ async def style_command(message: Message, state: FSMContext) -> None:
|
||||
await message.answer(f"Образцов сохранено: {len(samples)}.")
|
||||
|
||||
|
||||
def training_reset_confirmation_keyboard(user_id: int) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[[
|
||||
InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"training:reset:confirm:{user_id}"),
|
||||
InlineKeyboardButton(text="↩️ Отмена", callback_data=f"training:reset:cancel:{user_id}"),
|
||||
]]
|
||||
)
|
||||
|
||||
|
||||
async def clear_command(message: Message, state: FSMContext) -> None:
|
||||
if not await require_mode(message, state, "training"):
|
||||
return
|
||||
if message.from_user is None:
|
||||
await message.answer("Не удалось определить пользователя.")
|
||||
return
|
||||
await asyncio.to_thread(clear_user_data, message.from_user.id)
|
||||
await message.answer(
|
||||
"Данные обучения и история очищены: стиль, примеры постов, история публикаций и идей. "
|
||||
"Подписка и триал не изменены. Служебные API-логи сохраняются. "
|
||||
"⚠️ <b>Сбросить данные обучения?</b>\n\n"
|
||||
"Будут удалены стиль, примеры постов, история публикаций и идей, а также настройки.\n"
|
||||
"Подписка, триал и служебные API-логи останутся.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=training_reset_confirmation_keyboard(message.from_user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("training:reset:"))
|
||||
async def confirm_training_reset(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
parts = (callback.data or "").split(":")
|
||||
if len(parts) != 4 or parts[2] not in {"confirm", "cancel"}:
|
||||
await callback.answer("Некорректное действие.", show_alert=True)
|
||||
return
|
||||
try:
|
||||
owner_id = int(parts[3])
|
||||
except ValueError:
|
||||
await callback.answer("Некорректное подтверждение.", show_alert=True)
|
||||
return
|
||||
if callback.from_user is None or callback.from_user.id != owner_id:
|
||||
await callback.answer("Эта кнопка предназначена другому пользователю.", show_alert=True)
|
||||
return
|
||||
if await current_mode(state) != "training":
|
||||
await callback.answer("Сброс доступен только в режиме «Обучение».", show_alert=True)
|
||||
return
|
||||
message = callback.message
|
||||
if not isinstance(message, Message):
|
||||
await callback.answer("Не удалось обновить сообщение.", show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
if parts[2] == "cancel":
|
||||
await message.edit_text("Сброс отменён. Данные не изменены.", reply_markup=None)
|
||||
return
|
||||
await asyncio.to_thread(clear_user_data, owner_id)
|
||||
await message.edit_text(
|
||||
"✅ <b>Данные обучения сброшены.</b>\n\n"
|
||||
"Стиль, примеры постов и история удалены.\n"
|
||||
"Подписка, триал и API-логи сохранены.\n\n"
|
||||
"Пришли новые скриншоты, чтобы настроить стиль заново.",
|
||||
reply_markup=mode_keyboard("training"),
|
||||
parse_mode="HTML",
|
||||
reply_markup=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -485,7 +546,12 @@ async def _check_subscription(message: Message) -> bool:
|
||||
|
||||
|
||||
async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None:
|
||||
await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}")
|
||||
await message.answer(
|
||||
f"💡 <b>{html.escape(idea['title'])}</b>\n\n"
|
||||
f"<b>Угол:</b> {html.escape(idea['angle'])}\n\n"
|
||||
f"<b>Зачем:</b> {html.escape(idea['why'])}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await asyncio.to_thread(save_idea, user_id, idea)
|
||||
|
||||
|
||||
@@ -636,9 +702,17 @@ async def _process_image_group(messages: list[Message], bot: Bot) -> None:
|
||||
all_samples = await asyncio.to_thread(add_post_samples, first.from_user.id, new_samples)
|
||||
tone = await create_tone_profile(all_samples, previous_tone, user_id=first.from_user.id)
|
||||
await asyncio.to_thread(save_tone_of_voice, first.from_user.id, tone)
|
||||
await first.answer(
|
||||
f"Обновила только правила стиля по накопленным примерам ({len(all_samples)} постов).\n\n{tone}\n\n"
|
||||
"Распознанные тексты сохранены для следующих обновлений и не переписывались."
|
||||
rich_html = (
|
||||
"<h2>✅ Стиль обновлён</h2>"
|
||||
f"<p>Образцов учтено: {len(all_samples)}.</p>"
|
||||
f"<p>{html.escape(tone)}</p>"
|
||||
"<p>Скриншоты сохранены как образцы; тексты постов не переписывались.</p>"
|
||||
)
|
||||
await send_rich_message(
|
||||
first,
|
||||
rich_html,
|
||||
"обновления стиля",
|
||||
reply_markup=mode_keyboard("training"),
|
||||
)
|
||||
|
||||
|
||||
@@ -782,6 +856,9 @@ async def handle_text(message: Message, state: FSMContext) -> None:
|
||||
if button == MAIN_MENU_VALUE:
|
||||
await show_main_menu(message, state)
|
||||
return
|
||||
if button == TRAINING_RESET_VALUE:
|
||||
await clear_command(message, state)
|
||||
return
|
||||
if button and button.startswith("/"):
|
||||
await handle_command_text(message, state)
|
||||
return
|
||||
@@ -832,7 +909,7 @@ async def set_main_menu(bot: Bot) -> None:
|
||||
BotCommand(command="help", description="Режим: помощь"),
|
||||
BotCommand(command="post", description="Написать пост (генерация)"),
|
||||
BotCommand(command="style", description="Мой стиль (обучение)"),
|
||||
BotCommand(command="clear", description="Сбросить данные (обучение)"),
|
||||
BotCommand(command="clear", description="Сбросить данные (обучение, с подтверждением)"),
|
||||
BotCommand(command="idea", description="Идея (генерация)"),
|
||||
BotCommand(command="balance", description="Статус подписки"),
|
||||
BotCommand(command="subscribe", description="Оплатить подписку"),
|
||||
|
||||
+3
-2
@@ -28,7 +28,8 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
|
||||
),
|
||||
"tone": (
|
||||
"🎨 Tone of Voice",
|
||||
"""<p>Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст постов и сохраню его как образцы, дополню предыдущие примеры и обновлю только правила Tone of Voice. Сами публикации не переписываю.</p>""",
|
||||
"""<p>Пришли скриншот или галерею скриншотов своих опубликованных постов. Я распознаю текст, сохраню его как образцы и обновлю только правила Tone of Voice. Сами публикации не переписываю.</p>
|
||||
<p>Чтобы удалить стиль и историю обучения, нажми «Сбросить данные» и подтверди действие.</p>""",
|
||||
),
|
||||
"edit": (
|
||||
"✏️ Правка поста",
|
||||
@@ -51,7 +52,7 @@ HELP_SECTIONS: dict[str, tuple[str, str]] = {
|
||||
"commands": (
|
||||
"⌨️ Команды",
|
||||
"""<p>Команды-переключатели доступны в любом режиме: <code>/start</code> — главное меню; <code>/training</code> — обучение; <code>/generate</code> — генерация; <code>/subscription</code> — подписка; <code>/help</code> — помощь.</p>
|
||||
<p>Действия доступны только в своём режиме: <code>/style</code> и <code>/clear</code> — обучение; <code>/post</code> и <code>/idea</code> — генерация; <code>/balance</code> и <code>/subscribe</code> — подписка. <code>/clear</code> очищает пользовательские данные, но не меняет подписку и триал.</p>
|
||||
<p>Действия доступны только в своём режиме: <code>/style</code> и <code>/clear</code> — обучение; <code>/post</code> и <code>/idea</code> — генерация; <code>/balance</code> и <code>/subscribe</code> — подписка. <code>/clear</code> и кнопка «Сбросить данные» сначала запрашивают подтверждение; подписка и триал не меняются.</p>
|
||||
<p>В режиме «Генерация» можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>""",
|
||||
),
|
||||
}
|
||||
|
||||
+14
-4
@@ -16,11 +16,14 @@ MODE_BUTTONS: dict[str, str] = {
|
||||
}
|
||||
MAIN_MENU_BUTTON = "🏠 Главное меню"
|
||||
MAIN_MENU_VALUE = "__main_menu__"
|
||||
TRAINING_RESET_BUTTON = "🗑 Сбросить данные"
|
||||
TRAINING_RESET_VALUE = "__training_reset__"
|
||||
|
||||
BUTTON_TEXT_MAP: dict[str, str] = {
|
||||
**MODE_BUTTONS,
|
||||
MAIN_MENU_BUTTON: MAIN_MENU_VALUE,
|
||||
"Главное меню": MAIN_MENU_VALUE,
|
||||
TRAINING_RESET_BUTTON: TRAINING_RESET_VALUE,
|
||||
}
|
||||
|
||||
|
||||
@@ -36,12 +39,19 @@ def main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
)
|
||||
|
||||
|
||||
def mode_keyboard(_mode: str) -> ReplyKeyboardMarkup:
|
||||
"""Внутри любого режима оставляет только выход в главное меню."""
|
||||
def mode_keyboard(mode: str) -> ReplyKeyboardMarkup:
|
||||
"""Внутри режима показывает релевантные действия и возврат в меню."""
|
||||
rows = [[KeyboardButton(text=MAIN_MENU_BUTTON)]]
|
||||
if mode == "training":
|
||||
rows.append([KeyboardButton(text=TRAINING_RESET_BUTTON)])
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[[KeyboardButton(text=MAIN_MENU_BUTTON)]],
|
||||
keyboard=rows,
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Напиши сообщение или вернись в главное меню",
|
||||
input_field_placeholder=(
|
||||
"Управляй обучением или вернись в меню"
|
||||
if mode == "training"
|
||||
else "Напиши сообщение или вернись в главное меню"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+17
-3
@@ -51,10 +51,24 @@ class KeyboardLayoutTests(unittest.TestCase):
|
||||
self.assertEqual(labels, set(keyboards.MODE_BUTTONS))
|
||||
self.assertNotIn("🏠 Лобби", labels)
|
||||
|
||||
def test_each_mode_keyboard_has_only_main_menu_button(self):
|
||||
def test_each_mode_keyboard_shows_expected_actions(self):
|
||||
for mode in bot.MODE_STATES:
|
||||
labels = [b.text for row in keyboards.mode_keyboard(mode).keyboard for b in row]
|
||||
self.assertEqual(labels, [keyboards.MAIN_MENU_BUTTON], mode)
|
||||
expected = [keyboards.MAIN_MENU_BUTTON]
|
||||
if mode == "training":
|
||||
expected.append(keyboards.TRAINING_RESET_BUTTON)
|
||||
self.assertEqual(labels, expected, mode)
|
||||
|
||||
def test_mode_entry_copy_uses_short_paragraphs(self):
|
||||
self.assertIn("\n", bot.MODE_COPY["training"][1])
|
||||
self.assertIn("\n", bot.MODE_COPY["generation"][1])
|
||||
self.assertIn("\n", bot.MODE_COPY["subscription"][1])
|
||||
|
||||
def test_training_reset_button_routes_to_confirmation_action(self):
|
||||
self.assertEqual(
|
||||
keyboards.resolve_button_text(keyboards.TRAINING_RESET_BUTTON),
|
||||
keyboards.TRAINING_RESET_VALUE,
|
||||
)
|
||||
|
||||
def test_every_button_is_resolvable(self):
|
||||
markups = [keyboards.main_menu_keyboard()]
|
||||
@@ -165,7 +179,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(await state.get_state(), bot.BotMode.subscription.state)
|
||||
self.assertEqual(len(message.answers), 2)
|
||||
mode_keyboard = message.answers[0][1]["reply_markup"]
|
||||
self.assertEqual(mode_keyboard.keyboard[0][0].text, keyboards.MAIN_MENU_BUTTON)
|
||||
self.assertEqual(message.answers[0][1]["reply_markup"].keyboard[0][0].text, keyboards.MAIN_MENU_BUTTON)
|
||||
self.assertIn("Подписка не активирована", message.answers[1][0])
|
||||
renew_keyboard = message.answers[1][1]["reply_markup"]
|
||||
self.assertEqual(
|
||||
|
||||
+102
-27
@@ -386,6 +386,7 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(bot, "add_post_samples", return_value=["Старый пост", "Новый пост"]) as add_samples,
|
||||
patch.object(bot, "create_tone_profile", new=AsyncMock(return_value="Обновлённый стиль")) as create_profile,
|
||||
patch.object(bot, "save_tone_of_voice") as save_tone,
|
||||
patch.object(bot, "send_rich_message", new=AsyncMock()) as send_rich,
|
||||
patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread),
|
||||
):
|
||||
await bot._process_image_group([message], AsyncMock())
|
||||
@@ -395,8 +396,9 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase):
|
||||
["Старый пост", "Новый пост"], "Старый профиль", user_id=7
|
||||
)
|
||||
save_tone.assert_called_once_with(7, "Обновлённый стиль")
|
||||
self.assertIn("только правила стиля", message.answer.await_args.args[0])
|
||||
self.assertIn("не переписывались", message.answer.await_args.args[0])
|
||||
self.assertIn("Стиль обновлён", str(send_rich.await_args))
|
||||
self.assertIn("не переписывались", str(send_rich.await_args))
|
||||
self.assertIn("обновления стиля", str(send_rich.await_args))
|
||||
|
||||
|
||||
async def test_clear_command_resets_only_calling_user_profile_and_samples(self):
|
||||
@@ -424,11 +426,15 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread),
|
||||
):
|
||||
await bot.clear_command(message, _StubState("training"))
|
||||
clear_data.assert_called_once_with(25)
|
||||
msg_calls = [c.args[0] for c in message.answer.call_args_list]
|
||||
self.assertTrue(any("API-логи" in m for m in msg_calls))
|
||||
self.assertTrue(any("скриншоты" in m for m in msg_calls))
|
||||
self.assertTrue(any("Подписка и триал не изменены" in m for m in msg_calls))
|
||||
self.assertEqual(message.answer.await_count, 1)
|
||||
warning, kwargs = message.answer.await_args.args[0], message.answer.await_args.kwargs
|
||||
self.assertIn("Сбросить данные обучения?", warning)
|
||||
self.assertIn("Подписка, триал", warning)
|
||||
buttons = kwargs["reply_markup"].inline_keyboard[0]
|
||||
self.assertEqual([button.text for button in buttons], ["✅ Подтвердить", "↩️ Отмена"])
|
||||
self.assertEqual(buttons[0].callback_data, "training:reset:confirm:25")
|
||||
self.assertEqual(buttons[1].callback_data, "training:reset:cancel:25")
|
||||
clear_data.assert_not_called()
|
||||
|
||||
|
||||
class StorageTests(unittest.TestCase):
|
||||
@@ -698,34 +704,103 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_clear_preserves_subscription(self):
|
||||
user_store.ensure_subscription(105, self.db_path)
|
||||
user_store.activate_subscription(105, 1, self.db_path)
|
||||
message = SimpleNamespace(
|
||||
from_user=SimpleNamespace(id=105),
|
||||
user_store.save_tone_of_voice(105, "test style", self.db_path)
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from aiogram.types import Chat, Message
|
||||
|
||||
message_bot = AsyncMock()
|
||||
message = Message(message_id=10, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot)
|
||||
state = _StubState("training")
|
||||
async def run_in_thread(function, *args, **kwargs):
|
||||
return function(*args, **kwargs)
|
||||
|
||||
with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread):
|
||||
await bot.clear_command(message, cast(Any, state))
|
||||
callback = SimpleNamespace(
|
||||
data="training:reset:confirm:105",
|
||||
from_user=SimpleNamespace(id=105),
|
||||
message=message,
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
await bot.confirm_training_reset(callback, state)
|
||||
|
||||
self.assertEqual(user_store.get_tone_of_voice(105, self.db_path), "")
|
||||
sub = user_store.subscription_row(105, self.db_path)
|
||||
self.assertEqual(sub["is_active"], 1)
|
||||
|
||||
async def test_training_reset_cancel_does_not_clear_data(self):
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from aiogram.types import Chat, Message
|
||||
|
||||
message_bot = AsyncMock()
|
||||
message = Message(message_id=1, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot)
|
||||
callback = SimpleNamespace(
|
||||
data="training:reset:cancel:107",
|
||||
from_user=SimpleNamespace(id=107),
|
||||
message=message,
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
with patch.object(bot, "clear_user_data") as clear_data:
|
||||
await bot.confirm_training_reset(callback, _StubState("training"))
|
||||
clear_data.assert_not_called()
|
||||
callback.answer.assert_awaited_once_with()
|
||||
message_bot.assert_awaited_once()
|
||||
self.assertIn("Сброс отменён", str(message_bot.await_args))
|
||||
|
||||
async def test_training_reset_confirm_requires_owner_and_training_mode(self):
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from aiogram.types import Chat, Message
|
||||
|
||||
message = Message(message_id=1, date=datetime.now(), chat=Chat(id=555, type="private")).as_(AsyncMock())
|
||||
callback = SimpleNamespace(
|
||||
data="training:reset:confirm:108",
|
||||
from_user=SimpleNamespace(id=999),
|
||||
message=message,
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
with patch.object(bot, "clear_user_data") as clear_data:
|
||||
await bot.confirm_training_reset(callback, _StubState("training"))
|
||||
clear_data.assert_not_called()
|
||||
callback.answer.assert_awaited_once()
|
||||
self.assertIn("другому пользователю", callback.answer.await_args.args[0])
|
||||
|
||||
callback.from_user.id = 108
|
||||
callback.answer.reset_mock()
|
||||
with patch.object(bot, "clear_user_data") as clear_data:
|
||||
await bot.confirm_training_reset(callback, _StubState("generation"))
|
||||
clear_data.assert_not_called()
|
||||
self.assertIn("только в режиме", callback.answer.await_args.args[0])
|
||||
|
||||
async def test_clear_preserves_trial(self):
|
||||
user_store.ensure_subscription(106, self.db_path)
|
||||
user_store.save_tone_of_voice(106, "test style", self.db_path)
|
||||
before = user_store.subscription_row(106, self.db_path)
|
||||
assert before is not None
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from aiogram.types import Chat, Message
|
||||
|
||||
message_bot = AsyncMock()
|
||||
message = Message(message_id=10, date=datetime.now(), chat=Chat(id=555, type="private")).as_(message_bot)
|
||||
state = _StubState("training")
|
||||
|
||||
async def run_in_thread(function, *args, **kwargs):
|
||||
return function(*args, **kwargs)
|
||||
|
||||
with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread):
|
||||
await bot.clear_command(message, _StubState("training"))
|
||||
|
||||
msg_calls = [c.args[0] for c in message.answer.call_args_list]
|
||||
self.assertTrue(any("Подписка и триал не изменены" in msg for msg in msg_calls))
|
||||
sub = user_store.subscription_row(105, self.db_path)
|
||||
self.assertEqual(sub["is_active"], 1)
|
||||
|
||||
async def test_clear_preserves_trial(self):
|
||||
user_store.ensure_subscription(106, self.db_path)
|
||||
before = user_store.subscription_row(106, self.db_path)
|
||||
assert before is not None
|
||||
message = SimpleNamespace(
|
||||
from_user=SimpleNamespace(id=106),
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
|
||||
with patch.object(bot.asyncio, "to_thread", side_effect=lambda f, *a: f(*a)):
|
||||
await bot.clear_command(cast(Any, message), cast(Any, _StubState("training")))
|
||||
await bot.clear_command(cast(Any, message), cast(Any, state))
|
||||
callback = SimpleNamespace(
|
||||
data="training:reset:confirm:106",
|
||||
from_user=SimpleNamespace(id=106),
|
||||
message=message,
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
await bot.confirm_training_reset(callback, cast(Any, state))
|
||||
|
||||
self.assertEqual(user_store.get_tone_of_voice(106, self.db_path), "")
|
||||
after = user_store.subscription_row(106, self.db_path)
|
||||
assert after is not None
|
||||
self.assertEqual(after["is_active"], before["is_active"])
|
||||
|
||||
Reference in new issue
Block a user