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;
+ }
+ }
+ }
+}