fix(app-tauri,frontend): la fenêtre Tâches planifiées s'affiche en mode détaché — #359 (QA verte)

cause: le panneau Tâches planifiées n'avait aucune variante dans l'enum ViewPanel, open_view_window("scheduledTasks") rejetait INVALID et detachPanel ne touchait le placement qu'en cas de succès — même famille que #299, aucun effet visible ni message.
fix: variante ViewPanel::ScheduledTasks avec contrat stable label view-scheduledTasks / url index.html?panel=scheduledTasks / titre « Scheduled tasks », identité persistée des fenêtres view-scheduledTasks acceptée à la restauration #50; frontend ViewWindow rend le ScheduledTasksPanel du projet focalisé et ProjectsView expose le placement Fenêtre détachée via WindowGateway; retrait de la variante Projects devenue morte après #315 — le panneau Projects (coquille de la fenêtre principale) n'est plus détachable, refus INVALID gardé par tests.
QA verte: view_window_tests dont scheduled_tasks_view_window_contract_is_stable et projects_panel_is_not_detachable, persisted_identity_accepts_scheduled_tasks_view_labels, frontend ViewWindow/window gateway/ProjectsView.detach verts.
This commit is contained in:
2026-09-15 09:20:28 +02:00
parent 7de1d0afbe
commit 31ca03c29b
5 changed files with 103 additions and 4 deletions

View File

