feat: enhance autodraft settings with detailed option descriptions an… (#56)
* feat: enhance autodraft settings with detailed option descriptions and improved state handling * test: update AutodraftSettings tests for button interactions and API payloads
This commit is contained in:
parent
ca2fd288ab
commit
46332ea735
3 changed files with 311 additions and 268 deletions
|
|
@ -1,19 +1,85 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Check, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
|
||||
type AutodraftMode = "next_pick" | "while_on";
|
||||
|
||||
// The three visible states map to isEnabled + mode combinations:
|
||||
// "off" → isEnabled: false
|
||||
// "next_pick" → isEnabled: true, mode: "next_pick"
|
||||
// "all_picks" → isEnabled: true, mode: "while_on"
|
||||
type AutodraftState = "off" | "next_pick" | "all_picks";
|
||||
// 4 visible states map to isEnabled + mode + queueOnly:
|
||||
// "off" → isEnabled: false
|
||||
// "next_queue" → isEnabled: true, mode: "next_pick", queueOnly: true
|
||||
// "all_queue" → isEnabled: true, mode: "while_on", queueOnly: true
|
||||
// "all_picks" → isEnabled: true, mode: "while_on", queueOnly: false
|
||||
type AutodraftState = "off" | "next_queue" | "all_queue" | "all_picks";
|
||||
|
||||
function toAutodraftState(isEnabled: boolean, mode: AutodraftMode): AutodraftState {
|
||||
type OptionConfig = {
|
||||
label: string;
|
||||
desc: string;
|
||||
isEnabled: boolean;
|
||||
mode: AutodraftMode;
|
||||
queueOnly: boolean;
|
||||
};
|
||||
|
||||
// Record ensures all AutodraftState values are covered — no runtime find() needed
|
||||
const OPTIONS: Record<AutodraftState, OptionConfig> = {
|
||||
off: {
|
||||
label: "Off",
|
||||
desc: "You pick manually every round.",
|
||||
isEnabled: false,
|
||||
mode: "next_pick",
|
||||
queueOnly: false,
|
||||
},
|
||||
next_queue: {
|
||||
label: "Next in Queue",
|
||||
desc: "Autodrafts your next pick from your queue, then turns off.",
|
||||
isEnabled: true,
|
||||
mode: "next_pick",
|
||||
queueOnly: true,
|
||||
},
|
||||
all_queue: {
|
||||
label: "All in Queue",
|
||||
desc: "Keeps autodrafting from your queue until it runs out, then turns off automatically.",
|
||||
isEnabled: true,
|
||||
mode: "while_on",
|
||||
queueOnly: true,
|
||||
},
|
||||
all_picks: {
|
||||
label: "All Picks",
|
||||
desc: "Keeps autodrafting all your picks. Uses your queue first, then falls back to the highest-ranked undrafted player.",
|
||||
isEnabled: true,
|
||||
mode: "while_on",
|
||||
queueOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const OPTION_ORDER: AutodraftState[] = ["off", "next_queue", "all_queue", "all_picks"];
|
||||
|
||||
// border-l-[3px] is applied to ALL options (transparent when inactive) so the
|
||||
// content never shifts when the active border color is applied.
|
||||
function getButtonClassName(state: AutodraftState, isActive: boolean): string {
|
||||
if (!isActive) {
|
||||
return "border-l-[3px] border-l-transparent text-muted-foreground hover:bg-muted/40";
|
||||
}
|
||||
if (state === "off") {
|
||||
return "border-l-[3px] border-l-muted-foreground/50 text-foreground";
|
||||
}
|
||||
return "border-l-[3px] border-l-electric bg-electric text-background";
|
||||
}
|
||||
|
||||
function getCheckClassName(state: AutodraftState): string {
|
||||
if (state === "off") return "text-muted-foreground";
|
||||
return "text-background";
|
||||
}
|
||||
|
||||
function toAutodraftState(
|
||||
isEnabled: boolean,
|
||||
mode: AutodraftMode,
|
||||
queueOnly: boolean
|
||||
): AutodraftState {
|
||||
if (!isEnabled) return "off";
|
||||
return mode === "next_pick" ? "next_pick" : "all_picks";
|
||||
if (mode === "next_pick") return "next_queue";
|
||||
return queueOnly ? "all_queue" : "all_picks";
|
||||
}
|
||||
|
||||
interface AutodraftSettingsProps {
|
||||
|
|
@ -35,83 +101,99 @@ export function AutodraftSettings({
|
|||
isMyTurn,
|
||||
onUpdate,
|
||||
}: AutodraftSettingsProps) {
|
||||
const [localState, setLocalState] = useState<AutodraftState>(toAutodraftState(isEnabled, mode));
|
||||
const [localQueueOnly, setLocalQueueOnly] = useState(queueOnly);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [localState, setLocalState] = useState<AutodraftState>(
|
||||
toAutodraftState(isEnabled, mode, queueOnly)
|
||||
);
|
||||
// Holds the abort controller for any in-flight save so rapid selections
|
||||
// cancel the previous request rather than racing to update the server.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Sync local state with props when they change (from socket events)
|
||||
useEffect(() => {
|
||||
setLocalState(toAutodraftState(isEnabled, mode));
|
||||
}, [isEnabled, mode]);
|
||||
setLocalState(toAutodraftState(isEnabled, mode, queueOnly));
|
||||
}, [isEnabled, mode, queueOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalQueueOnly(queueOnly);
|
||||
}, [queueOnly]);
|
||||
const sendUpdate = async (newState: AutodraftState) => {
|
||||
// Cancel any in-flight request before starting a new one
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
const sendUpdate = async (newState: AutodraftState, newQueueOnly: boolean) => {
|
||||
if (isUpdating) return;
|
||||
setIsUpdating(true);
|
||||
|
||||
const newEnabled = newState !== "off";
|
||||
const newMode: AutodraftMode = newState === "all_picks" ? "while_on" : "next_pick";
|
||||
const option = OPTIONS[newState];
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", seasonId);
|
||||
formData.append("teamId", teamId);
|
||||
formData.append("isEnabled", newEnabled.toString());
|
||||
formData.append("mode", newMode);
|
||||
formData.append("queueOnly", newQueueOnly.toString());
|
||||
formData.append("isEnabled", option.isEnabled.toString());
|
||||
formData.append("mode", option.mode);
|
||||
formData.append("queueOnly", option.queueOnly.toString());
|
||||
|
||||
const response = await fetch("/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onUpdate(newEnabled, newMode, newQueueOnly);
|
||||
onUpdate(option.isEnabled, option.mode, option.queueOnly);
|
||||
toast.success(
|
||||
option.isEnabled ? `Autodraft set to "${option.label}"` : "Autodraft turned off"
|
||||
);
|
||||
} else {
|
||||
// Revert on error
|
||||
setLocalState(toAutodraftState(isEnabled, mode));
|
||||
setLocalQueueOnly(queueOnly);
|
||||
console.error("Failed to update autodraft settings");
|
||||
setLocalState(toAutodraftState(isEnabled, mode, queueOnly));
|
||||
toast.error("Failed to save autodraft settings");
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setLocalState(toAutodraftState(isEnabled, mode));
|
||||
setLocalQueueOnly(queueOnly);
|
||||
console.error("Error updating autodraft settings:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
// Ignore cancellations — a newer selection has already taken over
|
||||
if (error instanceof Error && error.name === "AbortError") return;
|
||||
setLocalState(toAutodraftState(isEnabled, mode, queueOnly));
|
||||
toast.error("Failed to save autodraft settings");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStateChange = (newState: AutodraftState) => {
|
||||
if (isMyTurn || isUpdating) return;
|
||||
if (newState === localState || isMyTurn) return;
|
||||
setLocalState(newState);
|
||||
sendUpdate(newState, localQueueOnly);
|
||||
sendUpdate(newState);
|
||||
};
|
||||
|
||||
const handleQueueOnlyChange = async (checked: boolean) => {
|
||||
if (isMyTurn || isUpdating) return;
|
||||
setLocalQueueOnly(checked);
|
||||
sendUpdate(localState, checked);
|
||||
};
|
||||
|
||||
const isDisabled = isMyTurn || isUpdating;
|
||||
const isDisabled = isMyTurn;
|
||||
|
||||
return (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<Label className="text-sm font-semibold mb-3 block">Autodraft</Label>
|
||||
{/* Header with info icon inline */}
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Label className="text-sm font-semibold">Autodraft</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Autodraft options explained"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<p className="text-sm font-semibold mb-2.5">Autodraft Options</p>
|
||||
<div className="space-y-2.5">
|
||||
{OPTION_ORDER.map((state) => {
|
||||
const { label, desc } = OPTIONS[state];
|
||||
return (
|
||||
<div key={state}>
|
||||
<p className="text-xs font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Three-state button group */}
|
||||
<div className="flex rounded-lg border overflow-hidden mb-3">
|
||||
{(["off", "next_pick", "all_picks"] as AutodraftState[]).map((state) => {
|
||||
const labels: Record<AutodraftState, string> = {
|
||||
off: "Off",
|
||||
next_pick: "Next Pick",
|
||||
all_picks: "All Picks",
|
||||
};
|
||||
<div className="flex flex-col rounded-lg border overflow-hidden">
|
||||
{OPTION_ORDER.map((state) => {
|
||||
const { label } = OPTIONS[state];
|
||||
const isActive = localState === state;
|
||||
return (
|
||||
<button
|
||||
|
|
@ -119,40 +201,21 @@ export function AutodraftSettings({
|
|||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleStateChange(state)}
|
||||
className={`flex-1 py-1.5 text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-electric text-background"
|
||||
: "bg-transparent text-muted-foreground hover:bg-muted/60"
|
||||
} ${isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
className={`w-full py-2.5 px-3 text-left transition-colors border-b last:border-b-0 flex items-center justify-between gap-2 ${getButtonClassName(state, isActive)} ${
|
||||
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{labels[state]}
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
{isActive && (
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${getCheckClassName(state)}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Queue-only toggle — only visible when autodraft is active */}
|
||||
{localState !== "off" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="queue-only-switch"
|
||||
className={`text-xs ${isDisabled ? "text-muted-foreground" : "cursor-pointer"}`}
|
||||
>
|
||||
Only autodraft from queue
|
||||
</Label>
|
||||
<Switch
|
||||
id="queue-only-switch"
|
||||
checked={localQueueOnly}
|
||||
onCheckedChange={handleQueueOnlyChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMyTurn && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Autodraft settings are disabled during your pick
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">You're on the clock!</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { AutodraftSettings } from '~/components/AutodraftSettings';
|
||||
|
||||
// Mock fetch
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('AutodraftSettings Component', () => {
|
||||
|
|
@ -11,8 +14,8 @@ describe('AutodraftSettings Component', () => {
|
|||
teamId: 'team-456',
|
||||
isEnabled: false,
|
||||
mode: 'next_pick' as const,
|
||||
isMyTurn: false,
|
||||
queueOnly: false,
|
||||
isMyTurn: false,
|
||||
onUpdate: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -24,63 +27,59 @@ describe('AutodraftSettings Component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render three autodraft state buttons', () => {
|
||||
it('renders all four option buttons', () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Next Pick' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Next in Queue' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'All in Queue' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'All Picks' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show the queue-only toggle when autodraft is Off', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Only autodraft from queue')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the queue-only toggle when autodraft is active', () => {
|
||||
it('does not render a queue-only toggle switch', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} />);
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Only autodraft from queue')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show disabled message when it is the user\'s turn', () => {
|
||||
it('shows "You\'re on the clock!" when isMyTurn=true', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
|
||||
|
||||
expect(screen.getByText(/Autodraft settings are disabled during your pick/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/You're on the clock!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should mark Off as active when isEnabled=false', () => {
|
||||
it('marks Off as active (muted border) when isEnabled=false', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
|
||||
const offButton = screen.getByRole('button', { name: 'Off' });
|
||||
expect(offButton.className).toContain('bg-electric');
|
||||
expect(screen.getByRole('button', { name: 'Off' }).className).toContain(
|
||||
'border-l-muted-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('should mark Next Pick as active when isEnabled=true and mode=next_pick', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" />);
|
||||
|
||||
const nextPickButton = screen.getByRole('button', { name: 'Next Pick' });
|
||||
expect(nextPickButton.className).toContain('bg-electric');
|
||||
it('marks Next in Queue as active when isEnabled=true, mode=next_pick', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" queueOnly={true} />);
|
||||
expect(screen.getByRole('button', { name: 'Next in Queue' }).className).toContain('bg-electric');
|
||||
});
|
||||
|
||||
it('should mark All Picks as active when isEnabled=true and mode=while_on', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="while_on" />);
|
||||
it('marks All in Queue as active when isEnabled=true, mode=while_on, queueOnly=true', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="while_on" queueOnly={true} />);
|
||||
expect(screen.getByRole('button', { name: 'All in Queue' }).className).toContain('bg-electric');
|
||||
});
|
||||
|
||||
const allPicksButton = screen.getByRole('button', { name: 'All Picks' });
|
||||
expect(allPicksButton.className).toContain('bg-electric');
|
||||
it('marks All Picks as active when isEnabled=true, mode=while_on, queueOnly=false', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="while_on" queueOnly={false} />);
|
||||
expect(screen.getByRole('button', { name: 'All Picks' }).className).toContain('bg-electric');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button Group Interaction', () => {
|
||||
it('should switch to Next Pick when that button is clicked', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} onUpdate={onUpdate} />);
|
||||
// ─── Interaction ──────────────────────────────────────────────────────────
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
|
||||
describe('Button interaction', () => {
|
||||
it('switches to Next in Queue and calls onUpdate with correct args', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} onUpdate={onUpdate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
|
|
@ -88,195 +87,176 @@ describe('AutodraftSettings Component', () => {
|
|||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick', false);
|
||||
});
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick', true));
|
||||
});
|
||||
|
||||
it('should switch to All Picks when that button is clicked', async () => {
|
||||
it('switches to All in Queue and calls onUpdate with correct args', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} onUpdate={onUpdate} />);
|
||||
render(<AutodraftSettings {...defaultProps} onUpdate={onUpdate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All in Queue' }));
|
||||
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(true, 'while_on', true));
|
||||
});
|
||||
|
||||
it('switches to All Picks and calls onUpdate with correct args', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} onUpdate={onUpdate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All Picks' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(true, 'while_on', false);
|
||||
});
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(true, 'while_on', false));
|
||||
});
|
||||
|
||||
it('should switch to Off when that button is clicked from an active state', async () => {
|
||||
it('switches to Off and calls onUpdate with isEnabled=false', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} onUpdate={onUpdate} />);
|
||||
render(
|
||||
<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" queueOnly={true} onUpdate={onUpdate} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(false, 'next_pick', false);
|
||||
});
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(false, 'next_pick', false));
|
||||
});
|
||||
|
||||
it('should disable all buttons when it is the user\'s turn', () => {
|
||||
it('disables all buttons when isMyTurn=true', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Off' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Next Pick' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Next in Queue' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'All in Queue' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'All Picks' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not fire API call when a button is clicked during user\'s turn', () => {
|
||||
it('does not call fetch when a button is clicked during the user\'s turn', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle API errors by reverting state', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Server error' }),
|
||||
});
|
||||
|
||||
it('does not re-fire when the already-active option is clicked', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Off button should still be active (reverted)
|
||||
const offButton = screen.getByRole('button', { name: 'Off' });
|
||||
expect(offButton.className).toContain('bg-electric');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle network errors by reverting state', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue-Only Toggle', () => {
|
||||
it('should send queueOnly=true when the toggle is switched on', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} queueOnly={false} onUpdate={onUpdate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick', true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should send queueOnly=false when the toggle is switched off', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} queueOnly={true} onUpdate={onUpdate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable the queue-only toggle when it is the user\'s turn', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} isMyTurn={true} />);
|
||||
|
||||
expect(screen.getByRole('switch')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Integration', () => {
|
||||
it('should post correct formData when switching to Next Pick', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/autodraft/update',
|
||||
expect.objectContaining({ method: 'POST', body: expect.any(FormData) })
|
||||
);
|
||||
});
|
||||
|
||||
const formData = (global.fetch as any).mock.calls[0][1].body as FormData;
|
||||
expect(formData.get('seasonId')).toBe('season-123');
|
||||
expect(formData.get('teamId')).toBe('team-456');
|
||||
expect(formData.get('isEnabled')).toBe('true');
|
||||
expect(formData.get('mode')).toBe('next_pick');
|
||||
expect(formData.get('queueOnly')).toBe('false');
|
||||
});
|
||||
|
||||
it('should post mode=while_on when switching to All Picks', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All Picks' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const formData = (global.fetch as any).mock.calls[0][1].body as FormData;
|
||||
expect(formData.get('mode')).toBe('while_on');
|
||||
expect(formData.get('isEnabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('should post isEnabled=false when switching to Off', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const formData = (global.fetch as any).mock.calls[0][1].body as FormData;
|
||||
expect(formData.get('isEnabled')).toBe('false');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable buttons during an in-flight API call', async () => {
|
||||
let resolve: any;
|
||||
// ─── Optimistic UI ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Optimistic UI', () => {
|
||||
it('does not disable buttons while a fetch is in flight', async () => {
|
||||
let resolve: (v: any) => void;
|
||||
(global.fetch as any).mockReturnValueOnce(new Promise((r) => { resolve = r; }));
|
||||
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
|
||||
// Buttons stay enabled — optimistic UI does not block interaction
|
||||
expect(screen.getByRole('button', { name: 'Off' })).not.toBeDisabled();
|
||||
|
||||
resolve!({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
it('fires a fetch for every rapid click, aborting previous in-flight requests', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All in Queue' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error handling ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('reverts to the previous state on a non-ok API response', async () => {
|
||||
(global.fetch as any).mockResolvedValueOnce({ ok: false });
|
||||
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Off' })).toBeDisabled();
|
||||
});
|
||||
|
||||
resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Off' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Off' }).className).toContain(
|
||||
'border-l-muted-foreground'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should only process the first click when buttons are clicked rapidly', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
it('reverts to the previous state on a network error', async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All Picks' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('button', { name: 'Off' }).className).toContain(
|
||||
'border-l-muted-foreground'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API payload ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('API payload', () => {
|
||||
it('posts correct formData for Next in Queue (next_pick, queueOnly=true)', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||
|
||||
const body = (global.fetch as any).mock.calls[0][1].body as FormData;
|
||||
expect(body.get('seasonId')).toBe('season-123');
|
||||
expect(body.get('teamId')).toBe('team-456');
|
||||
expect(body.get('isEnabled')).toBe('true');
|
||||
expect(body.get('mode')).toBe('next_pick');
|
||||
expect(body.get('queueOnly')).toBe('true');
|
||||
});
|
||||
|
||||
it('posts correct formData for All in Queue (while_on, queueOnly=true)', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All in Queue' }));
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||
|
||||
const body = (global.fetch as any).mock.calls[0][1].body as FormData;
|
||||
expect(body.get('isEnabled')).toBe('true');
|
||||
expect(body.get('mode')).toBe('while_on');
|
||||
expect(body.get('queueOnly')).toBe('true');
|
||||
});
|
||||
|
||||
it('posts correct formData for All Picks (while_on, queueOnly=false)', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All Picks' }));
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||
|
||||
const body = (global.fetch as any).mock.calls[0][1].body as FormData;
|
||||
expect(body.get('isEnabled')).toBe('true');
|
||||
expect(body.get('mode')).toBe('while_on');
|
||||
expect(body.get('queueOnly')).toBe('false');
|
||||
});
|
||||
|
||||
it('posts isEnabled=false for Off', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" queueOnly={true} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||
|
||||
const body = (global.fetch as any).mock.calls[0][1].body as FormData;
|
||||
expect(body.get('isEnabled')).toBe('false');
|
||||
});
|
||||
|
||||
it('passes an AbortSignal to fetch', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next in Queue' }));
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
||||
|
||||
const options = (global.fetch as any).mock.calls[0][1];
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -698,7 +698,7 @@ export default function DraftRoom() {
|
|||
if (userTeam && data.teamId === userTeam.id) {
|
||||
// Detect server-side auto-disable: was enabled with queueOnly, now disabled
|
||||
const prev = userAutodraftRef.current;
|
||||
if (!data.isEnabled && prev.isEnabled && prev.queueOnly) {
|
||||
if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") {
|
||||
toast.info("Autodraft disabled — your queue is empty");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue