diff --git a/README.md b/README.md index fbb238f..c535621 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Available commands: - `create` - Create a new project with guided scoring - `list` (alias: `ls`) - List all projects with scores, optionally sorted - `view ` - View detailed project information +- `update ` - Change a project's description or scores - `delete ` (alias: `rm`) - Delete a project by name - `export` - Export all projects to Markdown format, optionally sorted - `help` - Show available commands @@ -71,6 +72,18 @@ list -s impact Supported `--sort` values are `name`, `impact`, `confidence`, `ease`, `reach`, `effort`, `ice` and `rice`. Every value except `name` sorts from highest to lowest; omitting `--sort` keeps the order in which projects were created. +### Update Examples +```bash +# Change a project's description +update "My Project" --description "A sharper pitch" + +# Change one or more scores (1-5); fields not given keep their current values +update "My Project" --impact 5 --effort 2 +update "My Project" -c 4 -r 3 +``` + +`update` accepts `--description` (`-d`), `--impact` (`-i`), `--confidence` (`-c`), `--ease` (`-e`), `--reach` (`-r`) and `--effort` (`-f`), and at least one of them must be given. The change is saved to `projects.json` immediately and the project keeps its place in creation order; the new ICE and RICE scores are printed. If no project has the given name, `update` reports `Project not found: `; an empty description or a score outside 1-5 is rejected and nothing is changed. A project cannot be renamed with `update`. + ### Delete Examples ```bash # Delete a project by name diff --git a/src/main/java/com/preponderous/parpt/command/UpdateProjectCommand.java b/src/main/java/com/preponderous/parpt/command/UpdateProjectCommand.java new file mode 100644 index 0000000..08c360a --- /dev/null +++ b/src/main/java/com/preponderous/parpt/command/UpdateProjectCommand.java @@ -0,0 +1,59 @@ +package com.preponderous.parpt.command; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.repo.ProjectRepository; +import com.preponderous.parpt.score.ScoreCalculator; +import com.preponderous.parpt.service.ProjectService; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellOption; + +import java.util.stream.Stream; + +@ShellComponent +public class UpdateProjectCommand { + + private final ProjectService projectService; + private final ScoreCalculator scoreCalculator; + + public UpdateProjectCommand(ProjectService projectService, ScoreCalculator scoreCalculator) { + this.projectService = projectService; + this.scoreCalculator = scoreCalculator; + } + + private static boolean isOutOfRange(Integer score) { + return score != null && (score < 1 || score > 5); + } + + @ShellMethod(key = "update", value = "Updates the description or scores of a specific project by name.") + public String execute( + @ShellOption(help = "The name of the project to update") String projectName, + @ShellOption(value = {"-d", "--description"}, help = "New description of the project", defaultValue = ShellOption.NULL) String description, + @ShellOption(value = {"-i", "--impact"}, help = "New impact score (1-5)", defaultValue = ShellOption.NULL) Integer impact, + @ShellOption(value = {"-c", "--confidence"}, help = "New confidence score (1-5)", defaultValue = ShellOption.NULL) Integer confidence, + @ShellOption(value = {"-e", "--ease"}, help = "New ease score (1-5)", defaultValue = ShellOption.NULL) Integer ease, + @ShellOption(value = {"-r", "--reach"}, help = "New reach score (1-5)", defaultValue = ShellOption.NULL) Integer reach, + @ShellOption(value = {"-f", "--effort"}, help = "New effort score (1-5)", defaultValue = ShellOption.NULL) Integer effort + ) { + if (description == null && Stream.of(impact, confidence, ease, reach, effort).allMatch(score -> score == null)) { + return "Nothing to update. Provide at least one of --description, --impact, --confidence, --ease, --reach or --effort."; + } + if (description != null && description.isEmpty()) { + return "Project description cannot be empty."; + } + if (Stream.of(impact, confidence, ease, reach, effort).anyMatch(UpdateProjectCommand::isOutOfRange)) { + return "All scores must be between 1 and 5."; + } + + Project project; + try { + project = projectService.updateProject(projectName, description, impact, confidence, ease, reach, effort); + } catch (ProjectRepository.ProjectNotFoundException e) { + return "Project not found: " + projectName; + } + + double iceScore = scoreCalculator.ice(project); + double riceScore = scoreCalculator.rice(project); + return String.format("Project updated: %s\nICE Score: %.2f\nRICE Score: %.2f", projectName, iceScore, riceScore); + } +} diff --git a/src/main/java/com/preponderous/parpt/repo/ProjectRepository.java b/src/main/java/com/preponderous/parpt/repo/ProjectRepository.java index c79ac05..b5db017 100644 --- a/src/main/java/com/preponderous/parpt/repo/ProjectRepository.java +++ b/src/main/java/com/preponderous/parpt/repo/ProjectRepository.java @@ -39,6 +39,16 @@ public void remove(String projectName) throws ProjectNotFoundException { projectJsonReaderWriter.writeJson(projects); } + /** + * Replaces the stored project that has the same name as the given one, keeping its position + * in creation order. + */ + public void update(Project project) throws ProjectNotFoundException { + int index = projects.indexOf(findByName(project.getName())); + projects.set(index, project); + projectJsonReaderWriter.writeJson(projects); + } + public Project findByName(String projectName) throws ProjectNotFoundException { Project retrievedProject = projects.stream() .filter(project -> project.getName().equals(projectName)) diff --git a/src/main/java/com/preponderous/parpt/service/ProjectService.java b/src/main/java/com/preponderous/parpt/service/ProjectService.java index adf525e..2545025 100644 --- a/src/main/java/com/preponderous/parpt/service/ProjectService.java +++ b/src/main/java/com/preponderous/parpt/service/ProjectService.java @@ -40,6 +40,25 @@ public Project getProject(String projectName) throws ProjectRepository.ProjectNo return projectRepository.findByName(projectName); } + /** + * Updates the named project, changing only the fields given; a null argument keeps the + * project's current value. + */ + public Project updateProject(String name, String description, Integer impact, Integer confidence, Integer ease, Integer reach, Integer effort) throws ProjectRepository.ProjectNotFoundException { + Project existing = projectRepository.findByName(name); + Project updated = Project.builder() + .name(existing.getName()) + .description(description != null ? description : existing.getDescription()) + .impact(impact != null ? impact : existing.getImpact()) + .confidence(confidence != null ? confidence : existing.getConfidence()) + .ease(ease != null ? ease : existing.getEase()) + .reach(reach != null ? reach : existing.getReach()) + .effort(effort != null ? effort : existing.getEffort()) + .build(); + projectRepository.update(updated); + return updated; + } + public void deleteProject(String projectName) throws ProjectRepository.ProjectNotFoundException { projectRepository.remove(projectName); } diff --git a/src/test/java/com/preponderous/parpt/command/UpdateProjectCommandTest.java b/src/test/java/com/preponderous/parpt/command/UpdateProjectCommandTest.java new file mode 100644 index 0000000..8d72d92 --- /dev/null +++ b/src/test/java/com/preponderous/parpt/command/UpdateProjectCommandTest.java @@ -0,0 +1,150 @@ +package com.preponderous.parpt.command; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.repo.ProjectJsonReaderWriter; +import com.preponderous.parpt.repo.ProjectRepository; +import com.preponderous.parpt.score.ScoreCalculator; +import com.preponderous.parpt.service.ProjectService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +class UpdateProjectCommandTest { + + UpdateProjectCommand updateProjectCommand; + + @Autowired + ProjectService projectService; + + @Autowired + ProjectRepository projectRepository; + + @Autowired + ProjectJsonReaderWriter projectJsonReaderWriter; + + @Autowired + ScoreCalculator scoreCalculator; + + @BeforeEach + void setUp() { + // Initialize the command with the project service and score calculator + updateProjectCommand = new UpdateProjectCommand(projectService, scoreCalculator); + + // Clear any existing projects in the repository before each test + projectRepository.clear(); + } + + @Test + void shouldReturnProjectNotFoundWhenProjectDoesNotExist() { + // Given no projects exist + + // When the command is executed with a non-existing project name + var result = updateProjectCommand.execute("NonExistingProject", "New description", null, null, null, null, null); + + // Then the result should indicate that the project was not found + assertEquals("Project not found: NonExistingProject", result); + } + + @Test + void shouldUpdateOnlyTheGivenFields() throws Exception { + // Given a project exists + projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5); + + // When the command changes the description and two of the scores + var result = updateProjectCommand.execute("Test Project", "Updated description", 5, null, null, 1, null); + + // Then the given fields should change and the rest should keep their values + Project project = projectService.getProject("Test Project"); + assertEquals("Updated description", project.getDescription()); + assertEquals(5, project.getImpact()); + assertEquals(2, project.getConfidence()); + assertEquals(3, project.getEase()); + assertEquals(1, project.getReach()); + assertEquals(5, project.getEffort()); + assertEquals(String.format("Project updated: Test Project\nICE Score: %.2f\nRICE Score: %.2f", + scoreCalculator.ice(project), scoreCalculator.rice(project)), result); + } + + @Test + void shouldKeepProjectPositionAmongOtherProjects() throws Exception { + // Given three projects exist + projectService.createProject("First", "One", 1, 1, 1, 1, 1); + projectService.createProject("Second", "Two", 2, 2, 2, 2, 2); + projectService.createProject("Third", "Three", 3, 3, 3, 3, 3); + + // When the middle project is updated + updateProjectCommand.execute("Second", null, null, null, null, null, 5); + + // Then the projects should stay in creation order and only the middle one should change + List projects = projectService.getProjects(); + assertEquals(List.of("First", "Second", "Third"), projects.stream().map(Project::getName).toList()); + assertEquals(5, projects.get(1).getEffort()); + assertEquals(1, projects.get(0).getEffort()); + assertEquals(3, projects.get(2).getEffort()); + } + + @Test + void shouldPersistUpdateToStorage() throws Exception { + // Given a project has been saved to storage + projectService.createProject("Persisted Project", "Saved to disk", 1, 2, 3, 4, 5); + + // When the project is updated + updateProjectCommand.execute("Persisted Project", "Changed on disk", null, null, 4, null, null); + + // Then re-reading storage should contain the updated values + List stored = projectJsonReaderWriter.readJson(); + assertEquals(1, stored.size()); + assertEquals("Changed on disk", stored.get(0).getDescription()); + assertEquals(4, stored.get(0).getEase()); + } + + @Test + void shouldRejectUpdateWithNothingToChange() throws Exception { + // Given a project exists + projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5); + + // When the command is executed without any field to change + var result = updateProjectCommand.execute("Test Project", null, null, null, null, null, null); + + // Then the result should explain what to provide and the project should be unchanged + assertEquals("Nothing to update. Provide at least one of --description, --impact, --confidence, --ease, --reach or --effort.", result); + assertEquals("Original description", projectService.getProject("Test Project").getDescription()); + } + + @Test + void shouldRejectEmptyDescription() throws Exception { + // Given a project exists + projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5); + + // When the command is executed with an empty description + var result = updateProjectCommand.execute("Test Project", "", null, null, null, null, null); + + // Then the update should be rejected and the description kept + assertEquals("Project description cannot be empty.", result); + assertEquals("Original description", projectService.getProject("Test Project").getDescription()); + } + + @Test + void shouldRejectOutOfRangeScoresWithoutChangingAnything() throws Exception { + // Given a project exists + projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5); + + // When the command is executed with one valid and one out-of-range score + var tooHigh = updateProjectCommand.execute("Test Project", null, 4, null, null, null, 6); + var tooLow = updateProjectCommand.execute("Test Project", null, null, 0, null, null, null); + + // Then both updates should be rejected and no score should change + assertEquals("All scores must be between 1 and 5.", tooHigh); + assertEquals("All scores must be between 1 and 5.", tooLow); + Project project = projectService.getProject("Test Project"); + assertEquals(1, project.getImpact()); + assertEquals(2, project.getConfidence()); + assertEquals(5, project.getEffort()); + } +} diff --git a/src/test/java/com/preponderous/parpt/repo/ProjectRepositoryTest.java b/src/test/java/com/preponderous/parpt/repo/ProjectRepositoryTest.java index 124b6e2..bd58575 100644 --- a/src/test/java/com/preponderous/parpt/repo/ProjectRepositoryTest.java +++ b/src/test/java/com/preponderous/parpt/repo/ProjectRepositoryTest.java @@ -101,6 +101,33 @@ void remove_WithUnknownName_ShouldThrowAndNotPersist() { verify(projectJsonReaderWriter, never()).writeJson(anyList()); } + @Test + void update_ShouldReplaceProjectInPlaceAndPersistOnce() throws Exception { + repository.add(beta); + writtenNames.clear(); + clearInvocations(projectJsonReaderWriter); + Project changedAlpha = Project.builder().name("Alpha").description("Changed").impact(5).confidence(5).ease(5).reach(5).effort(5).build(); + + repository.update(changedAlpha); + + assertThat(repository.findAll()).containsExactly(changedAlpha, beta); + assertThat(repository.findByName("Alpha")).isSameAs(changedAlpha); + verify(projectJsonReaderWriter, times(1)).writeJson(anyList()); + assertThat(writtenNames).containsExactly(List.of("Alpha", "Beta")); + } + + @Test + void update_WithUnknownName_ShouldThrowAndNotPersist() { + Project missing = Project.builder().name("Missing").description("Nowhere").build(); + + ProjectRepository.ProjectNotFoundException exception = assertThrows( + ProjectRepository.ProjectNotFoundException.class, () -> repository.update(missing)); + + assertThat(exception.getMessage()).isEqualTo("Project not found: Missing"); + assertThat(repository.findAll()).containsExactly(alpha); + verify(projectJsonReaderWriter, never()).writeJson(anyList()); + } + @Test void clear_ShouldEmptyProjectsAndPersistOnce() { repository.clear(); diff --git a/src/test/java/com/preponderous/parpt/service/ProjectServiceTest.java b/src/test/java/com/preponderous/parpt/service/ProjectServiceTest.java index 6381be0..0056c72 100644 --- a/src/test/java/com/preponderous/parpt/service/ProjectServiceTest.java +++ b/src/test/java/com/preponderous/parpt/service/ProjectServiceTest.java @@ -100,6 +100,51 @@ void getProject_WithUnknownName_ShouldPropagateNotFound() throws Exception { assertThat(exception.getMessage()).isEqualTo("Project not found: Missing"); } + @Test + void updateProject_ShouldChangeOnlyGivenFieldsAndPersistViaRepository() throws Exception { + Project alpha = Project.builder().name("Alpha").description("First").impact(1).confidence(2).ease(3).reach(4).effort(5).build(); + when(projectRepository.findByName("Alpha")).thenReturn(alpha); + + Project updated = service.updateProject("Alpha", "Changed", null, 4, null, 1, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Project.class); + verify(projectRepository, times(1)).update(captor.capture()); + assertThat(updated).isSameAs(captor.getValue()); + assertThat(updated.getName()).isEqualTo("Alpha"); + assertThat(updated.getDescription()).isEqualTo("Changed"); + assertThat(updated.getImpact()).isEqualTo(1); + assertThat(updated.getConfidence()).isEqualTo(4); + assertThat(updated.getEase()).isEqualTo(3); + assertThat(updated.getReach()).isEqualTo(1); + assertThat(updated.getEffort()).isEqualTo(5); + verifyNoInteractions(usageReporter); + } + + @Test + void updateProject_WithAllFieldsGiven_ShouldApplyEachToItsOwnField() throws Exception { + when(projectRepository.findByName("Alpha")).thenReturn(Project.builder().name("Alpha").description("First").build()); + + Project updated = service.updateProject("Alpha", "Changed", 5, 4, 3, 2, 1); + + // Distinct score values catch any argument transposed between the signature and the builder + assertThat(updated.getImpact()).isEqualTo(5); + assertThat(updated.getConfidence()).isEqualTo(4); + assertThat(updated.getEase()).isEqualTo(3); + assertThat(updated.getReach()).isEqualTo(2); + assertThat(updated.getEffort()).isEqualTo(1); + } + + @Test + void updateProject_WithUnknownName_ShouldPropagateNotFoundAndNotUpdate() throws Exception { + when(projectRepository.findByName("Missing")) + .thenThrow(new ProjectRepository.ProjectNotFoundException("Project not found: Missing")); + + assertThrows(ProjectRepository.ProjectNotFoundException.class, + () -> service.updateProject("Missing", "Changed", 1, 1, 1, 1, 1)); + + verify(projectRepository, never()).update(any(Project.class)); + } + @Test void deleteProject_ShouldRemoveByNameAndNotReportUsage() throws Exception { service.deleteProject("Alpha");