diff --git a/src/adapters/discord_bot/task_workspace.py b/src/adapters/discord_bot/task_workspace.py index a7f22b1..e388305 100644 --- a/src/adapters/discord_bot/task_workspace.py +++ b/src/adapters/discord_bot/task_workspace.py @@ -631,16 +631,17 @@ async def handle_action( await _maybe_await(interaction.response.send_modal(modal)) return - if action == "edit": - from src.adapters.discord_bot.views.task_modals import TaskEditModal - - modal = TaskEditModal( - task=task, - task_service=self.task_service, - auth_service=self.auth_service, - ) - await _maybe_await(interaction.response.send_modal(modal)) - return + if action in ("edit", "controls"): + try: + await self.render_task_controls( + interaction=interaction, + task=task, + panel="quick_controls", + ) + return + except Exception as e: + await send_interaction_error(interaction, e, "opening task controls", logger, ephemeral=True) + return if action == "deps": try: @@ -666,18 +667,6 @@ async def handle_action( await send_interaction_error(interaction, e, "opening task dependencies", logger, ephemeral=True) return - if action == "controls": - try: - await self.render_task_controls( - interaction=interaction, - task=task, - panel="quick_controls", - ) - return - except Exception as e: - await send_interaction_error(interaction, e, "opening task controls", logger, ephemeral=True) - return - if action == "claim": try: if task.assignee_discord_id and task.assignee_discord_id != interaction.user.id: @@ -890,6 +879,9 @@ async def save_task_controls( due_at: Any = _UNSET, clear_due_at: bool = False, watchers: list[int] | None = None, + title: str | None = None, + body: str | None = None, + clear_body: bool = False, ) -> Task | None: """Applies staged task control adjustments atomically, syncing thread tags and action card.""" if self.auth_service: @@ -910,6 +902,7 @@ async def save_task_controls( async with unarchive_thread_if_needed(thread, keep_archived=keep_archived): updated_task = task actor_id = getattr(interaction.user, "id", None) + title_changed = False if priority is not None and priority != updated_task.priority: updated_task = await self.task_service.update_priority( @@ -919,6 +912,13 @@ async def save_task_controls( ) details_kwargs: dict[str, Any] = {} + if title is not None and title != updated_task.title: + details_kwargs["title"] = title + title_changed = True + if body is not None and body != updated_task.body: + details_kwargs["body"] = body + if clear_body: + details_kwargs["clear_body"] = True if due_at is not _UNSET and due_at != updated_task.due_at: details_kwargs["due_at"] = due_at if clear_due_at: @@ -946,7 +946,7 @@ async def save_task_controls( await self.sync_workspace( updated_task, - sync_title=False, + sync_title=title_changed, sync_tags=True, sync_archive=False, sync_starter_card=True, diff --git a/src/adapters/discord_bot/views/task_buttons.py b/src/adapters/discord_bot/views/task_buttons.py index 3f14178..2a3ace5 100644 --- a/src/adapters/discord_bot/views/task_buttons.py +++ b/src/adapters/discord_bot/views/task_buttons.py @@ -34,6 +34,8 @@ def build_task_controls_embed( assignee_id: Any = _UNSET, due_at: Any = _UNSET, watchers: list[int] | None = None, + title: str | None = None, + body: Any = _UNSET, ) -> discord.Embed: """Builds a summary embed for the interactive ephemeral task controls.""" prio_map = { @@ -57,26 +59,31 @@ def build_task_controls_embed( actual_watchers = task.watchers if watchers is None else watchers watchers_str = " ".join(f"<@{uid}>" for uid in actual_watchers) if actual_watchers else "*None*" + actual_title = title if title is not None else task.title + actual_body = task.body if body is _UNSET else body + if error_message: color = discord.Color.red() prefix = f"{error_message}\n\n" else: - color = discord.Color.blue() + color = discord.Color.gold() prefix = "" + body_section = f"\n• **Description**: {actual_body[:200]}" if actual_body else "" + embed = discord.Embed( - title=f"Quick Controls: [{task.short_id}] {task.title[:70]}", + title=f"Edit Draft: [{task.short_id}] {actual_title[:100]}", description=( f"{prefix}" - "Adjust fields above, then click 'Save Changes' to apply.\n\n" f"• **Priority**: {prio_str}\n" f"• **Assignee**: {assignee_str}\n" f"• **Due Date**: {due_str}\n" f"• **Watchers**: {watchers_str}" + f"{body_section}" ), color=color, ) - embed.set_footer(text="Adjust fields above, then click 'Save Changes' to apply.") + embed.set_footer(text="⚠️ Unsaved Draft • Click 'Save Changes' to apply or 'Discard Changes' to cancel.") return embed @@ -99,6 +106,8 @@ def __init__( self.workspace = workspace # Staged state + self.staged_title: str = task.title + self.staged_body: str | None = task.body self.staged_priority: PriorityLevel = task.priority self.staged_assignee_id: int | None = task.assignee_discord_id self.staged_due_at: datetime | None = task.due_at @@ -143,6 +152,8 @@ def _build_embed(self) -> discord.Embed: assignee_id=self.staged_assignee_id, due_at=due, watchers=self.staged_watchers, + title=self.staged_title, + body=self.staged_body, ) def _rebuild_items(self) -> None: @@ -157,13 +168,21 @@ def _rebuild_items(self) -> None: save_btn.callback = self._on_save_clicked self.add_item(save_btn) - cancel_btn = discord.ui.Button( - label="Cancel", + discard_btn = discord.ui.Button( + label="Discard Changes", + style=discord.ButtonStyle.danger, + row=0, + ) + discard_btn.callback = self._on_cancel_clicked + self.add_item(discard_btn) + + edit_text_btn = discord.ui.Button( + label="Edit Title / Body", style=discord.ButtonStyle.secondary, row=0, ) - cancel_btn.callback = self._on_cancel_clicked - self.add_item(cancel_btn) + edit_text_btn.callback = self._on_edit_text_clicked + self.add_item(edit_text_btn) if self.staged_assignee_id: unassign_btn = discord.ui.Button( @@ -244,6 +263,27 @@ def _rebuild_items(self) -> None: self.watchers_select.callback = self._on_watchers_selected self.add_item(self.watchers_select) + async def _on_edit_text_clicked(self, interaction: discord.Interaction) -> None: + from src.adapters.discord_bot.views.task_modals import TaskQuickEditTitleModal + + modal = TaskQuickEditTitleModal(self) + await interaction.response.send_modal(modal) + + async def update_text_content( + self, + interaction: discord.Interaction, + *, + title: str, + body: str | None, + ) -> None: + """Update staged title and description and refresh the draft embed.""" + self.staged_title = title + self.staged_body = body + self.error_message = None + self._rebuild_items() + embed = self._build_embed() + await interaction.response.edit_message(embed=embed, view=self) + async def _on_priority_selected(self, interaction: discord.Interaction) -> None: prio_map = { "high": PriorityLevel.HIGH, @@ -315,6 +355,7 @@ async def _on_watchers_selected(self, interaction: discord.Interaction) -> None: async def _on_save_clicked(self, interaction: discord.Interaction) -> None: ws = self.effective_workspace if ws: + clear_body = self.staged_body is None and bool(self.task.body) updated_task = await ws.save_task_controls( interaction, task=self.task, @@ -323,6 +364,9 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None: due_at=self.staged_due_at, clear_due_at=self.staged_clear_due, watchers=self.staged_watchers, + title=self.staged_title, + body=self.staged_body, + clear_body=clear_body, ) if updated_task: self.task = updated_task @@ -351,6 +395,9 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None: keep_archived = self.task.status == TaskStatus.COMPLETED or self.task.is_archived async with unarchive_thread_if_needed(thread, keep_archived=keep_archived): updated_task = self.task + title_changed = self.staged_title != updated_task.title + body_changed = self.staged_body != updated_task.body + if self.staged_priority != updated_task.priority: updated_task = await self.task_service.update_priority( task_id=self.task.id, @@ -359,17 +406,29 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None: ) details_changed = ( - self.staged_due_at != updated_task.due_at + title_changed + or body_changed + or self.staged_due_at != updated_task.due_at or self.staged_clear_due or set(self.staged_watchers) != set(updated_task.watchers) ) if details_changed: + details_kwargs: dict[str, Any] = { + "due_at": self.staged_due_at, + "clear_due_at": self.staged_clear_due, + "watchers": self.staged_watchers, + } + if title_changed: + details_kwargs["title"] = self.staged_title + if body_changed: + details_kwargs["body"] = self.staged_body + if not self.staged_body: + details_kwargs["clear_body"] = True + updated_task = await self.task_service.update_details( task_id=self.task.id, actor_discord_id=interaction.user.id, - due_at=self.staged_due_at, - clear_due_at=self.staged_clear_due, - watchers=self.staged_watchers, + **details_kwargs, ) if self.staged_assignee_id != updated_task.assignee_discord_id: @@ -392,7 +451,7 @@ async def _on_save_clicked(self, interaction: discord.Interaction) -> None: await interaction.response.edit_message(embed=embed, view=None) if self.bot and hasattr(self.bot, "sync_root_task_message"): await self.bot.sync_root_task_message(updated_task) - await self.bot.sync_task_thread(updated_task, sync_archive=False) + await self.bot.sync_task_thread(updated_task, sync_title=title_changed, sync_archive=False) menu_manager.unregister_menu(interaction) menu_manager.schedule_toast_dismissal(interaction, delay=3.0) @@ -500,9 +559,9 @@ def __init__( ) self.add_item(self.note_btn) - # Row 1: Advanced Actions / Tools (Edit Details in first position) + # Row 1: Advanced Actions / Tools (Consolidated Edit Task in first position) self.edit_btn = discord.ui.Button( - label="Edit Details", + label="Edit Task", style=discord.ButtonStyle.secondary, custom_id=f"task:edit:{task_id}", row=1, @@ -517,14 +576,6 @@ def __init__( ) self.add_item(self.deps_btn) - self.controls_btn = discord.ui.Button( - label="Quick Controls", - style=discord.ButtonStyle.secondary, - custom_id=f"task:controls:{task_id}", - row=1, - ) - self.add_item(self.controls_btn) - class TaskLinkButtonView(BaseView): """View containing a 1-click link button to open the task in Discord.""" diff --git a/src/adapters/discord_bot/views/task_modals.py b/src/adapters/discord_bot/views/task_modals.py index bac361f..b1943f9 100644 --- a/src/adapters/discord_bot/views/task_modals.py +++ b/src/adapters/discord_bot/views/task_modals.py @@ -2,7 +2,7 @@ import logging import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from uuid import UUID import discord @@ -228,3 +228,48 @@ async def on_submit(self, interaction: discord.Interaction) -> None: await send_interaction_error( interaction, e, f"updating details for task '{self.short_id}'", logger, ephemeral=True ) + + +class TaskQuickEditTitleModal(BaseModal): + """Modal to edit title and description inside TaskQuickControlsView with staged preview.""" + + def __init__(self, target_view: Any): + super().__init__(title="Edit Task Details") + self.target_view = target_view + + current_title = getattr(target_view, "staged_title", "") + current_body = getattr(target_view, "staged_body", "") or "" + + self.title_input = discord.ui.TextInput( + label="Task Title", + default=current_title, + required=True, + max_length=100, + ) + self.add_item(self.title_input) + + self.desc_input = discord.ui.TextInput( + label="Description / Body (Optional)", + style=discord.TextStyle.paragraph, + default=current_body, + placeholder="Detailed requirements or instructions...", + required=False, + max_length=1500, + ) + self.add_item(self.desc_input) + + async def on_submit(self, interaction: discord.Interaction) -> None: + title = self.title_input.value.strip() + if not title: + await interaction.response.send_message("❌ Task title cannot be empty.", ephemeral=True) + return + + body = self.desc_input.value.strip() or None + if hasattr(self.target_view, "update_text_content"): + await self.target_view.update_text_content(interaction, title=title, body=body) + else: + self.target_view.staged_title = title + self.target_view.staged_body = body + self.target_view._rebuild_items() + embed = self.target_view._build_embed() + await interaction.response.edit_message(embed=embed, view=self.target_view) diff --git a/src/adapters/discord_bot/workspace_protocol.py b/src/adapters/discord_bot/workspace_protocol.py index a48e391..c7e0556 100644 --- a/src/adapters/discord_bot/workspace_protocol.py +++ b/src/adapters/discord_bot/workspace_protocol.py @@ -141,6 +141,9 @@ async def save_task_controls( due_at: Any = _UNSET, clear_due_at: bool = False, watchers: list[int] | None = None, + title: str | None = None, + body: str | None = None, + clear_body: bool = False, ) -> Task | None: """Applies staged task control adjustments atomically, syncing thread tags and action card.""" ... diff --git a/tests/test_cogs.py b/tests/test_cogs.py index 361fd2b..567647e 100644 --- a/tests/test_cogs.py +++ b/tests/test_cogs.py @@ -237,14 +237,14 @@ async def test_task_action_view_and_modals(services): task_service=task_srv, ) - # Check children: action buttons, note, edit, deps, controls (no inline dropdown clutter) + # Check children: action buttons, note, edit, deps (no separate controls button or inline dropdown clutter) custom_ids = [item.custom_id for item in view.children if hasattr(item, "custom_id")] assert f"task:start:{task.id}" in custom_ids assert f"task:complete:{task.id}" in custom_ids assert f"task:note:{task.id}" in custom_ids assert f"task:edit:{task.id}" in custom_ids assert f"task:deps:{task.id}" in custom_ids - assert f"task:controls:{task.id}" in custom_ids + assert f"task:controls:{task.id}" not in custom_ids assert f"task:priority:{task.id}" not in custom_ids assert f"task:assignee:{task.id}" not in custom_ids assert f"task:due:{task.id}" not in custom_ids @@ -833,7 +833,7 @@ async def test_task_quick_controls_view_callbacks(services): from src.adapters.discord_bot.views.task_buttons import TaskQuickControlsView, build_task_controls_embed ctrl_embed = build_task_controls_embed(task) - assert "Quick Controls" in ctrl_embed.title + assert "Edit Draft" in ctrl_embed.title assert "Priority**: Low" in ctrl_embed.description mock_bot = MagicMock() diff --git a/tests/test_task_buttons_unification.py b/tests/test_task_buttons_unification.py new file mode 100644 index 0000000..3a0ffce --- /dev/null +++ b/tests/test_task_buttons_unification.py @@ -0,0 +1,360 @@ +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import discord +import pytest + +from src.adapters.discord_bot.task_workspace import DiscordTaskWorkspaceAdapter +from src.adapters.discord_bot.views.task_buttons import TaskActionView, TaskQuickControlsView +from src.adapters.discord_bot.views.task_modals import TaskQuickEditTitleModal +from src.domain.enums import PriorityLevel, TaskStatus +from src.domain.models import Task + + +def test_task_action_view_has_unified_edit_task_button(): + """Verify TaskActionView consolidates Edit Details and Quick Controls into a single Edit Task button.""" + task_id = uuid4() + view = TaskActionView( + task_id=task_id, + current_status=TaskStatus.NOT_STARTED, + current_priority=PriorityLevel.NORMAL, + ) + + row1_buttons = [item for item in view.children if isinstance(item, discord.ui.Button) and item.row == 1] + button_labels = [b.label for b in row1_buttons] + custom_ids = [getattr(b, "custom_id", "") for b in row1_buttons] + + # Consolidated button should be "Edit Task" + assert "Edit Task" in button_labels + assert "Edit Details" not in button_labels + assert "Quick Controls" not in button_labels + + # Verify custom IDs: edit button and dependencies button present + assert f"task:edit:{task_id}" in custom_ids + assert f"task:deps:{task_id}" in custom_ids + # Separate controls button is no longer in the row 1 layout + assert f"task:controls:{task_id}" not in custom_ids + + +@pytest.mark.asyncio +async def test_workspace_handle_action_edit_and_controls_open_unified_panel(): + """Verify that both 'edit' and 'controls' actions route to the unified quick_controls panel.""" + task_srv = MagicMock() + bot = MagicMock() + workspace = DiscordTaskWorkspaceAdapter(bot=bot, task_service=task_srv) + + task_id = uuid4() + mock_task = MagicMock(spec=Task) + mock_task.id = task_id + mock_task.guild_id = 12345 + task_srv.get_by_id = AsyncMock(return_value=mock_task) + + workspace.render_task_controls = AsyncMock() + + # Test action="edit" + interaction_edit = MagicMock(spec=discord.Interaction) + interaction_edit.guild_id = 12345 + interaction_edit.response.is_done.return_value = False + await workspace.handle_action(interaction_edit, action="edit", task_id=task_id) + + workspace.render_task_controls.assert_awaited_once_with( + interaction=interaction_edit, + task=mock_task, + panel="quick_controls", + ) + + workspace.render_task_controls.reset_mock() + + # Test action="controls" (backward compatibility) + interaction_ctrl = MagicMock(spec=discord.Interaction) + interaction_ctrl.guild_id = 12345 + interaction_ctrl.response.is_done.return_value = False + await workspace.handle_action(interaction_ctrl, action="controls", task_id=task_id) + + workspace.render_task_controls.assert_awaited_once_with( + interaction=interaction_ctrl, + task=mock_task, + panel="quick_controls", + ) + + +@pytest.mark.asyncio +async def test_unified_controls_view_stages_title_and_body_from_modal(): + """Verify TaskQuickControlsView allows staging title & body updates via modal.""" + task_srv = MagicMock() + mock_task = MagicMock(spec=Task) + mock_task.id = uuid4() + mock_task.short_id = "PRJ-101" + mock_task.title = "Original Title" + mock_task.body = "Original Body" + mock_task.priority = PriorityLevel.NORMAL + mock_task.assignee_discord_id = None + mock_task.due_at = None + mock_task.watchers = [] + + view = TaskQuickControlsView(task=mock_task, task_service=task_srv) + + # 1. Check Row 0 contains "Edit Title / Body" button + row0_buttons = [item for item in view.children if isinstance(item, discord.ui.Button) and item.row == 0] + button_labels = [b.label for b in row0_buttons] + assert "Edit Title / Body" in button_labels + + # 2. Check initial staged values + assert view.staged_title == "Original Title" + assert view.staged_body == "Original Body" + + # 3. Simulate modal submit + modal = TaskQuickEditTitleModal(target_view=view) + modal.title_input._value = "Updated Staged Title" + modal.desc_input._value = "Updated Staged Body" + + interaction = MagicMock(spec=discord.Interaction) + interaction.response = MagicMock() + interaction.response.is_done.return_value = False + interaction.response.edit_message = AsyncMock() + + await modal.on_submit(interaction) + + # Staged values should be updated on view + assert view.staged_title == "Updated Staged Title" + assert view.staged_body == "Updated Staged Body" + + # Check edit_message was called with updated embed + interaction.response.edit_message.assert_awaited_once() + _, kwargs = interaction.response.edit_message.call_args + updated_embed = kwargs.get("embed") + assert "Updated Staged Title" in updated_embed.title + assert "Updated Staged Body" in updated_embed.description + + +@pytest.mark.asyncio +async def test_unified_controls_view_saves_all_staged_fields_atomically(): + """Verify that clicking Save Changes passes staged title, body, priority, assignee, due date, watchers.""" + mock_workspace = MagicMock() + mock_workspace.save_task_controls = AsyncMock() + + task_srv = MagicMock() + mock_task = MagicMock(spec=Task) + mock_task.id = uuid4() + mock_task.short_id = "PRJ-202" + mock_task.title = "Old Title" + mock_task.body = "Old Body" + mock_task.priority = PriorityLevel.LOW + mock_task.assignee_discord_id = None + mock_task.due_at = None + mock_task.watchers = [] + mock_task.version = 1 + + view = TaskQuickControlsView(task=mock_task, task_service=task_srv, workspace=mock_workspace) + + # Change staged fields + view.staged_title = "New Atomically Saved Title" + view.staged_body = "New Atomically Saved Body" + view.staged_priority = PriorityLevel.HIGH + view.staged_assignee_id = 998877 + view.staged_watchers = [1122, 3344] + + interaction = MagicMock(spec=discord.Interaction) + interaction.user = MagicMock(id=1001) + interaction.response = MagicMock() + interaction.response.is_done.return_value = False + interaction.response.edit_message = AsyncMock() + + updated_mock_task = MagicMock(spec=Task) + updated_mock_task.short_id = "PRJ-202" + mock_workspace.save_task_controls.return_value = updated_mock_task + + await view._on_save_clicked(interaction) + + mock_workspace.save_task_controls.assert_awaited_once_with( + interaction, + task=mock_task, + priority=PriorityLevel.HIGH, + assignee_id=998877, + due_at=None, + clear_due_at=False, + watchers=[1122, 3344], + title="New Atomically Saved Title", + body="New Atomically Saved Body", + clear_body=False, + ) + + +@pytest.mark.asyncio +async def test_workspace_adapter_save_task_controls_persists_title_and_body(services): + """End-to-end verification that save_task_controls persists title and body to database.""" + proj_srv = services["project"] + task_srv = services["task"] + guild_id = 999111222 + + project = await proj_srv.create_project(guild_id=guild_id, name="Infra Ops", prefix="INF") + task = await task_srv.create_task( + guild_id=guild_id, + title="Initial Task Title", + body="Initial Body", + creator_discord_id=1001, + project_id=project.id, + ) + + bot = MagicMock() + workspace = DiscordTaskWorkspaceAdapter(bot=bot, task_service=task_srv, project_service=proj_srv) + workspace.sync_workspace = AsyncMock() + + interaction = MagicMock(spec=discord.Interaction) + interaction.user = MagicMock(id=1001) + interaction.channel = None + + updated_task = await workspace.save_task_controls( + interaction=interaction, + task=task, + title="Persisted New Title", + body="Persisted New Body", + priority=PriorityLevel.HIGH, + ) + + assert updated_task is not None + assert updated_task.title == "Persisted New Title" + assert updated_task.body == "Persisted New Body" + assert updated_task.priority == PriorityLevel.HIGH + + # Verify directly from database + db_task = await task_srv.get_by_id(task.id) + assert db_task.title == "Persisted New Title" + assert db_task.body == "Persisted New Body" + assert db_task.priority == PriorityLevel.HIGH + + # Verify sync_workspace was invoked with sync_title=True + workspace.sync_workspace.assert_awaited_once_with( + updated_task, + sync_title=True, + sync_tags=True, + sync_archive=False, + sync_starter_card=True, + ) + + +@pytest.mark.asyncio +async def test_workspace_adapter_save_task_controls_clears_body(services): + """Verify that save_task_controls with clear_body=True removes the description from the database.""" + proj_srv = services["project"] + task_srv = services["task"] + guild_id = 999111222 + + project = await proj_srv.create_project(guild_id=guild_id, name="Infra Ops", prefix="INF") + task = await task_srv.create_task( + guild_id=guild_id, + title="Task With Description", + body="Initial description to be cleared", + creator_discord_id=1001, + project_id=project.id, + ) + + bot = MagicMock() + workspace = DiscordTaskWorkspaceAdapter(bot=bot, task_service=task_srv, project_service=proj_srv) + workspace.sync_workspace = AsyncMock() + + interaction = MagicMock(spec=discord.Interaction) + interaction.user = MagicMock(id=1001) + interaction.channel = None + + updated_task = await workspace.save_task_controls( + interaction=interaction, + task=task, + clear_body=True, + ) + + assert updated_task is not None + assert updated_task.body is None + + # Verify in DB + db_task = await task_srv.get_by_id(task.id) + assert db_task.body is None + + +@pytest.mark.asyncio +async def test_unified_controls_view_clearing_body_passes_clear_body_flag(): + """Verify that when a user clears an existing body, save_task_controls receives clear_body=True.""" + mock_workspace = MagicMock() + mock_workspace.save_task_controls = AsyncMock() + + task_srv = MagicMock() + mock_task = MagicMock(spec=Task) + mock_task.id = uuid4() + mock_task.short_id = "PRJ-205" + mock_task.title = "Task Title" + mock_task.body = "Existing description" + mock_task.priority = PriorityLevel.NORMAL + mock_task.assignee_discord_id = None + mock_task.due_at = None + mock_task.watchers = [] + mock_task.version = 1 + + view = TaskQuickControlsView(task=mock_task, task_service=task_srv, workspace=mock_workspace) + + # Simulate clearing body + view.staged_body = None + + interaction = MagicMock(spec=discord.Interaction) + interaction.user = MagicMock(id=1001) + interaction.response = MagicMock() + interaction.response.is_done.return_value = False + interaction.response.edit_message = AsyncMock() + + updated_mock_task = MagicMock(spec=Task) + updated_mock_task.short_id = "PRJ-205" + mock_workspace.save_task_controls.return_value = updated_mock_task + + await view._on_save_clicked(interaction) + + mock_workspace.save_task_controls.assert_awaited_once_with( + interaction, + task=mock_task, + priority=PriorityLevel.NORMAL, + assignee_id=None, + due_at=None, + clear_due_at=False, + watchers=[], + title="Task Title", + body=None, + clear_body=True, + ) + + +def test_unified_controls_view_displays_clear_draft_indicators(): + """Verify TaskQuickControlsView clearly signals that it is an unsaved draft view and orders buttons properly.""" + task_srv = MagicMock() + mock_task = MagicMock(spec=Task) + mock_task.id = uuid4() + mock_task.short_id = "PRJ-303" + # Long 90-character title to test truncation threshold + mock_task.title = "Draft UX Task with a very long title that should not be truncated prematurely at 70 chars" + mock_task.body = "Testing draft indications" + mock_task.priority = PriorityLevel.NORMAL + mock_task.assignee_discord_id = None + mock_task.due_at = None + mock_task.watchers = [] + + view = TaskQuickControlsView(task=mock_task, task_service=task_srv) + embed = view._build_embed() + + # 1. Embed title signals draft cleanly without alert emoji and preserves titles up to 100 chars + assert embed.title.startswith("Edit Draft:") + assert f"[PRJ-303] {mock_task.title}" in embed.title + assert "⚠️" not in embed.title + + # 2. Embed color is amber/gold + assert embed.color == discord.Color.gold() + + # 3. Description does not clutter with duplicate header warning + assert "⚠️ **Unsaved Draft Changes**" not in embed.description + + # 4. Footer displays the single clear unsaved draft warning with alert emoji + assert "⚠️ Unsaved Draft" in embed.footer.text + + # 5. Row 0 has "Discard Changes" with danger style immediately adjacent to "Save Changes" + row0_buttons = [item for item in view.children if isinstance(item, discord.ui.Button) and item.row == 0] + button_labels = [b.label for b in row0_buttons] + assert button_labels[:3] == ["Save Changes", "Discard Changes", "Edit Title / Body"] + discard_btn = next((b for b in row0_buttons if b.label == "Discard Changes"), None) + assert discard_btn is not None + assert discard_btn.style == discord.ButtonStyle.danger