diff --git a/.gitignore b/.gitignore index 28cb4f5..4a7cd1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,43 +1,40 @@ -### IntelliJ IDEA ### -# Игнорируем всё содержимое .idea — это папка настроек среды разработки -# Оставляем только нужные файлы, чтобы IDE могла корректно открыть проект -.idea/ -!.idea/misc.xml # Уровень JDK, базовые настройки проекта -!.idea/modules.xml # Структура модулей (если нет Maven/Gradle) -!.idea/libraries/ # Подключённые библиотеки, например JUnit -!.idea/vcs.xml # Настройки системы контроля версий (Git) +# ===== Build output / compiled ===== +out/ +bin/ +build/ +target/ +*.class + +# ===== App data created by FileBackedTaskManager ===== +tasks.csv -# Игнорируем файлы модулей IntelliJ (.iml) — не нужны в репозитории +# ===== IntelliJ IDEA ===== +.idea/ +!.idea/misc.xml +!.idea/modules.xml +!.idea/vcs.xml +# !.idea/libraries/ # надо включать только осознанно *.iml -### Build output ### -# Исключаем папки, которые содержат скомпилированные классы и артефакты -out/ # Папка вывода сборки IntelliJ -bin/ # Папка вывода Eclipse/ручной сборки -target/ # Папка сборки Maven (если появится) +# ===== VS Code ===== +.vscode/ -### OS ### -# Системные файлы macOS, не должны попадать в репозиторий +# ===== OS junk ===== .DS_Store +Thumbs.db -### VS Code ### -# Конфигурации Visual Studio Code (если кто-то откроет проект там) -.vscode/ - -### Eclipse ### -# Игнорируем все файлы и папки, связанные с Eclipse IDE -.apt_generated # Автоматически сгенерированные исходники -.classpath # Файл конфигурации путей классов -.factorypath # Конфигурация аннотаций -.project # Основной файл проекта Eclipse -.settings # Папка с настройками проекта -.springBeans # Конфиги Spring Beans (если используется) -.sts4-cache # Кэш Spring Tool Suite (STS) +# ===== Eclipse ===== +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache -### NetBeans ### -# Всё, что создаёт NetBeans IDE -nbproject/private/ # Личные настройки проекта -nbbuild/ # Папка сборки NetBeans -dist/ # Артефакты сборки (JAR и т.д.) -nbdist/ # Расширенная папка вывода -.nb-gradle/ # Кэш Gradle от NetBeans +# ===== NetBeans ===== +nbproject/private/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 7bc07ec..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Environment-dependent path to Maven home directory -/mavenHomeManager.xml -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/libraries/junit_jupiter.xml b/.idea/libraries/junit_jupiter.xml deleted file mode 100644 index 0725eb8..0000000 --- a/.idea/libraries/junit_jupiter.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 89ee753..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e54d3ba..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/java-sprint4-hw.iml b/java-sprint4-hw.iml deleted file mode 100644 index 547dd47..0000000 --- a/java-sprint4-hw.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Main.java b/src/Main.java index 771028c..2da5e79 100644 --- a/src/Main.java +++ b/src/Main.java @@ -2,24 +2,38 @@ import manager.TaskManager; import model.*; - -// Демонстрация базовой работы с менеджером задач: -//добавление задач, получение и история просмотров. - +// Демонстрация базовой работы с менеджером задач public class Main { public static void main(String[] args) { TaskManager manager = Managers.getDefault(); // === Добавление задач === int id1 = manager.addNewTask(new Task("Задача 1", "Описание задачи", Status.NEW)); - int id2 = manager.addNewEpic(new Epic("Эпик 1", "Описание эпика")); - int id3 = manager.addNewSubtask(new Subtask("Подзадача 1", "Описание подзадачи", id2)); + int epicId = manager.addNewEpic(new Epic("Эпик 1", "Описание эпика")); + int subId = manager.addNewSubtask(new Subtask("Подзадача 1", "Описание подзадачи", epicId)); + manager.addNewSubtask(new Subtask("Подзадача 2", "Ещё одна", epicId)); + + // === Используем возвращаемые списки (убираем жёлтые лампы) TODO: это просто для себя подчеркиваю === + int tasksCount = manager.getTasks().size(); + int epicsCount = manager.getEpics().size(); + int subtasksCount = manager.getSubtasks().size(); + int epicSubCount = manager.getEpicSubtasks(epicId).size(); + System.out.printf( + "Всего: tasks=%d, epics=%d, subtasks=%d; у эпика %d подзадач=%d%n", + tasksCount, epicsCount, subtasksCount, epicId, epicSubCount + ); - // === Получение задач (для истории просмотров) === - manager.getTask(id1); - manager.getEpic(id2); - manager.getSubtask(id3); - manager.getTask(id1); // повторное обращение к задаче + // === Получение задач (для истории просмотров) — без пустых if === + boolean viewedTask1 = manager.getTask(id1) != null; + boolean viewedEpic = manager.getEpic(epicId) != null; + boolean viewedSub = manager.getSubtask(subId) != null; + boolean viewedTask2 = manager.getTask(id1) != null; + + // просто используем значения, чтобы инспекция была довольна + System.out.printf( + "Просмотры: t1=%b, epic=%b, sub=%b, t1-again=%b%n", + viewedTask1, viewedEpic, viewedSub, viewedTask2 + ); // === Вывод истории просмотров === System.out.println("=== История просмотров ==="); @@ -34,14 +48,16 @@ public static void main(String[] args) { } } - // понятный тип задачи private static String getTypeName(Task task) { - if (task instanceof Epic) return "Эпик"; - if (task instanceof Subtask) return "Подзадача"; + if (task instanceof Epic) { + return "Эпик"; + } + if (task instanceof Subtask) { + return "Подзадача"; + } return "Задача"; } - // Перевод статуса на русский private static String getStatusName(Status status) { return switch (status) { case NEW -> "Новая"; diff --git a/src/exceptions/TaskValidationException.java b/src/exceptions/TaskValidationException.java new file mode 100644 index 0000000..875d245 --- /dev/null +++ b/src/exceptions/TaskValidationException.java @@ -0,0 +1,8 @@ +package exceptions; + +/** + * NEW (sprint-8): бросается при пересечении задач по времени. + */ +public class TaskValidationException extends RuntimeException { + public TaskValidationException(String message) { super(message); } +} diff --git a/src/manager/FileBackedTaskManager.java b/src/manager/FileBackedTaskManager.java new file mode 100644 index 0000000..0197f69 --- /dev/null +++ b/src/manager/FileBackedTaskManager.java @@ -0,0 +1,248 @@ +package manager; + +import model.Epic; +import model.Status; +import model.Subtask; +import model.Task; +import model.TaskType; +import util.CsvUtils; // TODO(review): парсинг времени вынесен в утилиту + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; // NEW (sprint-8) +import java.time.LocalDateTime; // NEW (sprint-8) +import java.time.format.DateTimeFormatter; // NEW (sprint-8) +import java.util.ArrayList; +import java.util.List; + +/** + * Менеджер с сохранением состояния в файл (CSV). + * CHANGED (sprint-8): + * - расширен CSV-формат: добавлены колонки durationMinutes и startTime; + * - добавлена обратная совместимость чтения старого формата; + * - на restore используем put*PreserveId + setNextIdAfterRestore. + */ +public class FileBackedTaskManager extends InMemoryTaskManager { + + private final File file; + + // Единый формат для CSV + private static final DateTimeFormatter CSV_TIME_FMT = Task.CSV_TIME_FMT; + + /* ───────────── фабрика ───────────── */ + + public FileBackedTaskManager(File file) { + this.file = file; + } + + @SuppressWarnings("unused") + public static FileBackedTaskManager loadFromFile(File file) { + FileBackedTaskManager manager = new FileBackedTaskManager(file); + manager.restore(); + return manager; + } + + /* ───────────── сохранение ───────────── */ + + /** Сохраняет все задачи в CSV: id,type,name,status,description,durationMinutes,startTime,epic */ + private void save() { + try (BufferedWriter writer = + Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8)) { + writer.write("id,type,name,status,description,durationMinutes,startTime,epic"); + writer.newLine(); + + // Порядок не критичен, но читается приятнее + for (Task task : getTasks()) { + writer.write(task.toCsvRow()); + writer.newLine(); + } + for (Epic epic : getEpics()) { + writer.write(epic.toCsvRow()); + writer.newLine(); + } + for (Subtask subtask : getSubtasks()) { + writer.write(subtask.toCsvRow()); + writer.newLine(); + } + + } catch (IOException ex) { + throw new ManagerSaveException("Не удалось сохранить файл", ex); + } + } + + /* ───────────── восстановление ───────────── */ + + /** Читает CSV и восстанавливает состояние. */ + private void restore() { + if (!file.exists()) { + return; + } + + List epics = new ArrayList<>(); + List tasks = new ArrayList<>(); + List subtasks = new ArrayList<>(); + + int maxId = 0; // TODO(review): считаем maxId за один проход при чтении файла + + try (BufferedReader reader = + Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) { + String header = reader.readLine(); // заголовок + if (header == null) { + return; + } + + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + + Task task = fromCsv(line); + maxId = Math.max(maxId, task.getId()); // TODO(review): обновляем maxId на лету + + if (task instanceof Epic epic) { + epics.add(epic); + } else if (task instanceof Subtask subtask) { + subtasks.add(subtask); + } else if (task.getType() == TaskType.TASK) { // базовая Task + tasks.add(task); + } else { + throw new IllegalStateException( + "Неизвестный подкласс задачи (id=" + task.getId() + "): " + + task.getClass().getName()); + } + } + } catch (IOException ex) { + throw new ManagerSaveException("Не удалось прочитать файл", ex); + } + + // Важно: сначала эпики, затем задачи, затем подзадачи + for (Epic epic : epics) { + super.putEpicPreserveId(epic); + } + for (Task task : tasks) { + super.putTaskPreserveId(task); + } + for (Subtask subtask : subtasks) { + super.putSubtaskPreserveId(subtask); + } + + // TODO(review): без дополнительных циклов — используем maxId, посчитанный при чтении + super.setNextIdAfterRestore(maxId + 1); + } + + /* ───────────── CSV утилиты ───────────── */ + + private static Task fromCsv(String csv) { + String[] taskParts = csv.split(",", -1); // TODO(review): не используем односимвольные имена + // Старый формат Sprint 7: id,type,name,status,description,epic (6 полей, epic только у subtask) + // Новый формат Sprint 8: id,type,name,status,description,durationMinutes,startTime,epic (8 полей) + if (taskParts.length != 6 && taskParts.length != 8) { + throw new ManagerSaveException("Некорректная строка CSV: " + csv); + } + + int id = Integer.parseInt(taskParts[0]); + TaskType type = TaskType.valueOf(taskParts[1]); + String name = taskParts[2]; + Status status = Status.valueOf(taskParts[3]); + String description = taskParts[4]; + + String durStr = taskParts.length == 8 ? taskParts[5] : ""; + String startStr = taskParts.length == 8 ? taskParts[6] : ""; + // после проверки выше длина может быть только 6 или 8 + String epicStr = taskParts.length == 8 ? taskParts[7] : taskParts[5]; + + Duration duration = durStr.isBlank() ? null : Duration.ofMinutes(Long.parseLong(durStr)); + LocalDateTime startTime = CsvUtils.parseTimeOrNull(startStr, CSV_TIME_FMT); // TODO(review): парсинг времени из утилиты + + switch (type) { + case TASK: { + Task task = new Task(name, description, status); + task.setId(id); + task.setDuration(duration); + task.setStartTime(startTime); + return task; + } + case EPIC: { + Epic epic = new Epic(name, description); + epic.setId(id); + epic.setStatus(status); + // duration/start/end будут пересчитаны после загрузки subtask + return epic; + } + case SUBTASK: { + int epicId = (epicStr == null || epicStr.isBlank()) ? 0 : Integer.parseInt(epicStr); + Subtask subtask = new Subtask(name, description, status, epicId); + subtask.setId(id); + subtask.setDuration(duration); + subtask.setStartTime(startTime); + return subtask; + } + default: + throw new IllegalStateException("Неизвестный тип: " + type); + } + } + + /* ───────────── переопределения с автосохранением ───────────── */ + + @Override + public int addNewTask(Task task) { + int id = super.addNewTask(task); + save(); + return id; + } + + @Override + public int addNewEpic(Epic epic) { + int id = super.addNewEpic(epic); + save(); + return id; + } + + @Override + public int addNewSubtask(Subtask subtask) { + int id = super.addNewSubtask(subtask); + save(); + return id; + } + + @Override + public void updateTask(Task task) { + super.updateTask(task); + save(); + } + + @Override + public void updateEpic(Epic epic) { + super.updateEpic(epic); + save(); + } + + @Override + public void updateSubtask(Subtask subtask) { + super.updateSubtask(subtask); + save(); + } + + @Override + public void removeTask(int id) { + super.removeTask(id); + save(); + } + + @Override + public void removeEpic(int id) { + super.removeEpic(id); + save(); + } + + @Override + public void removeSubtask(int id) { + super.removeSubtask(id); + save(); + } +} diff --git a/src/manager/HistoryManager.java b/src/manager/HistoryManager.java index 084b9c5..9b2421b 100644 --- a/src/manager/HistoryManager.java +++ b/src/manager/HistoryManager.java @@ -1,9 +1,14 @@ package manager; -//Интерфейс менеджера истории просмотров задач. -import model.Task; + import java.util.List; +import model.Task; +/** Интерфейс менеджера истории просмотров задач. */ public interface HistoryManager { + void add(Task task); + + void remove(int id); + List getHistory(); -} \ No newline at end of file +} diff --git a/src/manager/InMemoryHistoryManager.java b/src/manager/InMemoryHistoryManager.java index d45efee..9593b3f 100644 --- a/src/manager/InMemoryHistoryManager.java +++ b/src/manager/InMemoryHistoryManager.java @@ -1,23 +1,39 @@ package manager; import model.Task; -import java.util.*; -//Хранит максимум 10 последних задач. При превышении лимита - // самая старая задача удаляется. + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; + public class InMemoryHistoryManager implements HistoryManager { - private static final int MAX_HISTORY = 10; - private final Deque history = new ArrayDeque<>(); + + private static final int MAX = 10; + private final LinkedHashMap order = new LinkedHashMap<>(); @Override public void add(Task task) { - history.addLast(task); - if (history.size() > MAX_HISTORY) { - history.pollFirst(); + if (task == null) { + return; + } + int id = task.getId(); + // Дедупликация. + order.remove(id); + order.put(id, task); + // Ограничиваем размер. + while (order.size() > MAX) { + Integer firstKey = order.keySet().iterator().next(); + order.remove(firstKey); } } -//Возвращает список просмотренных задач (в порядке просмотра). + + @Override + public void remove(int id) { + order.remove(id); + } + @Override public List getHistory() { - return new ArrayList<>(history); + return new ArrayList<>(order.values()); } -} \ No newline at end of file +} diff --git a/src/manager/InMemoryTaskManager.java b/src/manager/InMemoryTaskManager.java index b299777..8afccf2 100644 --- a/src/manager/InMemoryTaskManager.java +++ b/src/manager/InMemoryTaskManager.java @@ -1,35 +1,76 @@ package manager; +import exceptions.TaskValidationException; // NEW (sprint-8) import model.*; + +import java.time.LocalDateTime; // NEW (sprint-8) import java.util.*; +import java.util.stream.Collectors; -// InMemoryTaskManager — реализация интерфейса TaskManager, -// хранящая задачи, эпики и подзадачи в оперативной памяти. -// Поддерживает создание, обновление, удаление и получение задач всех типов, -// а также отслеживает историю просмотров через HistoryManager. +/** + * InMemoryTaskManager хранит задачи в памяти и ведет историю. + * + *

