feat: Implement initial Process Scheduling Simulator application with core logic, algorithms, UI components, and project setup.
This commit is contained in:
commit
083fc0027e
54 files changed
+8815
No files matched your search
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
SkipBack, StepForward, Play, Pause, RotateCcw
|
||||
} from 'lucide-react';
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
import { useSimulationStore, type SimStatus } from '../../store/simulationStore';
|
||||
import { useUiStore, SPEED_OPTIONS } from '../../store/uiStore';
|
||||
import IconButton from '../shared/IconButton';
|
||||
|
||||
const SPEED_LABELS: Record<number, string> = {
|
||||
0.25: '0.25×',
|
||||
0.5: '0.5×',
|
||||
1: '1×',
|
||||
2: '2×',
|
||||
4: '4×',
|
||||
};
|
||||
|
||||
const statusLabel: Record<SimStatus, string> = {
|
||||
idle: 'Pronto',
|
||||
paused: 'Pausado',
|
||||
running: 'Executando',
|
||||
finished: 'Concluído',
|
||||
};
|
||||
|
||||
const statusDotClass: Record<SimStatus, string> = {
|
||||
idle: 'bg-slate-500',
|
||||
paused: 'bg-amber-400',
|
||||
running: 'bg-emerald-400 animate-pulse',
|
||||
finished: 'bg-blue-400',
|
||||
};
|
||||
|
||||
const ControlBar = () => {
|
||||
const processes = useProcessStore(s => s.processes);
|
||||
const config = useProcessStore(s => s.config);
|
||||
|
||||
const status = useSimulationStore(s => s.status);
|
||||
const ticks = useSimulationStore(s => s.ticks);
|
||||
const { init, stepOnce, play, pause, reset } = useSimulationStore();
|
||||
|
||||
const speed = useUiStore(s => s.speed);
|
||||
const setSpeed = useUiStore(s => s.setSpeed);
|
||||
|
||||
const currentTick = ticks.length;
|
||||
const isIdle = status === 'idle';
|
||||
const isRunning = status === 'running';
|
||||
const isFinished = status === 'finished';
|
||||
const canPlay = (status === 'paused' || status === 'idle') && !isFinished && processes.length > 0;
|
||||
|
||||
const handlePlay = () => {
|
||||
if (isIdle) {
|
||||
init(processes, config);
|
||||
}
|
||||
play();
|
||||
};
|
||||
|
||||
const handleStep = () => {
|
||||
if (isIdle) {
|
||||
init(processes, config);
|
||||
}
|
||||
stepOnce();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (isIdle) return;
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-16 flex items-center px-5 gap-3 bg-slate-800 border-b border-slate-700 shadow-sm"
|
||||
role="toolbar"
|
||||
aria-label="Controles da simulação"
|
||||
>
|
||||
{/* Left group: simulation controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Reset */}
|
||||
<IconButton
|
||||
label="Reset"
|
||||
variant="ghost"
|
||||
onClick={handleReset}
|
||||
disabled={isIdle}
|
||||
data-testid="btn-reset"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</IconButton>
|
||||
|
||||
{/* Step */}
|
||||
<IconButton
|
||||
label="Avançar 1 tick (Step)"
|
||||
variant="default"
|
||||
onClick={handleStep}
|
||||
disabled={isFinished || processes.length === 0 || isRunning}
|
||||
data-testid="btn-step"
|
||||
>
|
||||
<StepForward size={18} />
|
||||
</IconButton>
|
||||
|
||||
{/* Play / Pause */}
|
||||
{isRunning ? (
|
||||
<IconButton
|
||||
label="Pausar simulação"
|
||||
variant="primary"
|
||||
onClick={pause}
|
||||
data-testid="btn-pause"
|
||||
>
|
||||
<Pause size={18} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
label="Iniciar / Retomar simulação"
|
||||
variant="primary"
|
||||
onClick={handlePlay}
|
||||
disabled={!canPlay && !isRunning}
|
||||
data-testid="btn-play"
|
||||
>
|
||||
<Play size={18} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-slate-700 mx-1" aria-hidden="true" />
|
||||
|
||||
{/* Tick display */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1 rounded-md bg-slate-900 border border-slate-700"
|
||||
aria-live="polite"
|
||||
aria-label={`Tick atual: ${currentTick}`}
|
||||
>
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-wide font-medium">Tick</span>
|
||||
<span
|
||||
className="font-mono font-bold text-blue-300 text-sm min-w-[3ch] text-right"
|
||||
data-testid="tick-display"
|
||||
>
|
||||
{String(currentTick).padStart(4, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`h-2 w-2 rounded-full ${statusDotClass[status]}`} aria-hidden="true" />
|
||||
<span className="text-xs text-slate-400">{statusLabel[status]}</span>
|
||||
</div>
|
||||
|
||||
{/* Right group: speed selector */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SkipBack size={14} className="text-slate-500" aria-hidden="true" />
|
||||
<label htmlFor="speed-select" className="text-xs text-slate-400 sr-only">
|
||||
Velocidade
|
||||
</label>
|
||||
<select
|
||||
id="speed-select"
|
||||
value={speed}
|
||||
onChange={e => setSpeed(Number(e.target.value))}
|
||||
className="rounded-md bg-slate-700 border border-slate-600 text-slate-200 text-xs px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||
aria-label="Velocidade de simulação"
|
||||
data-testid="speed-select"
|
||||
>
|
||||
{SPEED_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{SPEED_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlBar;
|
||||
@@ -0,0 +1,13 @@
|
||||
import Sidebar from './Sidebar';
|
||||
import MainArea from './MainArea';
|
||||
|
||||
const AppLayout = () => {
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-slate-900 text-slate-200 overflow-hidden font-sans">
|
||||
<Sidebar />
|
||||
<MainArea />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,25 @@
|
||||
import ControlBar from '../ControlBar/ControlBar';
|
||||
|
||||
const MainArea = () => {
|
||||
return (
|
||||
<main className="flex-1 flex flex-col h-full bg-slate-900 overflow-hidden relative">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<ControlBar />
|
||||
|
||||
{/* Placeholder for GanttChart — Phase 4 */}
|
||||
<div className="border-b border-slate-800 h-80 flex items-center justify-center text-slate-600 bg-slate-900/50 text-sm">
|
||||
[ Gantt Chart — Fase 4 ]
|
||||
</div>
|
||||
|
||||
{/* Placeholder for MetricsPanel — Phase 5 */}
|
||||
<div className="flex-1 flex items-center justify-center text-slate-600 bg-slate-950 p-6">
|
||||
<div className="w-full h-full border border-dashed border-slate-700 rounded-xl flex items-center justify-center text-sm">
|
||||
[ Metrics Panel — Fase 5 ]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainArea;
|
||||
@@ -0,0 +1,27 @@
|
||||
import ProcessPanel from '../ProcessPanel/ProcessPanel';
|
||||
import SchedulerPanel from '../SchedulerPanel/SchedulerPanel';
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<aside className="w-80 min-w-[20rem] h-full bg-slate-800 border-r border-slate-700 flex flex-col shadow-xl z-20">
|
||||
<div className="h-16 flex items-center px-6 border-b border-slate-700 bg-slate-800/80 backdrop-blur-sm">
|
||||
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent truncate">
|
||||
Process Simulator
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 scrollbar-hide">
|
||||
<ProcessPanel />
|
||||
<div className="border-t border-slate-700 pt-4">
|
||||
<SchedulerPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-12 border-t border-slate-700 flex items-center justify-center text-xs text-slate-500">
|
||||
PSS v1.0
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import type { Process } from '@core/types';
|
||||
import IconButton from '../shared/IconButton';
|
||||
import Badge from '../shared/Badge';
|
||||
import { classifyBound } from '../shared/processUtils';
|
||||
|
||||
interface ProcessCardProps {
|
||||
process: Process;
|
||||
onEdit: (process: Process) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const ProcessCard = ({ process, onEdit, onRemove }: ProcessCardProps) => {
|
||||
const bound = classifyBound(process.bursts);
|
||||
const totalCpu = process.bursts.filter(b => b.type === 'cpu').reduce((a, b) => a + b.duration, 0);
|
||||
const totalIo = process.bursts.filter(b => b.type === 'io').reduce((a, b) => a + b.duration, 0);
|
||||
|
||||
const boundVariant = bound === 'CPU Bound' ? 'cpu' : bound === 'I/O Bound' ? 'io' : 'balanced';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-start gap-3 rounded-lg bg-slate-800 border border-slate-700 p-3 hover:border-slate-500 transition-colors"
|
||||
data-testid={`process-card-${process.id}`}
|
||||
>
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="mt-0.5 h-8 w-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: process.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-slate-100 truncate">{process.name}</span>
|
||||
<Badge variant={boundVariant}>{bound}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-slate-400">
|
||||
<span>Arrival: <span className="text-slate-300 font-medium">{process.arrivalTime}</span></span>
|
||||
<span>Priority: <span className="text-slate-300 font-medium">{process.priority}</span></span>
|
||||
<span>CPU: <span className="text-emerald-400 font-medium">{totalCpu}</span></span>
|
||||
{totalIo > 0 && (
|
||||
<span>I/O: <span className="text-cyan-400 font-medium">{totalIo}</span></span>
|
||||
)}
|
||||
<span>Bursts: <span className="text-slate-300 font-medium">{process.bursts.length}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label={`Editar ${process.name}`}
|
||||
onClick={() => onEdit(process)}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label={`Remover ${process.name}`}
|
||||
onClick={() => onRemove(process.id)}
|
||||
className="hover:text-red-400 hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessCard;
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Minus, Check, X } from 'lucide-react';
|
||||
import type { Process, BurstSegment } from '@core/types';
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
import IconButton from '../shared/IconButton';
|
||||
import { generateId, nextColor } from '../shared/processUtils';
|
||||
|
||||
interface ProcessFormProps {
|
||||
/** If provided, the form edits this process; otherwise creates a new one. */
|
||||
editingProcess?: Process | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_BURSTS: BurstSegment[] = [{ type: 'cpu', duration: 4 }];
|
||||
|
||||
const ProcessForm = ({ editingProcess, onClose }: ProcessFormProps) => {
|
||||
const addProcess = useProcessStore(s => s.addProcess);
|
||||
const updateProcess = useProcessStore(s => s.updateProcess);
|
||||
|
||||
const isEditing = !!editingProcess;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [arrivalTime, setArrivalTime] = useState(0);
|
||||
const [priority, setPriority] = useState(5);
|
||||
const [color, setColor] = useState(nextColor);
|
||||
const [bursts, setBursts] = useState<BurstSegment[]>(DEFAULT_BURSTS);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProcess) {
|
||||
setName(editingProcess.name);
|
||||
setArrivalTime(editingProcess.arrivalTime);
|
||||
setPriority(editingProcess.priority);
|
||||
setColor(editingProcess.color);
|
||||
setBursts([...editingProcess.bursts.map(b => ({ ...b }))]);
|
||||
} else {
|
||||
setName('');
|
||||
setArrivalTime(0);
|
||||
setPriority(5);
|
||||
setColor(nextColor());
|
||||
setBursts([{ type: 'cpu', duration: 4 }]);
|
||||
}
|
||||
setErrors([]);
|
||||
}, [editingProcess]);
|
||||
|
||||
// ---- Burst helpers ----
|
||||
|
||||
const updateBurstDuration = (index: number, value: number) => {
|
||||
setBursts(prev => prev.map((b, i) => i === index ? { ...b, duration: Math.max(1, value) } : b));
|
||||
};
|
||||
|
||||
const addBurst = () => {
|
||||
// enforce alternating cpu/io pattern
|
||||
const lastType = bursts[bursts.length - 1]?.type ?? 'io';
|
||||
const nextType: BurstSegment['type'] = lastType === 'cpu' ? 'io' : 'cpu';
|
||||
setBursts(prev => [...prev, { type: nextType, duration: 2 }]);
|
||||
};
|
||||
|
||||
const removeBurst = (index: number) => {
|
||||
if (bursts.length <= 1) return;
|
||||
setBursts(prev => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
// Safe-guard: ensure it still starts (and ends) with cpu
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Validation ----
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: string[] = [];
|
||||
if (!name.trim()) errs.push('Nome é obrigatório');
|
||||
if (name.trim().length > 8) errs.push('Nome deve ter no máximo 8 caracteres');
|
||||
if (arrivalTime < 0) errs.push('Arrival Time deve ser ≥ 0');
|
||||
if (priority < 1 || priority > 10) errs.push('Priority deve estar entre 1 e 10');
|
||||
if (bursts.length === 0) errs.push('Deve haver ao menos 1 burst CPU');
|
||||
if (bursts[0]?.type !== 'cpu') errs.push('O primeiro burst deve ser CPU');
|
||||
if (bursts.at(-1)?.type !== 'cpu') errs.push('O último burst deve ser CPU');
|
||||
bursts.forEach((b, i) => {
|
||||
if (b.duration < 1) errs.push(`Burst ${i + 1}: duração deve ser ≥ 1`);
|
||||
if (i > 0 && bursts[i - 1].type === b.type) errs.push(`Bursts ${i} e ${i + 1} não podem ser do mesmo tipo (alternar CPU/IO)`);
|
||||
});
|
||||
setErrors(errs);
|
||||
return errs.length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
if (isEditing && editingProcess) {
|
||||
updateProcess(editingProcess.id, { name: name.trim(), arrivalTime, priority, color, bursts });
|
||||
} else {
|
||||
const p: Process = {
|
||||
id: generateId(),
|
||||
name: name.trim(),
|
||||
arrivalTime,
|
||||
priority,
|
||||
color,
|
||||
bursts,
|
||||
};
|
||||
addProcess(p);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const inputCls = 'w-full rounded-md bg-slate-700 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500';
|
||||
const labelCls = 'block text-xs text-slate-400 mb-1';
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/60 rounded-xl border border-slate-600 shadow-xl p-4 space-y-4" data-testid="process-form">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-200">
|
||||
{isEditing ? 'Editar Processo' : 'Novo Processo'}
|
||||
</h3>
|
||||
<IconButton size="sm" variant="ghost" label="Fechar" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 flex gap-3">
|
||||
{/* Name */}
|
||||
<div className="flex-1">
|
||||
<label className={labelCls} htmlFor="pf-name">Nome</label>
|
||||
<input
|
||||
id="pf-name"
|
||||
type="text"
|
||||
maxLength={8}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="P1"
|
||||
className={inputCls}
|
||||
aria-label="Nome do processo"
|
||||
/>
|
||||
</div>
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="pf-color">Cor</label>
|
||||
<input
|
||||
id="pf-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={e => setColor(e.target.value)}
|
||||
className="h-[34px] w-12 rounded-md border border-slate-600 bg-slate-700 cursor-pointer"
|
||||
aria-label="Cor do processo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrival */}
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="pf-arrival">Arrival Time</label>
|
||||
<input
|
||||
id="pf-arrival"
|
||||
type="number"
|
||||
min={0}
|
||||
value={arrivalTime}
|
||||
onChange={e => setArrivalTime(Number(e.target.value))}
|
||||
className={inputCls}
|
||||
aria-label="Tempo de chegada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="pf-priority">Priority (1–10)</label>
|
||||
<input
|
||||
id="pf-priority"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={priority}
|
||||
onChange={e => setPriority(Number(e.target.value))}
|
||||
className={inputCls}
|
||||
aria-label="Prioridade do processo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bursts editor */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs text-slate-400 font-medium">Bursts (CPU → I/O → CPU…)</label>
|
||||
<IconButton size="sm" variant="ghost" label="Adicionar burst" onClick={addBurst}>
|
||||
<Plus size={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="space-y-1.5" data-testid="burst-list">
|
||||
{bursts.map((burst, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-md border ${
|
||||
burst.type === 'cpu'
|
||||
? 'border-emerald-700/50 bg-emerald-900/20'
|
||||
: 'border-cyan-700/50 bg-cyan-900/20'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[10px] font-bold uppercase w-7 tracking-wider ${burst.type === 'cpu' ? 'text-emerald-400' : 'text-cyan-400'}`}>
|
||||
{burst.type}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={burst.duration}
|
||||
onChange={e => updateBurstDuration(idx, Number(e.target.value))}
|
||||
className="w-full rounded bg-slate-700/60 border border-slate-600/50 text-slate-200 text-xs px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
aria-label={`Duração do burst ${idx + 1}`}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500">ticks</span>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label={`Remover burst ${idx + 1}`}
|
||||
onClick={() => removeBurst(idx)}
|
||||
disabled={bursts.length <= 1}
|
||||
className="hover:text-red-400"
|
||||
>
|
||||
<Minus size={12} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation errors */}
|
||||
{errors.length > 0 && (
|
||||
<ul className="space-y-0.5" role="alert" aria-live="polite">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i} className="text-[11px] text-red-400">{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium py-1.5 rounded-lg transition-colors"
|
||||
aria-label={isEditing ? 'Salvar processo' : 'Criar processo'}
|
||||
>
|
||||
<Check size={15} />
|
||||
{isEditing ? 'Salvar' : 'Criar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm rounded-lg transition-colors"
|
||||
aria-label="Cancelar"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessForm;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Plus, Shuffle, Trash2 } from 'lucide-react';
|
||||
import type { Process } from '@core/types';
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
import ProcessCard from './ProcessCard';
|
||||
import IconButton from '../shared/IconButton';
|
||||
import { generateId, nextColor } from '../shared/processUtils';
|
||||
|
||||
interface ProcessListProps {
|
||||
onEditProcess: (process: Process) => void;
|
||||
onAddNew: () => void;
|
||||
}
|
||||
|
||||
const MAX_PROCESSES = 16;
|
||||
|
||||
/** Generates a random process for quick testing */
|
||||
function makeRandomProcess(index: number): Process {
|
||||
const cpuDur = Math.floor(Math.random() * 6) + 1;
|
||||
const ioDur = Math.floor(Math.random() * 5) + 1;
|
||||
const hasBothBursts = Math.random() > 0.4;
|
||||
return {
|
||||
id: generateId(),
|
||||
name: `P${index}`,
|
||||
arrivalTime: Math.floor(Math.random() * 8),
|
||||
priority: Math.floor(Math.random() * 10) + 1,
|
||||
color: nextColor(),
|
||||
bursts: hasBothBursts
|
||||
? [{ type: 'cpu', duration: cpuDur }, { type: 'io', duration: ioDur }, { type: 'cpu', duration: Math.floor(Math.random() * 4) + 1 }]
|
||||
: [{ type: 'cpu', duration: cpuDur }],
|
||||
};
|
||||
}
|
||||
|
||||
const ProcessList = ({ onEditProcess, onAddNew }: ProcessListProps) => {
|
||||
const processes = useProcessStore(s => s.processes);
|
||||
const removeProcess = useProcessStore(s => s.removeProcess);
|
||||
const clearProcesses = useProcessStore(s => s.clearProcesses);
|
||||
const addProcess = useProcessStore(s => s.addProcess);
|
||||
|
||||
const canAdd = processes.length < MAX_PROCESSES;
|
||||
|
||||
const handleRandomize = () => {
|
||||
const p = makeRandomProcess(processes.length + 1);
|
||||
addProcess(p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="process-list">
|
||||
{/* List header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-slate-400">
|
||||
{processes.length}/{MAX_PROCESSES} processos
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Gerar processo aleatório"
|
||||
onClick={handleRandomize}
|
||||
disabled={!canAdd}
|
||||
>
|
||||
<Shuffle size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Limpar todos os processos"
|
||||
onClick={clearProcesses}
|
||||
disabled={processes.length === 0}
|
||||
className="hover:text-red-400 hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-0.5 scrollbar-hide">
|
||||
{processes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-slate-500 text-sm py-8 border border-dashed border-slate-700 rounded-lg">
|
||||
<span>Nenhum processo adicionado</span>
|
||||
<span className="text-xs">Clique em + para criar</span>
|
||||
</div>
|
||||
) : (
|
||||
processes.map(p => (
|
||||
<ProcessCard
|
||||
key={p.id}
|
||||
process={p}
|
||||
onEdit={onEditProcess}
|
||||
onRemove={removeProcess}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
{canAdd && (
|
||||
<button
|
||||
onClick={onAddNew}
|
||||
aria-label="Adicionar novo processo"
|
||||
className="mt-2 w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed border-slate-600 text-slate-500 hover:border-blue-500 hover:text-blue-400 text-xs transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Adicionar Processo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessList;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import type { Process } from '@core/types';
|
||||
import ProcessList from './ProcessList';
|
||||
import ProcessForm from './ProcessForm';
|
||||
|
||||
const ProcessPanel = () => {
|
||||
const [editingProcess, setEditingProcess] = useState<Process | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const handleEditProcess = (process: Process) => {
|
||||
setEditingProcess(process);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditingProcess(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowForm(false);
|
||||
setEditingProcess(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Painel de Processos">
|
||||
<div className="flex items-center gap-2 mb-3 text-slate-300">
|
||||
<ListTree size={18} className="text-blue-400" />
|
||||
<h2 className="font-semibold uppercase text-xs tracking-wider">Processos</h2>
|
||||
</div>
|
||||
|
||||
{showForm ? (
|
||||
<ProcessForm editingProcess={editingProcess} onClose={handleClose} />
|
||||
) : (
|
||||
<ProcessList onEditProcess={handleEditProcess} onAddNew={handleAddNew} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessPanel;
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SchedulingAlgorithm } from '@core/types';
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
|
||||
const ALGORITHM_OPTIONS: { value: SchedulingAlgorithm; label: string }[] = [
|
||||
{ value: 'FCFS', label: 'FCFS — First Come First Served' },
|
||||
{ value: 'SJF_NP', label: 'SJF — Shortest Job First (NP)' },
|
||||
{ value: 'SJF_P', label: 'SRTF — Shortest Remaining Time (P)' },
|
||||
{ value: 'RR', label: 'RR — Round Robin' },
|
||||
{ value: 'PRIORITY_NP',label: 'Priority (Não-preemptivo)' },
|
||||
{ value: 'PRIORITY_P', label: 'Priority (Preemptivo)' },
|
||||
{ value: 'HRRN', label: 'HRRN — Highest Response Ratio Next' },
|
||||
{ value: 'MULTILEVEL', label: 'Multilevel Queue' },
|
||||
];
|
||||
|
||||
const AlgorithmSelector = () => {
|
||||
const algorithm = useProcessStore(s => s.config.algorithm);
|
||||
const setConfig = useProcessStore(s => s.setConfig);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="algorithm-select"
|
||||
className="block text-xs text-slate-400 mb-1"
|
||||
>
|
||||
Algoritmo
|
||||
</label>
|
||||
<select
|
||||
id="algorithm-select"
|
||||
value={algorithm}
|
||||
onChange={e => setConfig({ algorithm: e.target.value as SchedulingAlgorithm })}
|
||||
className="w-full rounded-md bg-slate-700 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||
aria-label="Selecionar algoritmo de escalonamento"
|
||||
>
|
||||
{ALGORITHM_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlgorithmSelector;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
import type { SchedulingAlgorithm } from '@core/types';
|
||||
|
||||
// Algorithms that require a quantum parameter
|
||||
const QUANTUM_ALGORITHMS = new Set<SchedulingAlgorithm>(['RR', 'MULTILEVEL']);
|
||||
|
||||
// Algorithms where aging (anti-starvation) is applicable
|
||||
const AGING_ALGORITHMS = new Set<SchedulingAlgorithm>(['PRIORITY_NP', 'PRIORITY_P']);
|
||||
|
||||
// Algorithms that are preemptive by definition (used for read-only info only)
|
||||
const PREEMPTIVE_BY_DESIGN = new Set<SchedulingAlgorithm>(['SJF_P', 'RR', 'PRIORITY_P', 'MULTILEVEL']);
|
||||
|
||||
const inputCls = 'w-full rounded-md bg-slate-700 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500';
|
||||
const labelCls = 'block text-xs text-slate-400 mb-1';
|
||||
|
||||
const ToggleRow = ({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={id} className="text-xs text-slate-300 cursor-pointer">{label}</label>
|
||||
<button
|
||||
id={id}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
checked ? 'bg-blue-600' : 'bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform duration-200 ${
|
||||
checked ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SchedulerConfig = () => {
|
||||
const config = useProcessStore(s => s.config);
|
||||
const setConfig = useProcessStore(s => s.setConfig);
|
||||
|
||||
const algo = config.algorithm;
|
||||
const showQuantum = QUANTUM_ALGORITHMS.has(algo);
|
||||
const showAging = AGING_ALGORITHMS.has(algo);
|
||||
const isPreemptive = PREEMPTIVE_BY_DESIGN.has(algo);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="scheduler-config">
|
||||
|
||||
{/* Read-only preemptive indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">Tipo de escalonamento</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
|
||||
isPreemptive
|
||||
? 'bg-amber-500/15 text-amber-400 border-amber-500/30'
|
||||
: 'bg-slate-700 text-slate-400 border-slate-600'
|
||||
}`}
|
||||
data-testid="preemptive-badge"
|
||||
>
|
||||
{isPreemptive ? 'Preemptivo' : 'Não-preemptivo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Context Switch */}
|
||||
<div>
|
||||
<label htmlFor="ctx-switch" className={labelCls}>
|
||||
Context Switch (0–10 ticks)
|
||||
</label>
|
||||
<input
|
||||
id="ctx-switch"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={config.contextSwitchTime}
|
||||
onChange={e => setConfig({ contextSwitchTime: Math.min(10, Math.max(0, Number(e.target.value))) })}
|
||||
className={inputCls}
|
||||
aria-label="Tempo de context switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quantum — RR and MULTILEVEL */}
|
||||
{showQuantum && (
|
||||
<div data-testid="quantum-field">
|
||||
<label htmlFor="quantum" className={labelCls}>
|
||||
Quantum (ticks)
|
||||
</label>
|
||||
<input
|
||||
id="quantum"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.quantum}
|
||||
onChange={e => setConfig({ quantum: Math.max(1, Number(e.target.value)) })}
|
||||
className={inputCls}
|
||||
aria-label="Quantum do Round Robin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aging — Priority only */}
|
||||
{showAging && (
|
||||
<div className="space-y-2 pt-0.5">
|
||||
<ToggleRow
|
||||
id="aging-toggle"
|
||||
label="Aging (anti-starvation)"
|
||||
checked={config.agingEnabled}
|
||||
onChange={v => setConfig({ agingEnabled: v })}
|
||||
/>
|
||||
{config.agingEnabled && (
|
||||
<div data-testid="aging-interval-field">
|
||||
<label htmlFor="aging-interval" className={labelCls}>
|
||||
Intervalo de Aging (ticks)
|
||||
</label>
|
||||
<input
|
||||
id="aging-interval"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.agingInterval}
|
||||
onChange={e => setConfig({ agingInterval: Math.max(1, Number(e.target.value)) })}
|
||||
className={inputCls}
|
||||
aria-label="Intervalo de aging"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import AlgorithmSelector from './AlgorithmSelector';
|
||||
import SchedulerConfig from './SchedulerConfig';
|
||||
|
||||
const SchedulerPanel = () => {
|
||||
return (
|
||||
<section aria-label="Painel do Escalonador" className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<Settings size={18} className="text-emerald-400" />
|
||||
<h2 className="font-semibold uppercase text-xs tracking-wider">Escalonador</h2>
|
||||
</div>
|
||||
|
||||
<AlgorithmSelector />
|
||||
<SchedulerConfig />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerPanel;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ControlBar from '../ControlBar/ControlBar';
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
import { useSimulationStore } from '../../store/simulationStore';
|
||||
import { useUiStore } from '../../store/uiStore';
|
||||
import type { Process } from '@core/types';
|
||||
|
||||
const MOCK_PROCESS: Process = {
|
||||
id: 'p1',
|
||||
name: 'P1',
|
||||
arrivalTime: 0,
|
||||
priority: 5,
|
||||
color: '#60a5fa',
|
||||
bursts: [{ type: 'cpu', duration: 4 }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useProcessStore.setState({ processes: [], config: useProcessStore.getState().config });
|
||||
useSimulationStore.setState({ status: 'idle', ticks: [], engine: null, _processes: [], _config: null });
|
||||
useUiStore.setState({ speed: 1 });
|
||||
});
|
||||
|
||||
describe('ControlBar', () => {
|
||||
it('renders the toolbar with all main controls', () => {
|
||||
render(<ControlBar />);
|
||||
expect(screen.getByRole('toolbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('btn-play')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('btn-step')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('btn-reset')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tick-display')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('speed-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays tick 0000 on initial render', () => {
|
||||
render(<ControlBar />);
|
||||
expect(screen.getByTestId('tick-display')).toHaveTextContent('0000');
|
||||
});
|
||||
|
||||
it('shows "Pronto" status when idle', () => {
|
||||
render(<ControlBar />);
|
||||
expect(screen.getByText('Pronto')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Play button is disabled when no processes are loaded', () => {
|
||||
render(<ControlBar />);
|
||||
expect(screen.getByTestId('btn-play')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Step button initialises simulation and advances tick', async () => {
|
||||
const user = userEvent.setup();
|
||||
useProcessStore.setState({ processes: [MOCK_PROCESS] });
|
||||
render(<ControlBar />);
|
||||
const stepBtn = screen.getByTestId('btn-step');
|
||||
await user.click(stepBtn);
|
||||
expect(screen.getByTestId('tick-display')).toHaveTextContent('0001');
|
||||
});
|
||||
|
||||
it('Play button triggers init and sets status to running', async () => {
|
||||
const user = userEvent.setup();
|
||||
useProcessStore.setState({ processes: [MOCK_PROCESS] });
|
||||
render(<ControlBar />);
|
||||
await user.click(screen.getByTestId('btn-play'));
|
||||
expect(useSimulationStore.getState().status).toBe('running');
|
||||
});
|
||||
|
||||
it('Pause button is shown (and plays) when simulation is running', async () => {
|
||||
const user = userEvent.setup();
|
||||
useProcessStore.setState({ processes: [MOCK_PROCESS] });
|
||||
render(<ControlBar />);
|
||||
await user.click(screen.getByTestId('btn-play'));
|
||||
expect(screen.getByTestId('btn-pause')).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('btn-pause'));
|
||||
expect(useSimulationStore.getState().status).toBe('paused');
|
||||
});
|
||||
|
||||
it('changing speed updates uiStore', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlBar />);
|
||||
const speedSelect = screen.getByTestId('speed-select');
|
||||
await user.selectOptions(speedSelect, '2');
|
||||
expect(useUiStore.getState().speed).toBe(2);
|
||||
});
|
||||
|
||||
it('Reset is disabled when status is idle', () => {
|
||||
render(<ControlBar />);
|
||||
expect(screen.getByTestId('btn-reset')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Reset becomes enabled after simulation starts and returns tick to 0', async () => {
|
||||
const user = userEvent.setup();
|
||||
useProcessStore.setState({ processes: [MOCK_PROCESS] });
|
||||
render(<ControlBar />);
|
||||
await user.click(screen.getByTestId('btn-step'));
|
||||
expect(screen.getByTestId('tick-display')).toHaveTextContent('0001');
|
||||
await user.click(screen.getByTestId('btn-reset'));
|
||||
expect(screen.getByTestId('tick-display')).toHaveTextContent('0000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ProcessForm from '../ProcessPanel/ProcessForm';
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
|
||||
// Reset Zustand processStore between tests
|
||||
beforeEach(() => {
|
||||
useProcessStore.setState({ processes: [], config: useProcessStore.getState().config });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ProcessForm', () => {
|
||||
it('renders the "Novo Processo" form with default fields', () => {
|
||||
render(<ProcessForm onClose={() => {}} />);
|
||||
expect(screen.getByTestId('process-form')).toBeInTheDocument();
|
||||
expect(screen.getByText('Novo Processo')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Nome do processo')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Tempo de chegada')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Prioridade do processo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Editar Processo" title when editingProcess is provided', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'P1', arrivalTime: 0, priority: 5, color: '#fff',
|
||||
bursts: [{ type: 'cpu' as const, duration: 4 }],
|
||||
};
|
||||
render(<ProcessForm editingProcess={proc} onClose={() => {}} />);
|
||||
expect(screen.getByText('Editar Processo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error when name is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProcessForm onClose={() => {}} />);
|
||||
const nameInput = screen.getByLabelText('Nome do processo');
|
||||
await user.clear(nameInput);
|
||||
const submitBtn = screen.getByRole('button', { name: /Criar/i });
|
||||
await user.click(submitBtn);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Nome é obrigatório/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error when arrival time is negative', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProcessForm onClose={() => {}} />);
|
||||
const nameInput = screen.getByLabelText('Nome do processo');
|
||||
await user.type(nameInput, 'P1');
|
||||
const arrivalInput = screen.getByLabelText('Tempo de chegada');
|
||||
// fireEvent.change used to reliably set a negative number bypassing HTML min attribute
|
||||
fireEvent.change(arrivalInput, { target: { value: '-1' } });
|
||||
const submitBtn = screen.getByRole('button', { name: /Criar/i });
|
||||
await user.click(submitBtn);
|
||||
expect(screen.getByText(/Arrival Time deve ser ≥ 0/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error for name longer than 8 chars', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProcessForm onClose={() => {}} />);
|
||||
const nameInput = screen.getByLabelText('Nome do processo');
|
||||
// Bypass the HTML maxLength attribute via fireEvent to test the JS validation
|
||||
fireEvent.change(nameInput, { target: { value: 'ProcessXXLong' } });
|
||||
await user.click(screen.getByRole('button', { name: /Criar/i }));
|
||||
expect(screen.getByText(/Nome deve ter no máximo 8 caracteres/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a new burst with alternating type when clicking + button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProcessForm onClose={() => {}} />);
|
||||
const addBurstBtn = screen.getByLabelText('Adicionar burst');
|
||||
await user.click(addBurstBtn);
|
||||
// After one CPU burst at start, clicking + adds IO burst
|
||||
// getAllByText with exact matches for the type labels
|
||||
const cpuLabels = screen.getAllByText((_, el) => el?.tagName === 'SPAN' && el.textContent?.trim().toUpperCase() === 'CPU');
|
||||
const ioLabels = screen.getAllByText((_, el) => el?.tagName === 'SPAN' && el.textContent?.trim().toUpperCase() === 'IO');
|
||||
expect(cpuLabels.length).toBeGreaterThanOrEqual(1);
|
||||
expect(ioLabels.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('calls onClose after successful form submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<ProcessForm onClose={onClose} />);
|
||||
const nameInput = screen.getByLabelText('Nome do processo');
|
||||
await user.type(nameInput, 'P1');
|
||||
const submitBtn = screen.getByRole('button', { name: /Criar/i });
|
||||
await user.click(submitBtn);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('adds the process to the store after successful submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProcessForm onClose={() => {}} />);
|
||||
const nameInput = screen.getByLabelText('Nome do processo');
|
||||
await user.type(nameInput, 'Test1');
|
||||
await user.click(screen.getByRole('button', { name: /Criar/i }));
|
||||
const processes = useProcessStore.getState().processes;
|
||||
expect(processes).toHaveLength(1);
|
||||
expect(processes[0]?.name).toBe('Test1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SchedulerConfig from '../SchedulerPanel/SchedulerConfig';
|
||||
import { useProcessStore } from '../../store/processStore';
|
||||
import type { SchedulingAlgorithm } from '@core/types';
|
||||
|
||||
const setAlgorithm = (algo: SchedulingAlgorithm) => {
|
||||
useProcessStore.setState(s => ({ config: { ...s.config, algorithm: algo } }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useProcessStore.setState(s => ({
|
||||
config: {
|
||||
...s.config,
|
||||
algorithm: 'FCFS',
|
||||
quantum: 2,
|
||||
contextSwitchTime: 0,
|
||||
isPreemptive: false,
|
||||
agingEnabled: false,
|
||||
agingInterval: 5,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('SchedulerConfig', () => {
|
||||
it('always shows Context Switch field regardless of algorithm', () => {
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByLabelText('Tempo de context switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('always shows read-only preemptive badge', () => {
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByTestId('preemptive-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('badge shows "Não-preemptivo" for FCFS', () => {
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByTestId('preemptive-badge')).toHaveTextContent('Não-preemptivo');
|
||||
});
|
||||
|
||||
it('badge shows "Preemptivo" for SJF_P (SRTF)', () => {
|
||||
setAlgorithm('SJF_P');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByTestId('preemptive-badge')).toHaveTextContent('Preemptivo');
|
||||
});
|
||||
|
||||
it('badge shows "Não-preemptivo" for SJF_NP (preemptive toggle no longer exists)', () => {
|
||||
setAlgorithm('SJF_NP');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByTestId('preemptive-badge')).toHaveTextContent('Não-preemptivo');
|
||||
// Confirm the old interactive preemptive switch is gone
|
||||
expect(screen.queryByRole('switch', { name: /Preemptivo/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT show Quantum field for FCFS', () => {
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.queryByTestId('quantum-field')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Quantum field when algorithm is RR', () => {
|
||||
setAlgorithm('RR');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByTestId('quantum-field')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Quantum do Round Robin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Quantum field when algorithm is MULTILEVEL', () => {
|
||||
setAlgorithm('MULTILEVEL');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByTestId('quantum-field')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT show Quantum field for SJF_NP', () => {
|
||||
setAlgorithm('SJF_NP');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.queryByTestId('quantum-field')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Aging toggle for PRIORITY_NP', () => {
|
||||
setAlgorithm('PRIORITY_NP');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByRole('switch', { name: /Aging/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Aging toggle for PRIORITY_P', () => {
|
||||
setAlgorithm('PRIORITY_P');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.getByRole('switch', { name: /Aging/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Aging interval field only when aging is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
setAlgorithm('PRIORITY_NP');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.queryByTestId('aging-interval-field')).not.toBeInTheDocument();
|
||||
const agingToggle = screen.getByRole('switch', { name: /Aging/i });
|
||||
await user.click(agingToggle);
|
||||
expect(screen.getByTestId('aging-interval-field')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Intervalo de aging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the store when Quantum field is changed', () => {
|
||||
setAlgorithm('RR');
|
||||
render(<SchedulerConfig />);
|
||||
const quantumInput = screen.getByLabelText('Quantum do Round Robin');
|
||||
fireEvent.change(quantumInput, { target: { value: '5' } });
|
||||
expect(useProcessStore.getState().config.quantum).toBe(5);
|
||||
});
|
||||
|
||||
it('does NOT show Aging toggle for FCFS', () => {
|
||||
setAlgorithm('FCFS');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.queryByRole('switch', { name: /Aging/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT show Aging toggle for HRRN', () => {
|
||||
setAlgorithm('HRRN');
|
||||
render(<SchedulerConfig />);
|
||||
expect(screen.queryByRole('switch', { name: /Aging/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type BadgeVariant = 'cpu' | 'io' | 'balanced' | 'slate';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
cpu: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
|
||||
io: 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30',
|
||||
balanced: 'bg-violet-500/20 text-violet-400 border border-violet-500/30',
|
||||
slate: 'bg-slate-700 text-slate-400 border border-slate-600',
|
||||
};
|
||||
|
||||
const Badge = ({ variant = 'slate', children, className }: BadgeProps) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'primary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md';
|
||||
label: string;
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-slate-700 hover:bg-slate-600 text-slate-200 border-slate-600',
|
||||
primary: 'bg-blue-600 hover:bg-blue-500 text-white border-blue-500',
|
||||
danger: 'bg-red-600/80 hover:bg-red-500 text-white border-red-500',
|
||||
ghost: 'bg-transparent hover:bg-slate-700 text-slate-400 hover:text-slate-200 border-transparent',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-7 w-7 text-sm',
|
||||
md: 'h-9 w-9 text-base',
|
||||
};
|
||||
|
||||
const IconButton = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
label,
|
||||
className,
|
||||
disabled,
|
||||
...rest
|
||||
}: IconButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-lg border transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400',
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { BurstSegment } from '@core/types';
|
||||
|
||||
/**
|
||||
* Classifies a process as CPU Bound, I/O Bound, or Balanced
|
||||
* based on declared burst segments (used for preview in ProcessCard).
|
||||
*/
|
||||
export function classifyBound(bursts: BurstSegment[]): 'CPU Bound' | 'I/O Bound' | 'Balanced' {
|
||||
const cpuTotal = bursts.filter(b => b.type === 'cpu').reduce((a, b) => a + b.duration, 0);
|
||||
const ioTotal = bursts.filter(b => b.type === 'io').reduce((a, b) => a + b.duration, 0);
|
||||
const total = cpuTotal + ioTotal;
|
||||
if (total === 0) return 'Balanced';
|
||||
const cpuRatio = cpuTotal / total;
|
||||
const ioRatio = ioTotal / total;
|
||||
if (cpuRatio >= 0.65) return 'CPU Bound';
|
||||
if (ioRatio >= 0.65) return 'I/O Bound';
|
||||
return 'Balanced';
|
||||
}
|
||||
|
||||
/** Generate a short unique id */
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).slice(2, 9);
|
||||
}
|
||||
|
||||
/** Random vivid HEX color */
|
||||
const PALETTE = [
|
||||
'#60a5fa','#34d399','#f472b6','#fb923c','#a78bfa',
|
||||
'#38bdf8','#facc15','#4ade80','#f87171','#c084fc',
|
||||
];
|
||||
let _paletteIndex = 0;
|
||||
export function nextColor(): string {
|
||||
return PALETTE[_paletteIndex++ % PALETTE.length];
|
||||
}
|
||||
Reference in new issue
Block a user