@ -4440,9 +4440,9 @@ pub struct FocusedProjectChangedEventDto {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ViewPanel { pub(crate) enum ViewPanel {
Projects,
Context, Context,
Work, Work,
ScheduledTasks,
Tickets, Tickets,
Mcp, Mcp,
Agents, Agents,
@ -4456,9 +4456,9 @@ pub(crate) enum ViewPanel {
impl ViewPanel { impl ViewPanel {
pub(crate) fn parse(raw: &str) -> Result<Self, ErrorDto> { pub(crate) fn parse(raw: &str) -> Result<Self, ErrorDto> {
match raw { match raw {
"projects" => Ok(Self::Projects),
"context" => Ok(Self::Context), "context" => Ok(Self::Context),
"work" => Ok(Self::Work), "work" => Ok(Self::Work),
"scheduledTasks" => Ok(Self::ScheduledTasks),
"tickets" => Ok(Self::Tickets), "tickets" => Ok(Self::Tickets),
"mcp" => Ok(Self::Mcp), "mcp" => Ok(Self::Mcp),
"agents" => Ok(Self::Agents), "agents" => Ok(Self::Agents),
@ -4476,9 +4476,9 @@ impl ViewPanel {
pub(crate) const fn as_str(self) -> &'static str { pub(crate) const fn as_str(self) -> &'static str {
match self { match self {
Self::Projects => "projects",
Self::Context => "context", Self::Context => "context",
Self::Work => "work", Self::Work => "work",
Self::ScheduledTasks => "scheduledTasks",
Self::Tickets => "tickets", Self::Tickets => "tickets",
Self::Mcp => "mcp", Self::Mcp => "mcp",
Self::Agents => "agents", Self::Agents => "agents",
@ -4492,9 +4492,9 @@ impl ViewPanel {
pub(crate) const fn title(self) -> &'static str { pub(crate) const fn title(self) -> &'static str {
match self { match self {
Self::Projects => "Projects",
Self::Context => "Project context", Self::Context => "Project context",
Self::Work => "Work state", Self::Work => "Work state",
Self::ScheduledTasks => "Scheduled tasks",
Self::Tickets => "Tickets", Self::Tickets => "Tickets",
Self::Mcp => "MCP", Self::Mcp => "MCP",
Self::Agents => "Agents", Self::Agents => "Agents",
@ -4814,6 +4814,25 @@ mod view_window_tests {
assert_eq!(view_panel_from_window_label("view-mcp"), Some(panel)); assert_eq!(view_panel_from_window_label("view-mcp"), Some(panel));
} }
#[test]
fn scheduled_tasks_view_window_contract_is_stable() {
let panel = ViewPanel::parse("scheduledTasks").unwrap();
assert_eq!(panel, ViewPanel::ScheduledTasks);
assert_eq!(panel.as_str(), "scheduledTasks");
assert_eq!(panel.title(), "Scheduled tasks");
assert_eq!(view_window_label(panel), "view-scheduledTasks");
assert_eq!(view_window_url(panel), "index.html?panel=scheduledTasks");
assert_eq!(
view_panel_from_window_label("view-scheduledTasks"),
Some(panel)
);
assert_eq!(
view_panel_from_window_label("view-scheduledTasks-0000000000000000000000000000002a"),
Some(panel)
);
}
#[test] #[test]
fn view_window_rejects_unknown_panel() { fn view_window_rejects_unknown_panel() {
let err = ViewPanel::parse("terminal").unwrap_err(); let err = ViewPanel::parse("terminal").unwrap_err();
@ -4822,6 +4841,15 @@ mod view_window_tests {
assert!(err.message.contains("invalid view panel: terminal")); assert!(err.message.contains("invalid view panel: terminal"));
} }
#[test]
fn projects_panel_is_not_detachable() {
let err = ViewPanel::parse("projects").unwrap_err();
assert_eq!(err.code, "INVALID");
assert!(err.message.contains("invalid view panel: projects"));
assert!(view_panel_from_window_label("view-projects").is_none());
}
#[test] #[test]
fn view_window_label_parser_accepts_stable_and_legacy_labels() { fn view_window_label_parser_accepts_stable_and_legacy_labels() {
assert_eq!( assert_eq!(

View File

@ -1132,6 +1132,26 @@ mod tests {
); );
} }
#[test]
fn persisted_identity_accepts_scheduled_tasks_view_labels() {
let (kind, panel, project_id, plugin_layout, url) =
persisted_window_identity_from_label("view-scheduledTasks").unwrap();
assert_eq!(kind, PersistedWindowKind::View);
assert_eq!(panel.as_deref(), Some("scheduledTasks"));
assert!(project_id.is_none());
assert!(plugin_layout.is_none());
assert_eq!(url.as_deref(), Some("index.html?panel=scheduledTasks"));
assert_eq!(
persisted_view_identity_from_label(
"view-scheduledTasks-0000000000000000000000000000002a"
)
.unwrap()
.as_str(),
"scheduledTasks"
);
}
#[test] #[test]
fn persisted_identity_accepts_plugin_layout_view_labels() { fn persisted_identity_accepts_plugin_layout_view_labels() {
let plugin_id = domain::PluginId::new("dev.idea.android-plugin").unwrap(); let plugin_id = domain::PluginId::new("dev.idea.android-plugin").unwrap();
@ -1157,6 +1177,7 @@ mod tests {
fn persisted_identity_filters_unknown_or_headless_labels() { fn persisted_identity_filters_unknown_or_headless_labels() {
assert!(persisted_window_identity_from_label("mcp-server").is_none()); assert!(persisted_window_identity_from_label("mcp-server").is_none());
assert!(persisted_window_identity_from_label("settings").is_none()); assert!(persisted_window_identity_from_label("settings").is_none());
assert!(persisted_window_identity_from_label("view-projects").is_none());
assert!(persisted_window_identity_from_label( assert!(persisted_window_identity_from_label(
"view-unknown-0000000000000000000000000000002a" "view-unknown-0000000000000000000000000000002a"
) )

View File

@ -41,6 +41,14 @@ describe("TauriWindowGateway (#23)", () => {
}); });
}); });
it("passes scheduledTasks unchanged to the detached-window command", async () => {
await new TauriWindowGateway().openViewWindow("scheduledTasks");
expect(invoke).toHaveBeenCalledWith("open_view_window", {
panel: "scheduledTasks",
});
});
it("opens plugin layout windows through the backend command", async () => { it("opens plugin layout windows through the backend command", async () => {
invoke.mockResolvedValueOnce({ invoke.mockResolvedValueOnce({
label: "view-plugin-layout", label: "view-plugin-layout",

View File

@ -10,6 +10,7 @@ import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { import {
createMockGateways,
MockAgentGateway, MockAgentGateway,
MockFocusedProjectGateway, MockFocusedProjectGateway,
MockProjectGateway, MockProjectGateway,
@ -66,6 +67,13 @@ describe("parseViewWindowParams (#47)", () => {
}); });
}); });
it("accepts the detachable scheduled tasks panel", () => {
expect(parseViewWindowParams("?panel=scheduledTasks")).toEqual({
kind: "panel",
panel: "scheduledTasks",
});
});
it("parses a plugin layout window query from the backend base64url contract", () => { it("parses a plugin layout window query from the backend base64url contract", () => {
expect( expect(
parseViewWindowParams( parseViewWindowParams(
@ -170,6 +178,29 @@ describe("PluginLayoutWindow (#143)", () => {
}); });
describe("ViewWindow (#47)", () => { describe("ViewWindow (#47)", () => {
it("renders ScheduledTasksPanel for a focused project", async () => {
const gateways = createMockGateways();
const project = await gateways.project.createProject("alpha", "/p/a");
const focusedProject = new MockFocusedProjectGateway();
gateways.focusedProject = focusedProject;
await focusedProject.setFocusedProject({
id: project.id,
name: project.name,
root: project.root,
});
render(
<DIProvider gateways={gateways}>
<ViewWindow panel="scheduledTasks" />
</DIProvider>,
);
await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy());
expect(screen.getByRole("heading", { name: "Tâches planifiées", level: 1 })).toBeTruthy();
expect(await screen.findByRole("button", { name: "Nouvelle tâche planifiée" })).toBeTruthy();
expect(await screen.findByText("Aucune tâche planifiée pour ce projet")).toBeTruthy();
});
it("keeps the latest focused project when the initial snapshot is delayed", async () => { it("keeps the latest focused project when the initial snapshot is delayed", async () => {
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const project = new MockProjectGateway(); const project = new MockProjectGateway();

View File

@ -92,6 +92,17 @@ describe("ProjectsView detach to OS window (#23)", () => {
expect(screen.queryByRole("dialog", { name: "MCP" })).toBeNull(); expect(screen.queryByRole("dialog", { name: "MCP" })).toBeNull();
}); });
it("detaches scheduled tasks through WindowGateway", async () => {
const { windowGateway } = await renderWithProject();
const openViewWindow = vi.spyOn(windowGateway, "openViewWindow");
pickPlacement("Tâches planifiées", "Fenêtre détachée");
await waitFor(() => expect(openViewWindow).toHaveBeenCalledWith("scheduledTasks"));
expect(windowGateway.open.has("scheduledTasks")).toBe(true);
expect(screen.queryByRole("dialog", { name: "Tâches planifiées" })).toBeNull();
});
it("detaches project settings through the same retry-safe window path", async () => { it("detaches project settings through the same retry-safe window path", async () => {
const { windowGateway } = await renderWithProject(); const { windowGateway } = await renderWithProject();