feat(tasks): define status graph and progress
This commit is contained in:
@@ -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.
|
||||||
@@ -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<TaskStatus> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TaskStatus, Set<TaskStatus>> 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<Arguments> allStatusTransitions() {
|
||||||
|
return Stream.of(TaskStatus.values())
|
||||||
|
.flatMap(current -> Stream.of(TaskStatus.values())
|
||||||
|
.map(target -> Arguments.of(
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
ALLOWED_TRANSITIONS.get(current).contains(target))));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user