diff --git a/docs/tests/unit/task-status-progress.md b/docs/tests/unit/task-status-progress.md new file mode 100644 index 0000000..6da6cc6 --- /dev/null +++ b/docs/tests/unit/task-status-progress.md @@ -0,0 +1,75 @@ +# Test Evidence: Fixed Task status graph and initial Project progress + +- **Test type:** Unit +- **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016` +- **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008` +- **Test class/method:** `com.lab.labtimesheet.tasks.TaskDomainRulesTest` +- **Implementation commit:** `pending` + +## Protected behavior + +The Task status graph accepts exactly the seven specified directed edges. Initial Project progress counts each current Task status and represents a Project without current Tasks as no percentage rather than zero percent. + +## Test method + +One parameterized test checks all 16 source/target status pairs against a hand-written allowed-edge table. Two focused tests check empty progress and a four-Task example with two `DONE` Tasks. + +## Hand-derived expected result + +Allowed edges are `TODO` to `IN_PROGRESS` or `BLOCKED`; `IN_PROGRESS` to `DONE` or `BLOCKED`; `BLOCKED` to `TODO` or `IN_PROGRESS`; and `DONE` to `IN_PROGRESS`. All other pairs are forbidden. Zero Tasks has no percentage. Two `DONE` among four Tasks is 50%, with counts 1 `TODO`, 1 `IN_PROGRESS`, 0 `BLOCKED`, and 2 `DONE`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDomainRulesTest test +``` + +**Observed result** + +```text +[ERROR] COMPILATION ERROR : +TaskDomainRulesTest.java:[16,30] cannot find symbol + symbol: class TaskStatus +[INFO] BUILD FAILURE +``` + +The test could not compile because the required Task status and progress domain types did not exist. + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=TaskDomainRulesTest test +``` + +**Observed result** + +```text +[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw test + +[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +## External-test boundaries + +This unit evidence does not prove current-assignee authorization, Project lifecycle enforcement, PostgreSQL persistence/query filtering, non-deleted selection, HTTP authorization, or rendered `N/A`. Those require the platform/Project foundation and PostgreSQL/web tests. diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java b/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java new file mode 100644 index 0000000..632d8e7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskProgress.java @@ -0,0 +1,40 @@ +package com.lab.labtimesheet.tasks; + +import java.util.Collection; +import java.util.OptionalDouble; + +public record TaskProgress(int todo, int inProgress, int blocked, int done) { + + public static TaskProgress from(Collection statuses) { + int todo = 0; + int inProgress = 0; + int blocked = 0; + int done = 0; + for (TaskStatus status : statuses) { + switch (status) { + case TODO -> todo++; + case IN_PROGRESS -> inProgress++; + case BLOCKED -> blocked++; + case DONE -> done++; + } + } + return new TaskProgress(todo, inProgress, blocked, done); + } + + public int total() { + return todo + inProgress + blocked + done; + } + + public int count(TaskStatus status) { + return switch (status) { + case TODO -> todo; + case IN_PROGRESS -> inProgress; + case BLOCKED -> blocked; + case DONE -> done; + }; + } + + public OptionalDouble completionPercentage() { + return total() == 0 ? OptionalDouble.empty() : OptionalDouble.of(done * 100.0 / total()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java b/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java new file mode 100644 index 0000000..5aff97a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/tasks/TaskStatus.java @@ -0,0 +1,17 @@ +package com.lab.labtimesheet.tasks; + +public enum TaskStatus { + TODO, + IN_PROGRESS, + BLOCKED, + DONE; + + public boolean canTransitionTo(TaskStatus target) { + return switch (this) { + case TODO -> target == IN_PROGRESS || target == BLOCKED; + case IN_PROGRESS -> target == DONE || target == BLOCKED; + case BLOCKED -> target == TODO || target == IN_PROGRESS; + case DONE -> target == IN_PROGRESS; + }; + } +} diff --git a/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java b/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java new file mode 100644 index 0000000..af42eaf --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/tasks/TaskDomainRulesTest.java @@ -0,0 +1,61 @@ +package com.lab.labtimesheet.tasks; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TaskDomainRulesTest { + + private static final Map> ALLOWED_TRANSITIONS = Map.of( + TaskStatus.TODO, Set.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED), + TaskStatus.IN_PROGRESS, Set.of(TaskStatus.DONE, TaskStatus.BLOCKED), + TaskStatus.BLOCKED, Set.of(TaskStatus.TODO, TaskStatus.IN_PROGRESS), + TaskStatus.DONE, Set.of(TaskStatus.IN_PROGRESS)); + + @ParameterizedTest + @MethodSource("allStatusTransitions") + void acceptsOnlyTheFixedStatusGraph(TaskStatus current, TaskStatus target, boolean expected) { + assertThat(current.canTransitionTo(target)).isEqualTo(expected); + } + + @Test + void reportsNoPercentageForAProjectWithoutTasks() { + TaskProgress progress = TaskProgress.from(List.of()); + + assertThat(progress.completionPercentage()).isEmpty(); + assertThat(progress.total()).isZero(); + assertThat(progress.count(TaskStatus.DONE)).isZero(); + } + + @Test + void countsStatusesAndDonePercentageFromCurrentTasks() { + TaskProgress progress = TaskProgress.from(List.of( + TaskStatus.TODO, + TaskStatus.IN_PROGRESS, + TaskStatus.DONE, + TaskStatus.DONE)); + + assertThat(progress.completionPercentage()).hasValue(50); + assertThat(progress.total()).isEqualTo(4); + assertThat(progress.count(TaskStatus.TODO)).isEqualTo(1); + assertThat(progress.count(TaskStatus.IN_PROGRESS)).isEqualTo(1); + assertThat(progress.count(TaskStatus.BLOCKED)).isZero(); + assertThat(progress.count(TaskStatus.DONE)).isEqualTo(2); + } + + private static Stream allStatusTransitions() { + return Stream.of(TaskStatus.values()) + .flatMap(current -> Stream.of(TaskStatus.values()) + .map(target -> Arguments.of( + current, + target, + ALLOWED_TRANSITIONS.get(current).contains(target)))); + } +}