CHANGED (sprint-8): + * - приоритизация через TreeSet (startTime); + * - проверка пересечений при add/update Task/Subtask; + * - эпики получают расчётные duration/start/end от подзадач; + * - добавлены protected put*-методы и setNextIdAfterRestore для FileBacked; + * - часть циклов переписана на stream API. + */ public class InMemoryTaskManager implements TaskManager { - private final Map tasks = new HashMap<>(); - private final Map epics = new HashMap<>(); - private final Map subtasks = new HashMap<>(); - private final HistoryManager historyManager = Managers.getDefaultHistory(); - private int nextId = 1; + + /* ---------- хранилища ---------- */ + protected final Map tasks = new HashMap<>(); + protected final Map epics = new HashMap<>(); + protected final Map subtasks = new HashMap<>(); + + /* ---------- история ---------- */ + private final HistoryManager historyManager = new InMemoryHistoryManager(); + + /* ---------- ID ---------- */ + protected int nextId = 1; private int generateId() { return nextId++; } + /* ---------- приоритизация (sprint-8) ---------- */ + // Задачи без startTime сюда не добавляем (по ТЗ). + private final Comparator PRIORITY_CMP = + Comparator.comparing(Task::getStartTime, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparingInt(Task::getId); + + private final NavigableSet prioritized = new TreeSet<>(PRIORITY_CMP); + + private void trackPrioritized(Task task) { + if (task != null && task.getStartTime() != null) { + prioritized.remove(task); + prioritized.add(task); + } else { + prioritized.remove(task); + } + } + + /* ---------- создание ---------- */ + @Override public int addNewTask(Task task) { - task.setId(generateId()); - tasks.put(task.getId(), task); - return task.getId(); + // NEW (sprint-8): валидация пересечений + validateNoOverlaps(task, null); + int id = generateId(); + task.setId(id); + tasks.put(id, task); + trackPrioritized(task); + return id; } @Override public int addNewEpic(Epic epic) { - epic.setId(generateId()); - epics.put(epic.getId(), epic); - return epic.getId(); + int id = generateId(); + epic.setId(id); + epics.put(id, epic); + // у эпика вычисляемые поля — посчитаются, когда появятся subtask + return id; } @Override @@ -38,81 +79,126 @@ public int addNewSubtask(Subtask subtask) { if (epic == null) { throw new IllegalArgumentException("Эпик не найден"); } + validateNoOverlaps(subtask, null); int id = generateId(); subtask.setId(id); subtasks.put(id, subtask); epic.addSubtaskId(id); + trackPrioritized(subtask); + recalcEpic(epic.getId()); return id; } + /* ---------- обновление ---------- */ + @Override public void updateTask(Task task) { - if (tasks.containsKey(task.getId())) { - tasks.put(task.getId(), task); + if (!tasks.containsKey(task.getId())) { + return; } + validateNoOverlaps(task, task.getId()); + tasks.put(task.getId(), task); + trackPrioritized(task); } @Override public void updateEpic(Epic epic) { - if (epics.containsKey(epic.getId())) { - epics.put(epic.getId(), epic); + if (!epics.containsKey(epic.getId())) { + return; } + // статус/время эпика пересчитывается от subtask — но позволим обновить title/description + Epic exist = epics.get(epic.getId()); + exist.setTitle(epic.getTitle()); + exist.setDescription(epic.getDescription()); + // статус руками не трогаем + recalcEpic(exist.getId()); } @Override public void updateSubtask(Subtask subtask) { - if (subtasks.containsKey(subtask.getId())) { - subtasks.put(subtask.getId(), subtask); + if (!subtasks.containsKey(subtask.getId())) { + return; } + validateNoOverlaps(subtask, subtask.getId()); + subtasks.put(subtask.getId(), subtask); + trackPrioritized(subtask); + recalcEpic(subtask.getEpicId()); } + /* ---------- удаление ---------- */ + @Override public void removeTask(int id) { - tasks.remove(id); + Task removed = tasks.remove(id); + if (removed != null) { + prioritized.remove(removed); + historyManager.remove(id); + } } @Override public void removeEpic(int id) { Epic epic = epics.remove(id); if (epic != null) { - for (int subId : epic.getSubtaskIds()) { - subtasks.remove(subId); + // удаляем все подзадачи эпика + for (int sid : epic.getSubtaskIds()) { + Subtask s = subtasks.remove(sid); + if (s != null) { + prioritized.remove(s); + historyManager.remove(sid); + } } + historyManager.remove(id); } } @Override public void removeSubtask(int id) { - Subtask subtask = subtasks.remove(id); - if (subtask != null) { - Epic epic = epics.get(subtask.getEpicId()); + Subtask s = subtasks.remove(id); + if (s != null) { + Epic epic = epics.get(s.getEpicId()); if (epic != null) { epic.getSubtaskIds().remove((Integer) id); } + prioritized.remove(s); + historyManager.remove(id); + if (epic != null) { + recalcEpic(epic.getId()); + } } } + /* ---------- получение + история ---------- */ + @Override public Task getTask(int id) { Task t = tasks.get(id); - if (t != null) historyManager.add(t); + if (t != null) { + historyManager.add(t); + } return t; } @Override public Epic getEpic(int id) { Epic e = epics.get(id); - if (e != null) historyManager.add(e); + if (e != null) { + historyManager.add(e); + } return e; } @Override public Subtask getSubtask(int id) { Subtask s = subtasks.get(id); - if (s != null) historyManager.add(s); + if (s != null) { + historyManager.add(s); + } return s; } + /* ---------- списки ---------- */ + @Override public List getTasks() { return new ArrayList<>(tasks.values()); @@ -130,19 +216,104 @@ public List getSubtasks() { @Override public List getEpicSubtasks(int epicId) { - List result = new ArrayList<>(); - Epic epic = epics.get(epicId); - if (epic != null) { - for (int id : epic.getSubtaskIds()) { - Subtask s = subtasks.get(id); - if (s != null) result.add(s); - } - } - return result; + // NEW (sprint-8): Stream API вместо временного списка + return epics.containsKey(epicId) + ? epics.get(epicId).getSubtaskIds().stream() + .map(subtasks::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()) + : List.of(); } @Override public List getHistory() { return historyManager.getHistory(); } + + /* ---------- prioritized (sprint-8) ---------- */ + + @Override + public List getPrioritizedTasks() { + // ожидается частый вызов → O(n) + return new ArrayList<>(prioritized); + } + + /* ---------- расчёт эпика (status/duration/start/end) ---------- */ + + private void recalcEpic(int epicId) { + Epic epic = epics.get(epicId); + if (epic == null) { + return; + } + + List subs = + epic.getSubtaskIds().stream().map(subtasks::get).filter(Objects::nonNull).toList(); + + // TODO(review sprint-8): пересчёт эпика вынесен в Epic.recalcFromSubtasks — один проход. + epic.recalcFromSubtasks(subs); + } + + /* ---------- пересечения (sprint-8) ---------- */ + + private void validateNoOverlaps(Task candidate, Integer selfId) { + // Не проверяем, если нет времени или длительности. + if (candidate.getStartTime() == null || candidate.getDuration() == null) { + return; + } + + boolean intersect = + prioritized.stream() + .filter(t -> selfId == null || t.getId() != selfId) + .anyMatch(t -> isOverlap(candidate, t)); + + if (intersect) { + throw new TaskValidationException("Задача пересекается по времени с другой"); + } + } + + // Пересечение отрезков: [A.start, A.end) и [B.start, B.end) + private static boolean isOverlap(Task a, Task b) { + LocalDateTime as = a.getStartTime(); + LocalDateTime bs = b.getStartTime(); + if (as == null || bs == null) { + return false; + } + var ad = a.getDuration(); + var bd = b.getDuration(); + if (ad == null || bd == null) { + return false; + } + LocalDateTime ae = a.getEndTime(); + LocalDateTime be = b.getEndTime(); + // пересечение при строгом наложении (границы, касающиеся впритык, допустимы) + return as.isBefore(be) && bs.isBefore(ae); + } + + /* ---------- поддержка FileBacked (preserve id / nextId) ---------- */ + + // Восстановление с сохранением id (используется FileBackedTaskManager.restore()) + protected void putTaskPreserveId(Task t) { + tasks.put(t.getId(), t); + trackPrioritized(t); + } + + protected void putEpicPreserveId(Epic e) { + epics.put(e.getId(), e); + // пересчёт сделаем после загрузки всех subtask + } + + protected void putSubtaskPreserveId(Subtask s) { + subtasks.put(s.getId(), s); + Epic epic = epics.get(s.getEpicId()); + if (epic != null) { + epic.addSubtaskId(s.getId()); + } + trackPrioritized(s); + } + + protected void setNextIdAfterRestore(int next) { + this.nextId = Math.max(this.nextId, next); + // После полного restore пересчитаем эпики: + epics.keySet().forEach(this::recalcEpic); + } } diff --git a/src/manager/ManagerSaveException.java b/src/manager/ManagerSaveException.java new file mode 100644 index 0000000..68d0e6e --- /dev/null +++ b/src/manager/ManagerSaveException.java @@ -0,0 +1,12 @@ +package manager; + +public class ManagerSaveException extends RuntimeException { + + public ManagerSaveException(String message) { + super(message); + } + + public ManagerSaveException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/manager/Managers.java b/src/manager/Managers.java index a42e7c7..d08561c 100644 --- a/src/manager/Managers.java +++ b/src/manager/Managers.java @@ -1,13 +1,16 @@ package manager; -//Упрощает инициализацию в точке входа (Main) и тестах. -//Используется для создания реализаций {TaskManager} и {HistoryManager}. -public class Managers { - //Возвращает реализацию менеджера задач, работающую в памяти. + +public final class Managers { + + private Managers() {} + public static TaskManager getDefault() { return new InMemoryTaskManager(); } -//Возвращает реализацию менеджера истории просмотров, работающую в памяти. + + // NEW для истории + @SuppressWarnings("unused") public static HistoryManager getDefaultHistory() { return new InMemoryHistoryManager(); } -} \ No newline at end of file +} diff --git a/src/manager/TaskManager.java b/src/manager/TaskManager.java index 6f31646..94bb687 100644 --- a/src/manager/TaskManager.java +++ b/src/manager/TaskManager.java @@ -1,35 +1,63 @@ package manager; -import model.*; +import model.Epic; +import model.Subtask; +import model.Task; + import java.util.List; -// Интерфейс менеджера задач. -// Определяет базовые методы для управления обычными задачами, эпиками и подзадачами. -// Также предоставляет методы для получения истории просмотров. + +/** + * Интерфейс менеджера задач. + * (из Sprint 7 + NEW методы Sprint 8) + */ public interface TaskManager { - //Создание задач всех типов + /* ===================== Создание ===================== */ int addNewTask(Task task); + int addNewEpic(Epic epic); + int addNewSubtask(Subtask subtask); - //Обновление задач всех типов + /* ===================== Обновление ===================== */ void updateTask(Task task); + void updateEpic(Epic epic); + void updateSubtask(Subtask subtask); - //Удаление задач всех типов + /* ===================== Удаление ===================== */ void removeTask(int id); + void removeEpic(int id); + void removeSubtask(int id); + /* ===================== Получение (одна) ===================== */ Task getTask(int id); + Epic getEpic(int id); + Subtask getSubtask(int id); + /* ===================== Получение (списки) ===================== */ List getTasks(); + List getEpics(); + List getSubtasks(); + List getEpicSubtasks(int epicId); + /* ===================== История ===================== */ List getHistory(); -} \ No newline at end of file + + /* ===================== Prioritized (sprint-8) ===================== */ + + /** + * NEW (sprint-8): задачи и подзадачи в порядке приоритета по startTime. + * Эпики не включаем (их время расчётное). + * Задачи без startTime не учитываются. + */ + List getPrioritizedTasks(); +} diff --git a/src/model/Epic.java b/src/model/Epic.java index 7ea6494..114e56d 100644 --- a/src/model/Epic.java +++ b/src/model/Epic.java @@ -1,29 +1,110 @@ package model; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -//Класс Epic представляет эпик — задачу, содержащую список подзадач. -// * Наследуется от Task и содержит список идентификаторов всех подзадач. +import util.CsvUtils; + +/** + * Эпик объединяет подзадачи. + */ public class Epic extends Task { + private final List subtaskIds = new ArrayList<>(); + private LocalDateTime endTime; public Epic(String title, String description) { - super(title, description, Status.NEW); + super(title, description); + } + + @Override + public TaskType getType() { + return TaskType.EPIC; } -//Защищает subtaskIds от внешнего изменения — инкапсуляция. -//Возвращает копию списка идентификаторов подзадач. - // * Это нужно для соблюдения принципа инкапсуляции — - // * чтобы внешний код не мог напрямую изменить внутренний список. + public List getSubtaskIds() { - return new ArrayList<>(subtaskIds); // ✅ ВОТ ТАК инкапсуляция соблюдена + return subtaskIds; } -//Добавляет идентификатор подзадачи к эпику. -//* Проверяет, что эпик не добавляет сам себя как подзадачу. public void addSubtaskId(int id) { - if (id == this.id) { - throw new IllegalArgumentException("Эпик не может быть собственным сабтаском"); - } subtaskIds.add(id); } -} \ No newline at end of file + + // TODO(review sprint-8): пересчёт status/duration/start/end за один проход по сабтаскам. + public void recalcFromSubtasks(List subs) { + if (subs == null || subs.isEmpty()) { + this.status = Status.NEW; + this.duration = null; + this.startTime = null; + this.endTime = null; + return; + } + + boolean allNew = true; + boolean allDone = true; + + long totalMinutes = 0L; + LocalDateTime minStart = null; + LocalDateTime maxEnd = null; + + for (Subtask s : subs) { + Status st = s.getStatus(); + if (st != Status.NEW) { + allNew = false; + } + if (st != Status.DONE) { + allDone = false; + } + + Duration d = s.getDuration(); + if (d != null) { + totalMinutes += d.toMinutes(); + } + + LocalDateTime stTime = s.getStartTime(); + if (stTime != null && (minStart == null || stTime.isBefore(minStart))) { + minStart = stTime; + } + LocalDateTime enTime = s.getEndTime(); + if (enTime != null && (maxEnd == null || enTime.isAfter(maxEnd))) { + maxEnd = enTime; + } + } + + if (allNew) { + this.status = Status.NEW; + } else if (allDone) { + this.status = Status.DONE; + } else { + this.status = Status.IN_PROGRESS; + } + + this.duration = (totalMinutes == 0) ? null : Duration.ofMinutes(totalMinutes); + this.startTime = minStart; + this.endTime = maxEnd; + } + + @Override + public LocalDateTime getEndTime() { + return endTime; + } + + // TODO(review sprint-8): CSV-escape вынесен в util.CsvUtils (убрано дублирование). + @Override + public String toCsvRow() { + String dur = duration == null ? "" : String.valueOf(duration.toMinutes()); + String st = startTime == null ? "" : startTime.format(CSV_TIME_FMT); + return String.join( + ",", + String.valueOf(id), + getType().name(), + CsvUtils.escape(title), + status.name(), + CsvUtils.escape(description), + dur, + st, + "" // epic + ); + } +} diff --git a/src/model/Subtask.java b/src/model/Subtask.java index eb110c6..9a7070a 100644 --- a/src/model/Subtask.java +++ b/src/model/Subtask.java @@ -1,17 +1,70 @@ package model; +import java.time.Duration; +import java.time.LocalDateTime; +import util.CsvUtils; // TODO(review sprint-8): используем утилиту для CSV-экранирования + +/** + * Подзадача, привязанная к эпику. + * + *

CHANGED (sprint-8): унаследованы duration/startTime/getEndTime от Task. + */ public class Subtask extends Task { - private final int epicId; + + private int epicId; public Subtask(String title, String description, int epicId) { - super(title, description, Status.NEW); - if (epicId <= 0) { - throw new IllegalArgumentException("Неверный epicId"); - } + super(title, description); + this.epicId = epicId; + } + + public Subtask(String title, String description, Status status, int epicId) { + super(title, description, status); this.epicId = epicId; } public int getEpicId() { return epicId; } -} \ No newline at end of file + + @SuppressWarnings("unused") + public void setEpicId(int epicId) { + this.epicId = epicId; + } + + @Override + public TaskType getType() { + return TaskType.SUBTASK; + } + + // TODO(review sprint-8): экранирование перенесено в CsvUtils, чтобы не дублировать метод. + @Override + public String toCsvRow() { + String dur = duration == null ? "" : String.valueOf(duration.toMinutes()); + String st = startTime == null ? "" : startTime.format(CSV_TIME_FMT); + return String.join( + ",", + String.valueOf(id), + getType().name(), + CsvUtils.escape(title), + status.name(), + CsvUtils.escape(description), + dur, + st, + String.valueOf(epicId) + ); + } + + // Удобные fluent-сеттеры + @SuppressWarnings("unused") + public Subtask withStart(LocalDateTime start) { + this.startTime = start; + return this; + } + + @SuppressWarnings("unused") + public Subtask withDuration(Duration d) { + this.duration = d; + return this; + } +} diff --git a/src/model/Task.java b/src/model/Task.java index 078802d..642e3f8 100644 --- a/src/model/Task.java +++ b/src/model/Task.java @@ -1,12 +1,47 @@ package model; +import java.time.Duration; // NEW (sprint-8) +import java.time.LocalDateTime; // NEW (sprint-8) +import java.time.format.DateTimeFormatter; import java.util.Objects; +/** + * Базовая задача. + *

CHANGED (sprint-8): + * - добавлены поля duration и startTime; + * - добавлен getEndTime(); + * - расширена CSV-строка (toCsvRow) на durationMinutes и startTime. + */ +@SuppressWarnings("DuplicatedCode") public class Task { + protected int id; protected String title; protected String description; - protected int id; - protected Status status; + protected Status status = Status.NEW; + + // NEW (sprint-8) + protected Duration duration; // оценка длительности (минуты) + protected LocalDateTime startTime; // когда начать + + // CSV-формат времени. Сохраняем человеко читаемо. + public static final DateTimeFormatter CSV_TIME_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + // NEW (sprint-8): удобный конструктор под тесты/инициализацию + public Task( + String title, + String description, + java.time.Duration duration, + java.time.LocalDateTime startTime) { + this(title, description); + this.duration = duration; + this.startTime = startTime; + } + + public Task(String title, String description) { + this.title = title; + this.description = description; + } public Task(String title, String description, Status status) { this.title = title; @@ -14,31 +49,131 @@ public Task(String title, String description, Status status) { this.status = status; } - public int getId() { return id; } - public void setId(int id) { this.id = id; } - public String getTitle() { return title; } - public Status getStatus() { return status; } - public void setStatus(Status status) { this.status = status; } + /* ---------- геттеры/сеттеры ---------- */ + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public TaskType getType() { + return TaskType.TASK; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + // NEW (sprint-8) + public Duration getDuration() { + return duration; + } + + public void setDuration(Duration duration) { + this.duration = duration; + } + + // NEW (sprint-8) + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + // NEW (sprint-8): вычисляем завершение как start + duration + public LocalDateTime getEndTime() { + if (startTime == null || duration == null) { + return null; + } + return startTime.plus(duration); + } + + /* ---------- CSV ---------- */ + + /** + * Возвращает CSV-строку в формате: + * id,type,name,status,description,durationMinutes,startTime,epic + * Для Task поле epic — пустое. + */ + public String toCsvRow() { + String dur = duration == null ? "" : String.valueOf(duration.toMinutes()); + String st = startTime == null ? "" : startTime.format(CSV_TIME_FMT); + return String.join( + ",", + String.valueOf(id), + getType().name(), + escape(title), + status.name(), + escape(description), + dur, + st, + "" // epic + ); + } + + // экранирование запятых + private static String escape(String s) { + return s == null ? "" : s; + } + + /* ---------- equals/hashCode ---------- */ @Override public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Task)) return false; - Task task = (Task) o; - return id == task.id; + if (this == o) { + return true; + } + if (!(o instanceof Task task)) { + return false; + } + return id == task.id + && Objects.equals(title, task.title) + && Objects.equals(description, task.description) + && status == task.status + && Objects.equals(duration, task.duration) + && Objects.equals(startTime, task.startTime) + && getType() == task.getType(); } @Override public int hashCode() { - return Objects.hash(id); + return Objects.hash(id, title, description, status, duration, startTime, getType()); } @Override public String toString() { - return getClass().getSimpleName() + "{" + - "id=" + id + - ", title='" + title + '\'' + - ", status=" + status + - '}'; + return getType() + + "{" + + "id=" + id + + ", title='" + title + '\'' + + ", status=" + status + + ", duration=" + (duration == null ? "null" : duration.toMinutes() + "m") + + ", startTime=" + startTime + + '}'; } -} \ No newline at end of file +} diff --git a/src/model/TaskType.java b/src/model/TaskType.java new file mode 100644 index 0000000..fbdbeb1 --- /dev/null +++ b/src/model/TaskType.java @@ -0,0 +1,8 @@ +package model; + +/** Тип задачи: нужен для CSV сериализация. */ +public enum TaskType { + TASK, + EPIC, + SUBTASK +} diff --git a/src/test/java/manager/InMemoryTaskManagerTest.java b/src/test/java/manager/InMemoryTaskManagerTest.java deleted file mode 100644 index aebf951..0000000 --- a/src/test/java/manager/InMemoryTaskManagerTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package test.java.manager; - -import manager.Managers; -import manager.TaskManager; -import model.*; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -// Юнит-тесты для {InMemoryTaskManager}. -// Проверяются добавление задач, история просмотров и корректная обработка ошибок. - -class InMemoryTaskManagerTest { - private TaskManager manager; -//Создаёт новый экземпляр менеджера перед каждым тестом. - @BeforeEach - void setup() { - manager = Managers.getDefault(); - } -//Проверяет, что добавленная задача возвращается корректно по ID. - @Test - void shouldAddAndReturnTask() { - Task task = new Task("Test task", "Desc", Status.NEW); - int id = manager.addNewTask(task); - Task returned = manager.getTask(id); - - assertNotNull(returned); - assertEquals(task.getTitle(), returned.getTitle()); - } -//Проверяет, что история просмотров сохраняет порядок и допускает повторы. - @Test - void shouldStoreHistoryCorrectly() { - int id1 = manager.addNewTask(new Task("T1", "", Status.NEW)); - int id2 = manager.addNewTask(new Task("T2", "", Status.NEW)); - - manager.getTask(id1); - manager.getTask(id2); - manager.getTask(id1); - - List history = manager.getHistory(); - assertEquals(3, history.size()); - assertEquals("T1", history.get(2).getTitle()); - } -//Проверяет, что при попытке привязать подзадачу к несуществующему эпику будет выброшено исключение. - @Test - void shouldThrowIfSubtaskReferencesMissingEpic() { - Subtask subtask = new Subtask("Ошибка", "Нет эпика", 999); // несуществующий epicId - assertThrows(IllegalArgumentException.class, () -> manager.addNewSubtask(subtask)); - } - -//Проверяет, что история просмотров не превышает 10 элементов. - @Test - void historyShouldNotExceedTenEntries() { - for (int i = 0; i < 12; i++) { - int id = manager.addNewTask(new Task("T" + i, "", Status.NEW)); - manager.getTask(id); - } - - List history = manager.getHistory(); - assertEquals(10, history.size(), "История не должна превышать 10 элементов"); - } -} diff --git a/src/test/manager/EpicStatusTest.java b/src/test/manager/EpicStatusTest.java new file mode 100644 index 0000000..edebe56 --- /dev/null +++ b/src/test/manager/EpicStatusTest.java @@ -0,0 +1,66 @@ +package manager; + +import java.io.File; +import model.Epic; +import model.Status; +import model.Subtask; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class EpicStatusTest { + + // Проверяем правила статуса эпика на InMemory + @Test + void epicStatusRules_inMemory() { + assertEpicStatusRules(new InMemoryTaskManager()); + } + + // И то же самое на FileBacked + @Test + void epicStatusRules_fileBacked() throws Exception { + File tmp = File.createTempFile("tasks", ".csv"); + tmp.deleteOnExit(); + assertEpicStatusRules(new FileBackedTaskManager(tmp)); + } + + // Общая проверка правил из ТЗ: + // a) без подзадач → NEW + // b) все подзадачи NEW → NEW + // c) все подзадачи DONE → DONE + // d) NEW + DONE → IN_PROGRESS + // e) есть хотя бы одна IN_PROGRESS → IN_PROGRESS + private void assertEpicStatusRules(TaskManager manager) { + int epicId = manager.addNewEpic(new Epic("E", "desc")); + + // a) без подзадач + assertEquals(Status.NEW, manager.getEpic(epicId).getStatus()); + + int s1 = manager.addNewSubtask(new Subtask("s1", "", epicId)); + int s2 = manager.addNewSubtask(new Subtask("s2", "", epicId)); + + // b) все NEW + assertEquals(Status.NEW, manager.getEpic(epicId).getStatus()); + + // c) все DONE + var u1 = manager.getSubtask(s1); + u1.setStatus(Status.DONE); + manager.updateSubtask(u1); + + var u2 = manager.getSubtask(s2); + u2.setStatus(Status.DONE); + manager.updateSubtask(u2); + + assertEquals(Status.DONE, manager.getEpic(epicId).getStatus()); + + // d) NEW + DONE → IN_PROGRESS + u1.setStatus(Status.NEW); + manager.updateSubtask(u1); + assertEquals(Status.IN_PROGRESS, manager.getEpic(epicId).getStatus()); + + // e) есть хотя бы одна IN_PROGRESS → IN_PROGRESS + u2.setStatus(Status.IN_PROGRESS); + manager.updateSubtask(u2); + assertEquals(Status.IN_PROGRESS, manager.getEpic(epicId).getStatus()); + } +} diff --git a/src/test/manager/FileBackedLegacyFormatTest.java b/src/test/manager/FileBackedLegacyFormatTest.java new file mode 100644 index 0000000..298e002 --- /dev/null +++ b/src/test/manager/FileBackedLegacyFormatTest.java @@ -0,0 +1,24 @@ +package manager; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; + +class FileBackedLegacyFormatTest { + + @Test + void legacySprint7Format_isAccepted() throws Exception { + File tmp = File.createTempFile("tasks", ".csv"); + tmp.deleteOnExit(); + String legacy = + "id,type,name,status,description,epic\n" + + "1,TASK,t,NEW,d,\n"; + Files.writeString(tmp.toPath(), legacy, StandardCharsets.UTF_8); + + FileBackedTaskManager m = FileBackedTaskManager.loadFromFile(tmp); + assertEquals(1, m.getTasks().size()); + assertEquals("t", m.getTasks().get(0).getTitle()); + } +} diff --git a/src/test/manager/FileBackedRoundTripTest.java b/src/test/manager/FileBackedRoundTripTest.java new file mode 100644 index 0000000..b60f59a --- /dev/null +++ b/src/test/manager/FileBackedRoundTripTest.java @@ -0,0 +1,56 @@ +package manager; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.time.LocalDateTime; +import model.Task; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FileBackedRoundTripTest { + + private File tmp; + + @BeforeEach + void init() throws Exception { + tmp = File.createTempFile("tasks", ".csv"); + tmp.deleteOnExit(); + } + + @AfterEach + void cleanup() { + if (tmp != null && !tmp.delete()) { + tmp.deleteOnExit(); + } + } + + @Test + void saveAndLoad_doesNotThrow() { + assertDoesNotThrow(() -> { + FileBackedTaskManager m1 = new FileBackedTaskManager(tmp); + Task t = new Task("t", "d"); + t.setDuration(Duration.ofMinutes(30)); + t.setStartTime(LocalDateTime.of(2025, 1, 1, 10, 0)); + m1.addNewTask(t); // триггерит save() + + FileBackedTaskManager m2 = FileBackedTaskManager.loadFromFile(tmp); + assertEquals(1, m2.getTasks().size()); + assertEquals("t", m2.getTasks().get(0).getTitle()); + }); + } + + @Test + void malformedCsv_throwsManagerSaveException() throws Exception { + // некорректная строка (ни 6, ни 8 колонок) + String bad = + "id,type,name,status,description,durationMinutes,startTime,epic\n" + + "1,TASK,t,NEW,d,10,2025-01-01 10:00,,"; // лишняя запятая => 9 колонок + Files.writeString(tmp.toPath(), bad, StandardCharsets.UTF_8); + + assertThrows(ManagerSaveException.class, () -> FileBackedTaskManager.loadFromFile(tmp)); + } +} diff --git a/src/test/manager/FileBackedTaskManagerTest.java b/src/test/manager/FileBackedTaskManagerTest.java new file mode 100644 index 0000000..06ccafa --- /dev/null +++ b/src/test/manager/FileBackedTaskManagerTest.java @@ -0,0 +1,41 @@ +package manager; + +import java.io.File; +import java.io.IOException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +class FileBackedTaskManagerTest extends TaskManagerTest { + + private File tmp; + + @BeforeEach + void initFile() throws Exception { + // можно оставить — не мешает + tmp = File.createTempFile("tasks", ".csv"); + tmp.deleteOnExit(); + } + + @AfterEach + void cleanup() { + if (tmp != null) { + if (!tmp.delete()) { + // на Windows файл может быть занят — удалим при выходе + tmp.deleteOnExit(); + } + } + } + + @Override + protected FileBackedTaskManager createManager() { + if (tmp == null) { // <-- гарантия при вызове из super.setUp() + try { + tmp = File.createTempFile("tasks", ".csv"); + tmp.deleteOnExit(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return new FileBackedTaskManager(tmp); + } +} diff --git a/src/test/manager/InMemoryHistoryManagerTest.java b/src/test/manager/InMemoryHistoryManagerTest.java new file mode 100644 index 0000000..ed94f45 --- /dev/null +++ b/src/test/manager/InMemoryHistoryManagerTest.java @@ -0,0 +1,54 @@ +package manager; + +import model.Task; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +public class InMemoryHistoryManagerTest { + + @Test + void emptyHistory_ok() { + InMemoryHistoryManager h = new InMemoryHistoryManager(); + assertTrue(h.getHistory().isEmpty()); + } + + @Test + void noDuplicates_limit10() { + InMemoryHistoryManager h = new InMemoryHistoryManager(); + for (int i = 0; i < 12; i++) { + Task t = new Task("T" + i, "", Duration.ofMinutes(1), LocalDateTime.now()); + t.setId(i + 1); + h.add(t); + h.add(t); + } + assertEquals(10, h.getHistory().size()); + } + + @Test + void remove_edges() { + InMemoryHistoryManager h = new InMemoryHistoryManager(); + + Task a = new Task("A", "", Duration.ofMinutes(1), LocalDateTime.now()); + a.setId(1); + + Task b = new Task("B", "", Duration.ofMinutes(1), LocalDateTime.now()); + b.setId(2); + + Task c = new Task("C", "", Duration.ofMinutes(1), LocalDateTime.now()); + c.setId(3); + + h.add(a); + h.add(b); + h.add(c); + + h.remove(1); // начало + h.remove(2); // середина (после удаления 1 останутся [b, c]) + h.remove(3); // конец + + assertTrue(h.getHistory().isEmpty()); + } +} diff --git a/src/test/manager/InMemoryTaskManagerTest.java b/src/test/manager/InMemoryTaskManagerTest.java new file mode 100644 index 0000000..002850a --- /dev/null +++ b/src/test/manager/InMemoryTaskManagerTest.java @@ -0,0 +1,9 @@ +package manager; + +class InMemoryTaskManagerTest extends TaskManagerTest { + + @Override + protected InMemoryTaskManager createManager() { + return new InMemoryTaskManager(); + } +} diff --git a/src/test/manager/PrioritizedViewTest.java b/src/test/manager/PrioritizedViewTest.java new file mode 100644 index 0000000..0eaf1de --- /dev/null +++ b/src/test/manager/PrioritizedViewTest.java @@ -0,0 +1,39 @@ +package manager; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import model.Epic; +import model.Task; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PrioritizedViewTest { + + private InMemoryTaskManager manager; + + @BeforeEach + void setUp() { + manager = new InMemoryTaskManager(); + } + + @Test + void prioritized_excludesEpicsAndNullStart() { + int epicId = manager.addNewEpic(new Epic("E", "d")); + + Task noStart = new Task("noStart", ""); + noStart.setDuration(Duration.ofMinutes(15)); + manager.addNewTask(noStart); + + Task withStart = new Task("withStart", ""); + withStart.setDuration(Duration.ofMinutes(10)); + withStart.setStartTime(LocalDateTime.of(2025, 1, 1, 10, 0)); + manager.addNewTask(withStart); + + List pr = manager.getPrioritizedTasks(); + assertEquals(1, pr.size(), "В приоритизации должны быть только задачи с startTime (без эпиков)"); + assertEquals("withStart", pr.get(0).getTitle()); + } +} diff --git a/src/test/manager/TaskManagerTest.java b/src/test/manager/TaskManagerTest.java new file mode 100644 index 0000000..8e8e776 --- /dev/null +++ b/src/test/manager/TaskManagerTest.java @@ -0,0 +1,108 @@ +package manager; + +import model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public abstract class TaskManagerTest { + + protected T manager; + + protected abstract T createManager(); + + @BeforeEach + void setUp() { + manager = createManager(); + } + + @Test + void epicStatusRules() { + int epicId = manager.addNewEpic(new Epic("E", "d")); + + // a) все NEW + int s1 = manager.addNewSubtask(new Subtask("s1", "", epicId)); + int s2 = manager.addNewSubtask(new Subtask("s2", "", epicId)); + assertEquals(Status.NEW, manager.getEpic(epicId).getStatus()); + + // b) все DONE + Subtask us1 = manager.getSubtask(s1); + us1.setStatus(Status.DONE); + manager.updateSubtask(us1); + + Subtask us2 = manager.getSubtask(s2); + us2.setStatus(Status.DONE); + manager.updateSubtask(us2); + + assertEquals(Status.DONE, manager.getEpic(epicId).getStatus()); + + // c) NEW + DONE => IN_PROGRESS + us1.setStatus(Status.NEW); + manager.updateSubtask(us1); + assertEquals(Status.IN_PROGRESS, manager.getEpic(epicId).getStatus()); + + // d) есть IN_PROGRESS => IN_PROGRESS + us2.setStatus(Status.IN_PROGRESS); + manager.updateSubtask(us2); + assertEquals(Status.IN_PROGRESS, manager.getEpic(epicId).getStatus()); + } + + @Test + void prioritizedAndNoIntersections() { + // две задачи подряд без пересечения + Task t1 = new Task("t1", ""); + t1.setStartTime(LocalDateTime.of(2025, 1, 1, 10, 0)); + t1.setDuration(Duration.ofMinutes(30)); + + Task t2 = new Task("t2", ""); + t2.setStartTime(LocalDateTime.of(2025, 1, 1, 10, 30)); + t2.setDuration(Duration.ofMinutes(30)); + + manager.addNewTask(t1); + manager.addNewTask(t2); + + List pr = manager.getPrioritizedTasks(); + assertEquals(2, pr.size()); + assertEquals("t1", pr.get(0).getTitle()); + assertEquals("t2", pr.get(1).getTitle()); + + // пересечение должно падать + Task t3 = new Task("t3", ""); + t3.setStartTime(LocalDateTime.of(2025, 1, 1, 10, 15)); + t3.setDuration(Duration.ofMinutes(30)); + assertThrows(RuntimeException.class, () -> manager.addNewTask(t3)); + } + + @Test + void epicTimeIsCalculatedFromSubtasks() { + int epicId = manager.addNewEpic(new Epic("E", "")); + + Subtask s1 = new Subtask("a", "", epicId); + s1.setStartTime(LocalDateTime.of(2025, 1, 1, 9, 0)); + s1.setDuration(Duration.ofMinutes(30)); + int s1id = manager.addNewSubtask(s1); + + Subtask s2 = new Subtask("b", "", epicId); + s2.setStartTime(LocalDateTime.of(2025, 1, 1, 10, 0)); + s2.setDuration(Duration.ofMinutes(90)); + int s2id = manager.addNewSubtask(s2); + assertTrue(s2id > 0); // используем переменную, чтобы не было предупреждения + + Epic e = manager.getEpic(epicId); + assertEquals(LocalDateTime.of(2025, 1, 1, 9, 0), e.getStartTime()); + assertEquals(LocalDateTime.of(2025, 1, 1, 11, 30), e.getEndTime()); + assertEquals(120, e.getDuration().toMinutes()); // 30 + 90 + + // удалил одну — пересчёт + manager.removeSubtask(s1id); + e = manager.getEpic(epicId); + assertEquals(LocalDateTime.of(2025, 1, 1, 10, 0), e.getStartTime()); + assertEquals(LocalDateTime.of(2025, 1, 1, 11, 30), e.getEndTime()); + assertEquals(90, e.getDuration().toMinutes()); + } +} diff --git a/src/util/CsvUtils.java b/src/util/CsvUtils.java new file mode 100644 index 0000000..ae16816 --- /dev/null +++ b/src/util/CsvUtils.java @@ -0,0 +1,28 @@ +package util; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +public final class CsvUtils { + private CsvUtils() {} + + public static String escape(String s) { + return s == null ? "" : s; + } + + public static LocalDateTime parseTimeOrNull(String s, DateTimeFormatter fmt) { + if (s == null || s.isBlank()) { + return null; + } + try { + return LocalDateTime.parse(s, fmt); + } catch (DateTimeParseException ex) { + try { + return LocalDateTime.parse(s); // ISO fallback + } catch (DateTimeParseException ignored) { + return null; + } + } + } +}