From 450d5ef2f2b43758783f8a187bdb17ed9430d411 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:17:41 +0000 Subject: [PATCH 1/6] Initial plan From a8c935ccc4bb30ee15456bfa09a615d5c6759b9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:22:16 +0000 Subject: [PATCH 2/6] Initial exploration complete, planning markdown export implementation Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 55c68e6a39128e254c02dcd7e839a79fe6964484 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:27:34 +0000 Subject: [PATCH 3/6] Implement markdown export functionality for projects Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../parpt/command/ExportProjectsCommand.java | 48 +++++ .../parpt/repo/ProjectMarkdownWriter.java | 31 ++++ .../parpt/repo/ProjectMarkdownWriterImpl.java | 98 ++++++++++ src/main/resources/application.yaml | 3 +- .../command/ExportProjectsCommandTest.java | 135 ++++++++++++++ .../parpt/demo/SampleMarkdownGeneration.java | 71 ++++++++ .../MarkdownExportIntegrationTest.java | 98 ++++++++++ .../repo/ProjectMarkdownWriterImplTest.java | 172 ++++++++++++++++++ src/test/resources/application.yaml | 3 +- 9 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/preponderous/parpt/command/ExportProjectsCommand.java create mode 100644 src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriter.java create mode 100644 src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java create mode 100644 src/test/java/com/preponderous/parpt/command/ExportProjectsCommandTest.java create mode 100644 src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java create mode 100644 src/test/java/com/preponderous/parpt/integration/MarkdownExportIntegrationTest.java create mode 100644 src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java diff --git a/src/main/java/com/preponderous/parpt/command/ExportProjectsCommand.java b/src/main/java/com/preponderous/parpt/command/ExportProjectsCommand.java new file mode 100644 index 0000000..5caeb30 --- /dev/null +++ b/src/main/java/com/preponderous/parpt/command/ExportProjectsCommand.java @@ -0,0 +1,48 @@ +package com.preponderous.parpt.command; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.repo.ProjectMarkdownWriter; +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.List; + +@ShellComponent +public class ExportProjectsCommand { + + private final ProjectService projectService; + private final ProjectMarkdownWriter markdownWriter; + + public ExportProjectsCommand(ProjectService projectService, ProjectMarkdownWriter markdownWriter) { + this.projectService = projectService; + this.markdownWriter = markdownWriter; + } + + @ShellMethod(key = "export", value = "Exports all projects to Markdown format, sorted by score.") + public String execute( + @ShellOption(value = {"-s", "--sort"}, help = "Sort by 'ice' or 'rice' score (default: ice)", defaultValue = "ice") String sortBy + ) { + List projects = projectService.getProjects(); + + if (projects.isEmpty()) { + return "No projects found to export."; + } + + boolean sortByRice = "rice".equalsIgnoreCase(sortBy); + + if (!sortByRice && !"ice".equalsIgnoreCase(sortBy)) { + return "Invalid sort option. Use 'ice' or 'rice'."; + } + + try { + markdownWriter.writeMarkdown(projects, sortByRice); + String scoreType = sortByRice ? "RICE" : "ICE"; + return String.format("Successfully exported %d projects to projects.md, sorted by %s score.", + projects.size(), scoreType); + } catch (Exception e) { + return "Failed to export projects: " + e.getMessage(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriter.java b/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriter.java new file mode 100644 index 0000000..0fae036 --- /dev/null +++ b/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriter.java @@ -0,0 +1,31 @@ +package com.preponderous.parpt.repo; + +import com.preponderous.parpt.domain.Project; + +import java.util.List; + +/** + * Interface for writing project data to Markdown format. + * This interface provides methods to export project data + * in Obsidian-compatible markdown format. + */ +public interface ProjectMarkdownWriter { + /** + * Writes a list of projects to Markdown format. + * Projects should be sorted by their calculated scores. + * + * @param projects the list of projects to be written to Markdown + * @param sortByRice true to sort by RICE score, false to sort by ICE score + */ + void writeMarkdown(List projects, boolean sortByRice); + + /** + * Writes a list of projects to Markdown format. + * Projects will be sorted by ICE score by default. + * + * @param projects the list of projects to be written to Markdown + */ + default void writeMarkdown(List projects) { + writeMarkdown(projects, false); + } +} \ No newline at end of file diff --git a/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java b/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java new file mode 100644 index 0000000..3243252 --- /dev/null +++ b/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java @@ -0,0 +1,98 @@ +package com.preponderous.parpt.repo; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.score.ScoreCalculator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.FileWriter; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.List; + +@Component +public class ProjectMarkdownWriterImpl implements ProjectMarkdownWriter { + + private final String markdownFilePath; + private final ScoreCalculator scoreCalculator; + + public ProjectMarkdownWriterImpl( + @Value("${app.projects.markdown-file}") String markdownFilePath, + ScoreCalculator scoreCalculator) { + this.markdownFilePath = markdownFilePath; + this.scoreCalculator = scoreCalculator; + } + + @Override + public void writeMarkdown(List projects, boolean sortByRice) { + if (projects == null) { + throw new IllegalArgumentException("Projects list cannot be null"); + } + + try (FileWriter writer = new FileWriter(markdownFilePath)) { + // Write header + writer.write("# Project Priorities\n\n"); + writer.write("*Generated on " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + "*\n\n"); + + if (projects.isEmpty()) { + writer.write("No projects found.\n"); + return; + } + + // Sort projects by the chosen scoring method + List sortedProjects = projects.stream() + .sorted(sortByRice ? + Comparator.comparingDouble((Project p) -> scoreCalculator.rice(p)).reversed() : + Comparator.comparingDouble((Project p) -> scoreCalculator.ice(p)).reversed()) + .toList(); + + String scoreType = sortByRice ? "RICE" : "ICE"; + writer.write("*Sorted by " + scoreType + " score (highest to lowest)*\n\n"); + + // Write projects + for (int i = 0; i < sortedProjects.size(); i++) { + Project project = sortedProjects.get(i); + writeProject(writer, project, i + 1); + } + + } catch (IOException e) { + throw new RuntimeException("Failed to write projects to Markdown file", e); + } + } + + private void writeProject(FileWriter writer, Project project, int rank) throws IOException { + double iceScore = scoreCalculator.ice(project); + double riceScore = scoreCalculator.rice(project); + + writer.write("## " + rank + ". " + project.getName() + "\n\n"); + writer.write("**Description:** " + project.getDescription() + "\n\n"); + + // Scores summary + writer.write("### Scores\n"); + writer.write("- **ICE Score:** " + String.format("%.2f", iceScore) + "\n"); + writer.write("- **RICE Score:** " + String.format("%.2f", riceScore) + "\n\n"); + + // Individual components + writer.write("### Components\n"); + writer.write("- **Impact:** " + project.getImpact() + "/5 (" + getScoreDescription(project.getImpact()) + ")\n"); + writer.write("- **Confidence:** " + project.getConfidence() + "/5 (" + getScoreDescription(project.getConfidence()) + ")\n"); + writer.write("- **Ease:** " + project.getEase() + "/5 (" + getScoreDescription(project.getEase()) + ")\n"); + writer.write("- **Reach:** " + project.getReach() + "/5 (" + getScoreDescription(project.getReach()) + ")\n"); + writer.write("- **Effort:** " + project.getEffort() + "/5 (" + getScoreDescription(project.getEffort()) + ")\n\n"); + + writer.write("---\n\n"); + } + + private String getScoreDescription(int score) { + return switch (score) { + case 1 -> "very low"; + case 2 -> "low"; + case 3 -> "medium"; + case 4 -> "high"; + case 5 -> "very high"; + default -> String.valueOf(score); + }; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 6f12ad4..4509fb1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,4 +37,5 @@ parpt: - "Does this need ongoing work? (1=set and forget, 5=lots of upkeep) " app: projects: - file: projects.json \ No newline at end of file + file: projects.json + markdown-file: projects.md \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/command/ExportProjectsCommandTest.java b/src/test/java/com/preponderous/parpt/command/ExportProjectsCommandTest.java new file mode 100644 index 0000000..4b18b5c --- /dev/null +++ b/src/test/java/com/preponderous/parpt/command/ExportProjectsCommandTest.java @@ -0,0 +1,135 @@ +package com.preponderous.parpt.command; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.repo.ProjectMarkdownWriter; +import com.preponderous.parpt.service.ProjectService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ExportProjectsCommandTest { + + @Mock + private ProjectService projectService; + + @Mock + private ProjectMarkdownWriter markdownWriter; + + private ExportProjectsCommand exportCommand; + + @BeforeEach + void setUp() { + exportCommand = new ExportProjectsCommand(projectService, markdownWriter); + } + + @Test + void execute_WithProjectsAndDefaultSort_ShouldExportWithICE() { + // Given + List projects = Arrays.asList( + Project.builder().name("Project 1").description("Desc 1").build(), + Project.builder().name("Project 2").description("Desc 2").build() + ); + when(projectService.getProjects()).thenReturn(projects); + + // When + String result = exportCommand.execute("ice"); + + // Then + verify(markdownWriter).writeMarkdown(projects, false); + assertTrue(result.contains("Successfully exported 2 projects")); + assertTrue(result.contains("ICE score")); + } + + @Test + void execute_WithRiceSort_ShouldExportWithRICE() { + // Given + List projects = List.of( + Project.builder().name("Project 1").description("Desc 1").build() + ); + when(projectService.getProjects()).thenReturn(projects); + + // When + String result = exportCommand.execute("rice"); + + // Then + verify(markdownWriter).writeMarkdown(projects, true); + assertTrue(result.contains("Successfully exported 1 projects")); + assertTrue(result.contains("RICE score")); + } + + @Test + void execute_WithNoProjects_ShouldReturnNoProjectsMessage() { + // Given + when(projectService.getProjects()).thenReturn(Collections.emptyList()); + + // When + String result = exportCommand.execute("ice"); + + // Then + verify(markdownWriter, never()).writeMarkdown(any(), anyBoolean()); + assertEquals("No projects found to export.", result); + } + + @Test + void execute_WithInvalidSortOption_ShouldReturnErrorMessage() { + // Given + List projects = List.of( + Project.builder().name("Project 1").description("Desc 1").build() + ); + when(projectService.getProjects()).thenReturn(projects); + + // When + String result = exportCommand.execute("invalid"); + + // Then + verify(markdownWriter, never()).writeMarkdown(any(), anyBoolean()); + assertEquals("Invalid sort option. Use 'ice' or 'rice'.", result); + } + + @Test + void execute_WhenMarkdownWriterThrowsException_ShouldReturnErrorMessage() { + // Given + List projects = List.of( + Project.builder().name("Project 1").description("Desc 1").build() + ); + when(projectService.getProjects()).thenReturn(projects); + doThrow(new RuntimeException("Write failed")).when(markdownWriter).writeMarkdown(any(), anyBoolean()); + + // When + String result = exportCommand.execute("ice"); + + // Then + assertTrue(result.contains("Failed to export projects: Write failed")); + } + + @Test + void execute_WithCaseInsensitiveSortOptions_ShouldWork() { + // Given + List projects = List.of( + Project.builder().name("Project 1").description("Desc 1").build() + ); + when(projectService.getProjects()).thenReturn(projects); + + // When & Then + exportCommand.execute("ICE"); + verify(markdownWriter).writeMarkdown(projects, false); + + exportCommand.execute("Rice"); + verify(markdownWriter).writeMarkdown(projects, true); + + exportCommand.execute("RICE"); + verify(markdownWriter, times(2)).writeMarkdown(projects, true); + } +} \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java b/src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java new file mode 100644 index 0000000..cc43056 --- /dev/null +++ b/src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java @@ -0,0 +1,71 @@ +package com.preponderous.parpt.demo; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.repo.ProjectMarkdownWriterImpl; +import com.preponderous.parpt.score.ScoreCalculator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +/** + * Demo test to generate sample markdown output for verification + */ +class SampleMarkdownGeneration { + + @Test + void generateSampleMarkdown(@TempDir Path tempDir) throws IOException { + // Create sample projects + List projects = Arrays.asList( + Project.builder() + .name("Mobile App Redesign") + .description("Complete overhaul of our mobile application with modern UI/UX") + .impact(5).confidence(4).ease(2).reach(5).effort(4) + .build(), + + Project.builder() + .name("Quick Bug Fix") + .description("Fix critical login issue affecting 10% of users") + .impact(4).confidence(5).ease(5).reach(3).effort(1) + .build(), + + Project.builder() + .name("Database Migration") + .description("Migrate legacy database to modern cloud solution") + .impact(3).confidence(3).ease(1).reach(2).effort(5) + .build(), + + Project.builder() + .name("Feature Toggle System") + .description("Implement feature flags for better deployment control") + .impact(3).confidence(4).ease(3).reach(4).effort(3) + .build() + ); + + // Generate markdown files with both sorting options + Path iceFile = tempDir.resolve("sample-ice-sorted.md"); + Path riceFile = tempDir.resolve("sample-rice-sorted.md"); + + ScoreCalculator calculator = new ScoreCalculator(); + ProjectMarkdownWriterImpl writer = new ProjectMarkdownWriterImpl( + iceFile.toString(), calculator); + + // Generate ICE-sorted version + writer.writeMarkdown(projects, false); + + // Generate RICE-sorted version + writer = new ProjectMarkdownWriterImpl(riceFile.toString(), calculator); + writer.writeMarkdown(projects, true); + + // Output the content for verification + System.out.println("=== ICE SORTED MARKDOWN ==="); + System.out.println(Files.readString(iceFile)); + + System.out.println("\n\n=== RICE SORTED MARKDOWN ==="); + System.out.println(Files.readString(riceFile)); + } +} \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/integration/MarkdownExportIntegrationTest.java b/src/test/java/com/preponderous/parpt/integration/MarkdownExportIntegrationTest.java new file mode 100644 index 0000000..5f1ae32 --- /dev/null +++ b/src/test/java/com/preponderous/parpt/integration/MarkdownExportIntegrationTest.java @@ -0,0 +1,98 @@ +package com.preponderous.parpt.integration; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.repo.ProjectMarkdownWriter; +import com.preponderous.parpt.score.ScoreCalculator; +import com.preponderous.parpt.service.ProjectService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestPropertySource(properties = { + "app.projects.file=integration-test-projects.json", + "app.projects.markdown-file=integration-test-projects.md" +}) +class MarkdownExportIntegrationTest { + + @Autowired + private ProjectService projectService; + + @Autowired + private ProjectMarkdownWriter markdownWriter; + + @Autowired + private ScoreCalculator scoreCalculator; + + @Test + void endToEndMarkdownExport_ShouldCreateReadableMarkdownFile() throws Exception { + // Clean up any existing files + Files.deleteIfExists(Paths.get("integration-test-projects.json")); + Files.deleteIfExists(Paths.get("integration-test-projects.md")); + + // Create test projects with different scores + projectService.createProject( + "High Impact App", + "A mobile app with massive user base potential", + 5, 4, 3, 5, 4 // ICE: 60, RICE: 25 + ); + + projectService.createProject( + "Quick Win Feature", + "Easy feature that users will love", + 3, 5, 5, 3, 2 // ICE: 75, RICE: 22.5 + ); + + projectService.createProject( + "Technical Debt Fix", + "Important but not user-facing improvement", + 2, 4, 2, 1, 3 // ICE: 16, RICE: 2.67 + ); + + // Export to markdown sorted by ICE + List projects = projectService.getProjects(); + markdownWriter.writeMarkdown(projects, false); + + // Verify the markdown file was created and has correct content + assertTrue(Files.exists(Paths.get("integration-test-projects.md"))); + + String content = Files.readString(Paths.get("integration-test-projects.md")); + + // Check header and metadata + assertTrue(content.contains("# Project Priorities")); + assertTrue(content.contains("Sorted by ICE score")); + + // Check that projects are in the correct order (highest ICE first) + // Quick Win Feature (ICE: 75) should come before High Impact App (ICE: 60) + int quickWinIndex = content.indexOf("Quick Win Feature"); + int highImpactIndex = content.indexOf("High Impact App"); + int techDebtIndex = content.indexOf("Technical Debt Fix"); + + assertTrue(quickWinIndex < highImpactIndex, "Quick Win should come before High Impact"); + assertTrue(highImpactIndex < techDebtIndex, "High Impact should come before Tech Debt"); + + // Check that all expected content is present + assertTrue(content.contains("Easy feature that users will love")); + assertTrue(content.contains("ICE Score")); + assertTrue(content.contains("RICE Score")); + assertTrue(content.contains("Components")); + assertTrue(content.contains("Impact:** 5/5 (very high)")); + + // Test RICE sorting + markdownWriter.writeMarkdown(projects, true); + String riceContent = Files.readString(Paths.get("integration-test-projects.md")); + assertTrue(riceContent.contains("Sorted by RICE score")); + + // Clean up + Files.deleteIfExists(Paths.get("integration-test-projects.json")); + Files.deleteIfExists(Paths.get("integration-test-projects.md")); + } +} \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java b/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java new file mode 100644 index 0000000..bb7b32e --- /dev/null +++ b/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java @@ -0,0 +1,172 @@ +package com.preponderous.parpt.repo; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.score.ScoreCalculator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ProjectMarkdownWriterImplTest { + + private ProjectMarkdownWriterImpl markdownWriter; + private ScoreCalculator scoreCalculator; + private Path tempMarkdownFile; + + @BeforeEach + void setUp(@TempDir Path tempDir) throws IOException { + tempMarkdownFile = tempDir.resolve("test-projects.md"); + scoreCalculator = new ScoreCalculator(); + markdownWriter = new ProjectMarkdownWriterImpl( + tempMarkdownFile.toString(), + scoreCalculator + ); + } + + @Test + void writeMarkdown_ShouldCreateMarkdownFile() throws IOException { + // Given + Project project = Project.builder() + .name("Test Project") + .description("A test project") + .impact(4) + .confidence(3) + .ease(5) + .reach(2) + .effort(3) + .build(); + List projects = List.of(project); + + // When + markdownWriter.writeMarkdown(projects, false); + + // Then + assertTrue(Files.exists(tempMarkdownFile)); + String content = Files.readString(tempMarkdownFile); + assertAll( + () -> assertTrue(content.contains("# Project Priorities")), + () -> assertTrue(content.contains("Test Project")), + () -> assertTrue(content.contains("A test project")), + () -> assertTrue(content.contains("ICE Score")), + () -> assertTrue(content.contains("RICE Score")), + () -> assertTrue(content.contains("Sorted by ICE score")) + ); + } + + @Test + void writeMarkdown_ShouldSortByICEByDefault() throws IOException { + // Given + Project highICE = Project.builder() + .name("High ICE") + .description("High ICE project") + .impact(5).confidence(5).ease(5) + .reach(1).effort(5) + .build(); + + Project lowICE = Project.builder() + .name("Low ICE") + .description("Low ICE project") + .impact(1).confidence(1).ease(1) + .reach(5).effort(1) + .build(); + + List projects = Arrays.asList(lowICE, highICE); + + // When + markdownWriter.writeMarkdown(projects); + + // Then + String content = Files.readString(tempMarkdownFile); + int highICEIndex = content.indexOf("High ICE"); + int lowICEIndex = content.indexOf("Low ICE"); + assertTrue(highICEIndex < lowICEIndex, "High ICE project should appear before Low ICE project"); + } + + @Test + void writeMarkdown_ShouldSortByRICEWhenRequested() throws IOException { + // Given + Project highRICE = Project.builder() + .name("High RICE") + .description("High RICE project") + .impact(5).confidence(5).ease(1) + .reach(5).effort(1) + .build(); + + Project lowRICE = Project.builder() + .name("Low RICE") + .description("Low RICE project") + .impact(1).confidence(1).ease(5) + .reach(1).effort(5) + .build(); + + List projects = Arrays.asList(lowRICE, highRICE); + + // When + markdownWriter.writeMarkdown(projects, true); + + // Then + String content = Files.readString(tempMarkdownFile); + assertAll( + () -> assertTrue(content.contains("Sorted by RICE score")), + () -> assertTrue(content.indexOf("High RICE") < content.indexOf("Low RICE")) + ); + } + + @Test + void writeMarkdown_WithEmptyList_ShouldWriteNoProjectsMessage() throws IOException { + // Given + List projects = List.of(); + + // When + markdownWriter.writeMarkdown(projects, false); + + // Then + String content = Files.readString(tempMarkdownFile); + assertAll( + () -> assertTrue(content.contains("# Project Priorities")), + () -> assertTrue(content.contains("No projects found")) + ); + } + + @Test + void writeMarkdown_WithNullList_ShouldThrowException() { + // When & Then + assertThrows(IllegalArgumentException.class, + () -> markdownWriter.writeMarkdown(null, false)); + } + + @Test + void writeMarkdown_ShouldIncludeAllProjectDetails() throws IOException { + // Given + Project project = Project.builder() + .name("Detailed Project") + .description("A project with all details") + .impact(4) + .confidence(3) + .ease(2) + .reach(5) + .effort(3) + .build(); + List projects = List.of(project); + + // When + markdownWriter.writeMarkdown(projects, false); + + // Then + String content = Files.readString(tempMarkdownFile); + assertAll( + () -> assertTrue(content.contains("Impact:** 4/5 (high)")), + () -> assertTrue(content.contains("Confidence:** 3/5 (medium)")), + () -> assertTrue(content.contains("Ease:** 2/5 (low)")), + () -> assertTrue(content.contains("Reach:** 5/5 (very high)")), + () -> assertTrue(content.contains("Effort:** 3/5 (medium)")) + ); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 66f973e..ffcf904 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -38,4 +38,5 @@ parpt: - "[TEST] Effort 4: Ongoing work (1-5): " app: projects: - file: test-projects.json \ No newline at end of file + file: test-projects.json + markdown-file: test-projects.md \ No newline at end of file From ca468ef435468c0ed0e4310e8fba155367b1b26d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:29:36 +0000 Subject: [PATCH 4/6] Update README documentation and clean up temporary files Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- README.md | 32 +++++++-- .../parpt/demo/SampleMarkdownGeneration.java | 71 ------------------- 2 files changed, 25 insertions(+), 78 deletions(-) delete mode 100644 src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java diff --git a/README.md b/README.md index 99debd1..d8d73f0 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Parpt is an interactive CLI tool that helps developers, indie creators and teams - Guided project scoring using ICE and RICE methods - Calculate ICE (Impact, Confidence, Ease) scores - Calculate RICE (Reach, Impact, Confidence, Effort) scores -- Save project entries to Markdown and JSON -- Obsidian-compatible for review workflows +- Export projects to Markdown and JSON formats +- Obsidian-compatible markdown export with sorting by ICE/RICE scores - Rank and sort projects by monetization, potential, feasibility and effort - Spring Boot architecture with interactive shell - 100% local-first and open source @@ -30,19 +30,37 @@ java -jar build/libs/parpt.jar Run the CLI: java -jar parpt.jar +Available commands: +- `create` - Create a new project with guided scoring +- `list` - List all projects with scores +- `view ` - View detailed project information +- `export` - Export all projects to Markdown format +- `help` - Show available commands + You'll be prompted to enter: - Project name and description - Detailed scoring for each category (1-5 scale) - Parpt will then: - Calculate ICE and RICE scores - - Save the results to `projects.json` and `projects.md` + - Save the results to `projects.json` + - Allow you to export to `projects.md` sorted by priority - Help you sort and review your efforts over time +### Export Examples +```bash +# Export projects sorted by ICE score (default) +export + +# Export projects sorted by RICE score +export --sort rice +``` + ## Roadmap -- [ ] Project input and validation loop -- [ ] Score calculation engine -- [ ] Markdown and JSON writer modules -- [ ] CLI configuration and persistence +- [x] Project input and validation loop +- [x] Score calculation engine +- [x] Markdown and JSON writer modules +- [x] CLI configuration and persistence +- [x] Obsidian-compatible markdown export with sorting - [ ] Visualization of project scores - [ ] Batch project comparison features diff --git a/src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java b/src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java deleted file mode 100644 index cc43056..0000000 --- a/src/test/java/com/preponderous/parpt/demo/SampleMarkdownGeneration.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.preponderous.parpt.demo; - -import com.preponderous.parpt.domain.Project; -import com.preponderous.parpt.repo.ProjectMarkdownWriterImpl; -import com.preponderous.parpt.score.ScoreCalculator; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; - -/** - * Demo test to generate sample markdown output for verification - */ -class SampleMarkdownGeneration { - - @Test - void generateSampleMarkdown(@TempDir Path tempDir) throws IOException { - // Create sample projects - List projects = Arrays.asList( - Project.builder() - .name("Mobile App Redesign") - .description("Complete overhaul of our mobile application with modern UI/UX") - .impact(5).confidence(4).ease(2).reach(5).effort(4) - .build(), - - Project.builder() - .name("Quick Bug Fix") - .description("Fix critical login issue affecting 10% of users") - .impact(4).confidence(5).ease(5).reach(3).effort(1) - .build(), - - Project.builder() - .name("Database Migration") - .description("Migrate legacy database to modern cloud solution") - .impact(3).confidence(3).ease(1).reach(2).effort(5) - .build(), - - Project.builder() - .name("Feature Toggle System") - .description("Implement feature flags for better deployment control") - .impact(3).confidence(4).ease(3).reach(4).effort(3) - .build() - ); - - // Generate markdown files with both sorting options - Path iceFile = tempDir.resolve("sample-ice-sorted.md"); - Path riceFile = tempDir.resolve("sample-rice-sorted.md"); - - ScoreCalculator calculator = new ScoreCalculator(); - ProjectMarkdownWriterImpl writer = new ProjectMarkdownWriterImpl( - iceFile.toString(), calculator); - - // Generate ICE-sorted version - writer.writeMarkdown(projects, false); - - // Generate RICE-sorted version - writer = new ProjectMarkdownWriterImpl(riceFile.toString(), calculator); - writer.writeMarkdown(projects, true); - - // Output the content for verification - System.out.println("=== ICE SORTED MARKDOWN ==="); - System.out.println(Files.readString(iceFile)); - - System.out.println("\n\n=== RICE SORTED MARKDOWN ==="); - System.out.println(Files.readString(riceFile)); - } -} \ No newline at end of file From 9ba947432a738528c0d580d64dca5f9aeafb7b45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 02:12:04 +0000 Subject: [PATCH 5/6] Extract components and add comprehensive unit tests for markdown export Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../parpt/export/MarkdownFormatter.java | 100 +++++++++ .../parpt/export/ProjectSorter.java | 61 ++++++ .../export/ScoreDescriptionProvider.java | 28 +++ .../parpt/repo/ProjectMarkdownWriterImpl.java | 69 ++---- .../parpt/export/MarkdownFormatterTest.java | 205 ++++++++++++++++++ .../parpt/export/ProjectSorterTest.java | 204 +++++++++++++++++ .../export/ScoreDescriptionProviderTest.java | 61 ++++++ .../repo/ProjectMarkdownWriterImplTest.java | 160 +++++++------- 8 files changed, 751 insertions(+), 137 deletions(-) create mode 100644 src/main/java/com/preponderous/parpt/export/MarkdownFormatter.java create mode 100644 src/main/java/com/preponderous/parpt/export/ProjectSorter.java create mode 100644 src/main/java/com/preponderous/parpt/export/ScoreDescriptionProvider.java create mode 100644 src/test/java/com/preponderous/parpt/export/MarkdownFormatterTest.java create mode 100644 src/test/java/com/preponderous/parpt/export/ProjectSorterTest.java create mode 100644 src/test/java/com/preponderous/parpt/export/ScoreDescriptionProviderTest.java diff --git a/src/main/java/com/preponderous/parpt/export/MarkdownFormatter.java b/src/main/java/com/preponderous/parpt/export/MarkdownFormatter.java new file mode 100644 index 0000000..e97402d --- /dev/null +++ b/src/main/java/com/preponderous/parpt/export/MarkdownFormatter.java @@ -0,0 +1,100 @@ +package com.preponderous.parpt.export; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.score.ScoreCalculator; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Component responsible for formatting project data into markdown strings. + * This class handles the actual markdown formatting logic without dealing with file I/O. + */ +@Component +public class MarkdownFormatter { + + private final ScoreCalculator scoreCalculator; + private final ScoreDescriptionProvider scoreDescriptionProvider; + + public MarkdownFormatter(ScoreCalculator scoreCalculator, ScoreDescriptionProvider scoreDescriptionProvider) { + this.scoreCalculator = scoreCalculator; + this.scoreDescriptionProvider = scoreDescriptionProvider; + } + + /** + * Generates the markdown header with timestamp and sorting information. + * + * @param sortByRice true if sorted by RICE, false if sorted by ICE + * @return formatted header string + */ + public String formatHeader(boolean sortByRice) { + StringBuilder header = new StringBuilder(); + header.append("# Project Priorities\n\n"); + header.append("*Generated on ") + .append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .append("*\n\n"); + + String scoreType = sortByRice ? "RICE" : "ICE"; + header.append("*Sorted by ").append(scoreType).append(" score (highest to lowest)*\n\n"); + + return header.toString(); + } + + /** + * Formats a single project into markdown. + * + * @param project the project to format + * @param rank the ranking position (1, 2, 3, etc.) + * @return formatted project markdown string + */ + public String formatProject(Project project, int rank) { + StringBuilder content = new StringBuilder(); + + double iceScore = scoreCalculator.ice(project); + double riceScore = scoreCalculator.rice(project); + + // Project header and description + content.append("## ").append(rank).append(". ").append(project.getName()).append("\n\n"); + content.append("**Description:** ").append(project.getDescription()).append("\n\n"); + + // Scores summary + content.append("### Scores\n"); + content.append("- **ICE Score:** ").append(String.format("%.2f", iceScore)).append("\n"); + content.append("- **RICE Score:** ").append(String.format("%.2f", riceScore)).append("\n\n"); + + // Individual components + content.append("### Components\n"); + content.append(formatScoreComponent("Impact", project.getImpact())); + content.append(formatScoreComponent("Confidence", project.getConfidence())); + content.append(formatScoreComponent("Ease", project.getEase())); + content.append(formatScoreComponent("Reach", project.getReach())); + content.append(formatScoreComponent("Effort", project.getEffort())); + content.append("\n"); + + content.append("---\n\n"); + + return content.toString(); + } + + /** + * Formats a single score component line. + * + * @param componentName the name of the component (Impact, Confidence, etc.) + * @param score the numerical score (1-5) + * @return formatted component line + */ + private String formatScoreComponent(String componentName, int score) { + return "- **" + componentName + ":** " + score + "/5 (" + + scoreDescriptionProvider.getDescription(score) + ")\n"; + } + + /** + * Formats the "no projects found" message. + * + * @return formatted no projects message + */ + public String formatNoProjectsMessage() { + return "No projects found.\n"; + } +} \ No newline at end of file diff --git a/src/main/java/com/preponderous/parpt/export/ProjectSorter.java b/src/main/java/com/preponderous/parpt/export/ProjectSorter.java new file mode 100644 index 0000000..c341fb4 --- /dev/null +++ b/src/main/java/com/preponderous/parpt/export/ProjectSorter.java @@ -0,0 +1,61 @@ +package com.preponderous.parpt.export; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.score.ScoreCalculator; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +/** + * Component responsible for sorting projects by their calculated scores. + * This separates the sorting logic from the formatting logic. + */ +@Component +public class ProjectSorter { + + private final ScoreCalculator scoreCalculator; + + public ProjectSorter(ScoreCalculator scoreCalculator) { + this.scoreCalculator = scoreCalculator; + } + + /** + * Sorts a list of projects by their calculated scores. + * + * @param projects the list of projects to sort + * @param sortByRice true to sort by RICE score, false to sort by ICE score + * @return new list with projects sorted by score (highest to lowest) + */ + public List sortByScore(List projects, boolean sortByRice) { + if (projects == null) { + throw new IllegalArgumentException("Projects list cannot be null"); + } + + return projects.stream() + .sorted(sortByRice ? + Comparator.comparingDouble((Project p) -> scoreCalculator.rice(p)).reversed() : + Comparator.comparingDouble((Project p) -> scoreCalculator.ice(p)).reversed()) + .toList(); + } + + /** + * Sorts projects by ICE score (highest to lowest). + * + * @param projects the list of projects to sort + * @return new list with projects sorted by ICE score + */ + public List sortByIce(List projects) { + return sortByScore(projects, false); + } + + /** + * Sorts projects by RICE score (highest to lowest). + * + * @param projects the list of projects to sort + * @return new list with projects sorted by RICE score + */ + public List sortByRice(List projects) { + return sortByScore(projects, true); + } +} \ No newline at end of file diff --git a/src/main/java/com/preponderous/parpt/export/ScoreDescriptionProvider.java b/src/main/java/com/preponderous/parpt/export/ScoreDescriptionProvider.java new file mode 100644 index 0000000..f45248c --- /dev/null +++ b/src/main/java/com/preponderous/parpt/export/ScoreDescriptionProvider.java @@ -0,0 +1,28 @@ +package com.preponderous.parpt.export; + +import org.springframework.stereotype.Component; + +/** + * Component responsible for providing descriptive text for numerical scores. + * This centralizes the score-to-description mapping logic. + */ +@Component +public class ScoreDescriptionProvider { + + /** + * Converts a numerical score (1-5) to a descriptive text. + * + * @param score the numerical score + * @return descriptive text for the score + */ + public String getDescription(int score) { + return switch (score) { + case 1 -> "very low"; + case 2 -> "low"; + case 3 -> "medium"; + case 4 -> "high"; + case 5 -> "very high"; + default -> String.valueOf(score); + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java b/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java index 3243252..113c47e 100644 --- a/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java +++ b/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java @@ -1,28 +1,29 @@ package com.preponderous.parpt.repo; import com.preponderous.parpt.domain.Project; -import com.preponderous.parpt.score.ScoreCalculator; +import com.preponderous.parpt.export.MarkdownFormatter; +import com.preponderous.parpt.export.ProjectSorter; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.io.FileWriter; import java.io.IOException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Comparator; import java.util.List; @Component public class ProjectMarkdownWriterImpl implements ProjectMarkdownWriter { private final String markdownFilePath; - private final ScoreCalculator scoreCalculator; + private final MarkdownFormatter markdownFormatter; + private final ProjectSorter projectSorter; public ProjectMarkdownWriterImpl( @Value("${app.projects.markdown-file}") String markdownFilePath, - ScoreCalculator scoreCalculator) { + MarkdownFormatter markdownFormatter, + ProjectSorter projectSorter) { this.markdownFilePath = markdownFilePath; - this.scoreCalculator = scoreCalculator; + this.markdownFormatter = markdownFormatter; + this.projectSorter = projectSorter; } @Override @@ -33,66 +34,22 @@ public void writeMarkdown(List projects, boolean sortByRice) { try (FileWriter writer = new FileWriter(markdownFilePath)) { // Write header - writer.write("# Project Priorities\n\n"); - writer.write("*Generated on " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + "*\n\n"); + writer.write(markdownFormatter.formatHeader(sortByRice)); if (projects.isEmpty()) { - writer.write("No projects found.\n"); + writer.write(markdownFormatter.formatNoProjectsMessage()); return; } - // Sort projects by the chosen scoring method - List sortedProjects = projects.stream() - .sorted(sortByRice ? - Comparator.comparingDouble((Project p) -> scoreCalculator.rice(p)).reversed() : - Comparator.comparingDouble((Project p) -> scoreCalculator.ice(p)).reversed()) - .toList(); - - String scoreType = sortByRice ? "RICE" : "ICE"; - writer.write("*Sorted by " + scoreType + " score (highest to lowest)*\n\n"); - - // Write projects + // Sort projects and write them + List sortedProjects = projectSorter.sortByScore(projects, sortByRice); for (int i = 0; i < sortedProjects.size(); i++) { Project project = sortedProjects.get(i); - writeProject(writer, project, i + 1); + writer.write(markdownFormatter.formatProject(project, i + 1)); } } catch (IOException e) { throw new RuntimeException("Failed to write projects to Markdown file", e); } } - - private void writeProject(FileWriter writer, Project project, int rank) throws IOException { - double iceScore = scoreCalculator.ice(project); - double riceScore = scoreCalculator.rice(project); - - writer.write("## " + rank + ". " + project.getName() + "\n\n"); - writer.write("**Description:** " + project.getDescription() + "\n\n"); - - // Scores summary - writer.write("### Scores\n"); - writer.write("- **ICE Score:** " + String.format("%.2f", iceScore) + "\n"); - writer.write("- **RICE Score:** " + String.format("%.2f", riceScore) + "\n\n"); - - // Individual components - writer.write("### Components\n"); - writer.write("- **Impact:** " + project.getImpact() + "/5 (" + getScoreDescription(project.getImpact()) + ")\n"); - writer.write("- **Confidence:** " + project.getConfidence() + "/5 (" + getScoreDescription(project.getConfidence()) + ")\n"); - writer.write("- **Ease:** " + project.getEase() + "/5 (" + getScoreDescription(project.getEase()) + ")\n"); - writer.write("- **Reach:** " + project.getReach() + "/5 (" + getScoreDescription(project.getReach()) + ")\n"); - writer.write("- **Effort:** " + project.getEffort() + "/5 (" + getScoreDescription(project.getEffort()) + ")\n\n"); - - writer.write("---\n\n"); - } - - private String getScoreDescription(int score) { - return switch (score) { - case 1 -> "very low"; - case 2 -> "low"; - case 3 -> "medium"; - case 4 -> "high"; - case 5 -> "very high"; - default -> String.valueOf(score); - }; - } } \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/export/MarkdownFormatterTest.java b/src/test/java/com/preponderous/parpt/export/MarkdownFormatterTest.java new file mode 100644 index 0000000..d80ac8f --- /dev/null +++ b/src/test/java/com/preponderous/parpt/export/MarkdownFormatterTest.java @@ -0,0 +1,205 @@ +package com.preponderous.parpt.export; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.score.ScoreCalculator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MarkdownFormatterTest { + + @Mock + private ScoreCalculator scoreCalculator; + + @Mock + private ScoreDescriptionProvider scoreDescriptionProvider; + + private MarkdownFormatter markdownFormatter; + + @BeforeEach + void setUp() { + markdownFormatter = new MarkdownFormatter(scoreCalculator, scoreDescriptionProvider); + } + + @Test + void formatHeader_WithICESort_ShouldIncludeICEInHeader() { + // When + String header = markdownFormatter.formatHeader(false); + + // Then + assertAll( + () -> assertTrue(header.contains("# Project Priorities")), + () -> assertTrue(header.contains("Generated on")), + () -> assertTrue(header.contains("Sorted by ICE score")), + () -> assertFalse(header.contains("Sorted by RICE score")) + ); + } + + @Test + void formatHeader_WithRICESort_ShouldIncludeRICEInHeader() { + // When + String header = markdownFormatter.formatHeader(true); + + // Then + assertAll( + () -> assertTrue(header.contains("# Project Priorities")), + () -> assertTrue(header.contains("Generated on")), + () -> assertTrue(header.contains("Sorted by RICE score")), + () -> assertFalse(header.contains("Sorted by ICE score")) + ); + } + + @Test + void formatHeader_ShouldIncludeTimestamp() { + // When + String header = markdownFormatter.formatHeader(false); + + // Then + assertTrue(header.contains("Generated on")); + assertTrue(header.matches("(?s).*Generated on \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.*")); + } + + @Test + void formatProject_ShouldIncludeAllProjectDetails() { + // Given + Project project = Project.builder() + .name("Test Project") + .description("A test project description") + .impact(4) + .confidence(3) + .ease(5) + .reach(2) + .effort(3) + .build(); + + when(scoreCalculator.ice(project)).thenReturn(60.0); + when(scoreCalculator.rice(project)).thenReturn(8.0); + when(scoreDescriptionProvider.getDescription(4)).thenReturn("high"); + when(scoreDescriptionProvider.getDescription(3)).thenReturn("medium"); + when(scoreDescriptionProvider.getDescription(5)).thenReturn("very high"); + when(scoreDescriptionProvider.getDescription(2)).thenReturn("low"); + + // When + String result = markdownFormatter.formatProject(project, 1); + + // Then + assertAll( + () -> assertTrue(result.contains("## 1. Test Project")), + () -> assertTrue(result.contains("**Description:** A test project description")), + () -> assertTrue(result.contains("ICE Score:** 60.00")), + () -> assertTrue(result.contains("RICE Score:** 8.00")), + () -> assertTrue(result.contains("**Impact:** 4/5 (high)")), + () -> assertTrue(result.contains("**Confidence:** 3/5 (medium)")), + () -> assertTrue(result.contains("**Ease:** 5/5 (very high)")), + () -> assertTrue(result.contains("**Reach:** 2/5 (low)")), + () -> assertTrue(result.contains("**Effort:** 3/5 (medium)")), + () -> assertTrue(result.contains("---")) + ); + } + + @Test + void formatProject_ShouldCallScoreCalculatorForBothScores() { + // Given + Project project = Project.builder() + .name("Test Project") + .description("Test description") + .impact(1).confidence(1).ease(1).reach(1).effort(1) + .build(); + + when(scoreCalculator.ice(project)).thenReturn(1.0); + when(scoreCalculator.rice(project)).thenReturn(0.2); + when(scoreDescriptionProvider.getDescription(anyInt())).thenReturn("very low"); + + // When + markdownFormatter.formatProject(project, 2); + + // Then + verify(scoreCalculator).ice(project); + verify(scoreCalculator).rice(project); + } + + @Test + void formatProject_ShouldCallScoreDescriptionProviderForAllComponents() { + // Given + Project project = Project.builder() + .name("Test Project") + .description("Test description") + .impact(1).confidence(2).ease(3).reach(4).effort(5) + .build(); + + when(scoreCalculator.ice(project)).thenReturn(6.0); + when(scoreCalculator.rice(project)).thenReturn(1.6); + when(scoreDescriptionProvider.getDescription(anyInt())).thenReturn("test"); + + // When + markdownFormatter.formatProject(project, 1); + + // Then + verify(scoreDescriptionProvider).getDescription(1); // Impact + verify(scoreDescriptionProvider).getDescription(2); // Confidence + verify(scoreDescriptionProvider).getDescription(3); // Ease + verify(scoreDescriptionProvider).getDescription(4); // Reach + verify(scoreDescriptionProvider).getDescription(5); // Effort + } + + @Test + void formatProject_WithDifferentRanks_ShouldIncludeCorrectRank() { + // Given + Project project = Project.builder() + .name("Ranked Project") + .description("Test description") + .impact(3).confidence(3).ease(3).reach(3).effort(3) + .build(); + + when(scoreCalculator.ice(project)).thenReturn(27.0); + when(scoreCalculator.rice(project)).thenReturn(9.0); + when(scoreDescriptionProvider.getDescription(anyInt())).thenReturn("medium"); + + // When & Then + String result1 = markdownFormatter.formatProject(project, 1); + String result5 = markdownFormatter.formatProject(project, 5); + + assertTrue(result1.contains("## 1. Ranked Project")); + assertTrue(result5.contains("## 5. Ranked Project")); + } + + @Test + void formatNoProjectsMessage_ShouldReturnStandardMessage() { + // When + String message = markdownFormatter.formatNoProjectsMessage(); + + // Then + assertEquals("No projects found.\n", message); + } + + @Test + void formatProject_ShouldFormatScoresWithTwoDecimalPlaces() { + // Given + Project project = Project.builder() + .name("Decimal Test") + .description("Test description") + .impact(3).confidence(4).ease(2).reach(1).effort(2) + .build(); + + when(scoreCalculator.ice(project)).thenReturn(24.123456); + when(scoreCalculator.rice(project)).thenReturn(6.789123); + when(scoreDescriptionProvider.getDescription(anyInt())).thenReturn("test"); + + // When + String result = markdownFormatter.formatProject(project, 1); + + // Then + assertAll( + () -> assertTrue(result.contains("ICE Score:** 24.12")), + () -> assertTrue(result.contains("RICE Score:** 6.79")), + () -> assertFalse(result.contains("24.123456")), + () -> assertFalse(result.contains("6.789123")) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/export/ProjectSorterTest.java b/src/test/java/com/preponderous/parpt/export/ProjectSorterTest.java new file mode 100644 index 0000000..b55ef2e --- /dev/null +++ b/src/test/java/com/preponderous/parpt/export/ProjectSorterTest.java @@ -0,0 +1,204 @@ +package com.preponderous.parpt.export; + +import com.preponderous.parpt.domain.Project; +import com.preponderous.parpt.score.ScoreCalculator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProjectSorterTest { + + @Mock + private ScoreCalculator scoreCalculator; + + private ProjectSorter projectSorter; + + @BeforeEach + void setUp() { + projectSorter = new ProjectSorter(scoreCalculator); + } + + @Test + void sortByScore_WithNullProjects_ShouldThrowException() { + // When & Then + assertThrows(IllegalArgumentException.class, + () -> projectSorter.sortByScore(null, false)); + } + + @Test + void sortByScore_WithEmptyList_ShouldReturnEmptyList() { + // Given + List emptyList = Collections.emptyList(); + + // When + List result = projectSorter.sortByScore(emptyList, false); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + void sortByScore_WithICESorting_ShouldSortByICEScore() { + // Given + Project project1 = Project.builder().name("Low ICE").build(); + Project project2 = Project.builder().name("High ICE").build(); + Project project3 = Project.builder().name("Medium ICE").build(); + + List projects = Arrays.asList(project1, project2, project3); + + when(scoreCalculator.ice(project1)).thenReturn(10.0); + when(scoreCalculator.ice(project2)).thenReturn(50.0); + when(scoreCalculator.ice(project3)).thenReturn(30.0); + + // When + List result = projectSorter.sortByScore(projects, false); + + // Then + assertEquals(3, result.size()); + assertEquals("High ICE", result.get(0).getName()); + assertEquals("Medium ICE", result.get(1).getName()); + assertEquals("Low ICE", result.get(2).getName()); + + verify(scoreCalculator, atLeastOnce()).ice(project1); + verify(scoreCalculator, atLeastOnce()).ice(project2); + verify(scoreCalculator, atLeastOnce()).ice(project3); + verify(scoreCalculator, never()).rice(any()); + } + + @Test + void sortByScore_WithRICESorting_ShouldSortByRICEScore() { + // Given + Project project1 = Project.builder().name("Low RICE").build(); + Project project2 = Project.builder().name("High RICE").build(); + Project project3 = Project.builder().name("Medium RICE").build(); + + List projects = Arrays.asList(project1, project2, project3); + + when(scoreCalculator.rice(project1)).thenReturn(5.0); + when(scoreCalculator.rice(project2)).thenReturn(25.0); + when(scoreCalculator.rice(project3)).thenReturn(15.0); + + // When + List result = projectSorter.sortByScore(projects, true); + + // Then + assertEquals(3, result.size()); + assertEquals("High RICE", result.get(0).getName()); + assertEquals("Medium RICE", result.get(1).getName()); + assertEquals("Low RICE", result.get(2).getName()); + + verify(scoreCalculator, atLeastOnce()).rice(project1); + verify(scoreCalculator, atLeastOnce()).rice(project2); + verify(scoreCalculator, atLeastOnce()).rice(project3); + verify(scoreCalculator, never()).ice(any()); + } + + @Test + void sortByScore_WithEqualScores_ShouldMaintainStableOrder() { + // Given + Project project1 = Project.builder().name("First").build(); + Project project2 = Project.builder().name("Second").build(); + + List projects = Arrays.asList(project1, project2); + + when(scoreCalculator.ice(project1)).thenReturn(20.0); + when(scoreCalculator.ice(project2)).thenReturn(20.0); + + // When + List result = projectSorter.sortByScore(projects, false); + + // Then + assertEquals(2, result.size()); + // Should maintain original order when scores are equal + assertEquals("First", result.get(0).getName()); + assertEquals("Second", result.get(1).getName()); + } + + @Test + void sortByScore_ShouldNotModifyOriginalList() { + // Given + Project project1 = Project.builder().name("Project 1").build(); + Project project2 = Project.builder().name("Project 2").build(); + + List originalProjects = Arrays.asList(project1, project2); + + when(scoreCalculator.ice(project1)).thenReturn(10.0); + when(scoreCalculator.ice(project2)).thenReturn(20.0); + + // When + List sortedProjects = projectSorter.sortByScore(originalProjects, false); + + // Then + assertEquals("Project 1", originalProjects.get(0).getName()); + assertEquals("Project 2", originalProjects.get(1).getName()); + assertEquals("Project 2", sortedProjects.get(0).getName()); + assertEquals("Project 1", sortedProjects.get(1).getName()); + assertNotSame(originalProjects, sortedProjects); + } + + @Test + void sortByIce_ShouldDelegateToSortByScoreWithFalse() { + // Given + Project project1 = Project.builder().name("Project 1").build(); + Project project2 = Project.builder().name("Project 2").build(); + List projects = List.of(project1, project2); + when(scoreCalculator.ice(project1)).thenReturn(25.0); + when(scoreCalculator.ice(project2)).thenReturn(15.0); + + // When + List result = projectSorter.sortByIce(projects); + + // Then + assertEquals(2, result.size()); + assertEquals("Project 1", result.get(0).getName()); + assertEquals("Project 2", result.get(1).getName()); + verify(scoreCalculator, atLeastOnce()).ice(project1); + verify(scoreCalculator, atLeastOnce()).ice(project2); + verify(scoreCalculator, never()).rice(any()); + } + + @Test + void sortByRice_ShouldDelegateToSortByScoreWithTrue() { + // Given + Project project1 = Project.builder().name("Project 1").build(); + Project project2 = Project.builder().name("Project 2").build(); + List projects = List.of(project1, project2); + when(scoreCalculator.rice(project1)).thenReturn(12.5); + when(scoreCalculator.rice(project2)).thenReturn(8.5); + + // When + List result = projectSorter.sortByRice(projects); + + // Then + assertEquals(2, result.size()); + assertEquals("Project 1", result.get(0).getName()); + assertEquals("Project 2", result.get(1).getName()); + verify(scoreCalculator, atLeastOnce()).rice(project1); + verify(scoreCalculator, atLeastOnce()).rice(project2); + verify(scoreCalculator, never()).ice(any()); + } + + @Test + void sortByScore_WithSingleProject_ShouldReturnSingleProjectList() { + // Given + Project project = Project.builder().name("Only Project").build(); + List projects = List.of(project); + + // When + List result = projectSorter.sortByScore(projects, false); + + // Then + assertEquals(1, result.size()); + assertEquals("Only Project", result.get(0).getName()); + } +} \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/export/ScoreDescriptionProviderTest.java b/src/test/java/com/preponderous/parpt/export/ScoreDescriptionProviderTest.java new file mode 100644 index 0000000..3ad337b --- /dev/null +++ b/src/test/java/com/preponderous/parpt/export/ScoreDescriptionProviderTest.java @@ -0,0 +1,61 @@ +package com.preponderous.parpt.export; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; + +class ScoreDescriptionProviderTest { + + private ScoreDescriptionProvider scoreDescriptionProvider; + + @BeforeEach + void setUp() { + scoreDescriptionProvider = new ScoreDescriptionProvider(); + } + + @ParameterizedTest + @CsvSource({ + "1, very low", + "2, low", + "3, medium", + "4, high", + "5, very high" + }) + void getDescription_WithValidScores_ShouldReturnCorrectDescription(int score, String expectedDescription) { + // When + String result = scoreDescriptionProvider.getDescription(score); + + // Then + assertEquals(expectedDescription, result); + } + + @Test + void getDescription_WithInvalidScore_ShouldReturnStringValue() { + // When & Then + assertEquals("0", scoreDescriptionProvider.getDescription(0)); + assertEquals("6", scoreDescriptionProvider.getDescription(6)); + assertEquals("-1", scoreDescriptionProvider.getDescription(-1)); + assertEquals("10", scoreDescriptionProvider.getDescription(10)); + } + + @Test + void getDescription_ShouldBeDeterministicForSameInput() { + // When + String result1 = scoreDescriptionProvider.getDescription(3); + String result2 = scoreDescriptionProvider.getDescription(3); + + // Then + assertEquals(result1, result2); + assertEquals("medium", result1); + } + + @Test + void getDescription_ShouldHandleEdgeCases() { + // When & Then + assertEquals("very low", scoreDescriptionProvider.getDescription(1)); + assertEquals("very high", scoreDescriptionProvider.getDescription(5)); + } +} \ No newline at end of file diff --git a/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java b/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java index bb7b32e..9c3a839 100644 --- a/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java +++ b/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java @@ -1,10 +1,14 @@ package com.preponderous.parpt.repo; import com.preponderous.parpt.domain.Project; -import com.preponderous.parpt.score.ScoreCalculator; +import com.preponderous.parpt.export.MarkdownFormatter; +import com.preponderous.parpt.export.ProjectSorter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; import java.nio.file.Files; @@ -13,20 +17,27 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +@ExtendWith(MockitoExtension.class) class ProjectMarkdownWriterImplTest { + @Mock + private MarkdownFormatter markdownFormatter; + + @Mock + private ProjectSorter projectSorter; + private ProjectMarkdownWriterImpl markdownWriter; - private ScoreCalculator scoreCalculator; private Path tempMarkdownFile; @BeforeEach void setUp(@TempDir Path tempDir) throws IOException { tempMarkdownFile = tempDir.resolve("test-projects.md"); - scoreCalculator = new ScoreCalculator(); markdownWriter = new ProjectMarkdownWriterImpl( tempMarkdownFile.toString(), - scoreCalculator + markdownFormatter, + projectSorter ); } @@ -36,13 +47,14 @@ void writeMarkdown_ShouldCreateMarkdownFile() throws IOException { Project project = Project.builder() .name("Test Project") .description("A test project") - .impact(4) - .confidence(3) - .ease(5) - .reach(2) - .effort(3) + .impact(4).confidence(3).ease(5).reach(2).effort(3) .build(); List projects = List.of(project); + List sortedProjects = List.of(project); + + when(markdownFormatter.formatHeader(false)).thenReturn("# Project Priorities\n\n"); + when(projectSorter.sortByScore(projects, false)).thenReturn(sortedProjects); + when(markdownFormatter.formatProject(project, 1)).thenReturn("## 1. Test Project\n"); // When markdownWriter.writeMarkdown(projects, false); @@ -50,89 +62,77 @@ void writeMarkdown_ShouldCreateMarkdownFile() throws IOException { // Then assertTrue(Files.exists(tempMarkdownFile)); String content = Files.readString(tempMarkdownFile); - assertAll( - () -> assertTrue(content.contains("# Project Priorities")), - () -> assertTrue(content.contains("Test Project")), - () -> assertTrue(content.contains("A test project")), - () -> assertTrue(content.contains("ICE Score")), - () -> assertTrue(content.contains("RICE Score")), - () -> assertTrue(content.contains("Sorted by ICE score")) - ); + assertTrue(content.contains("# Project Priorities")); + assertTrue(content.contains("## 1. Test Project")); + + verify(markdownFormatter).formatHeader(false); + verify(projectSorter).sortByScore(projects, false); + verify(markdownFormatter).formatProject(project, 1); } @Test - void writeMarkdown_ShouldSortByICEByDefault() throws IOException { + void writeMarkdown_ShouldDelegateToSorterWithCorrectParameters() throws IOException { // Given - Project highICE = Project.builder() - .name("High ICE") - .description("High ICE project") - .impact(5).confidence(5).ease(5) - .reach(1).effort(5) - .build(); - - Project lowICE = Project.builder() - .name("Low ICE") - .description("Low ICE project") - .impact(1).confidence(1).ease(1) - .reach(5).effort(1) - .build(); - - List projects = Arrays.asList(lowICE, highICE); + Project project1 = Project.builder().name("Project 1").build(); + Project project2 = Project.builder().name("Project 2").build(); + List projects = Arrays.asList(project1, project2); + List sortedProjects = Arrays.asList(project2, project1); + + when(markdownFormatter.formatHeader(true)).thenReturn("# Header\n"); + when(projectSorter.sortByScore(projects, true)).thenReturn(sortedProjects); + when(markdownFormatter.formatProject(any(), anyInt())).thenReturn("## Project\n"); // When - markdownWriter.writeMarkdown(projects); + markdownWriter.writeMarkdown(projects, true); // Then - String content = Files.readString(tempMarkdownFile); - int highICEIndex = content.indexOf("High ICE"); - int lowICEIndex = content.indexOf("Low ICE"); - assertTrue(highICEIndex < lowICEIndex, "High ICE project should appear before Low ICE project"); + verify(projectSorter).sortByScore(projects, true); + verify(markdownFormatter).formatHeader(true); + verify(markdownFormatter).formatProject(project2, 1); + verify(markdownFormatter).formatProject(project1, 2); } @Test - void writeMarkdown_ShouldSortByRICEWhenRequested() throws IOException { + void writeMarkdown_WithRiceSort_ShouldPassCorrectParameterToFormatter() throws IOException { // Given - Project highRICE = Project.builder() - .name("High RICE") - .description("High RICE project") - .impact(5).confidence(5).ease(1) - .reach(5).effort(1) - .build(); - - Project lowRICE = Project.builder() - .name("Low RICE") - .description("Low RICE project") - .impact(1).confidence(1).ease(5) - .reach(1).effort(5) - .build(); - - List projects = Arrays.asList(lowRICE, highRICE); + Project project = Project.builder().name("Test Project").build(); + List projects = List.of(project); + List sortedProjects = List.of(project); + + when(markdownFormatter.formatHeader(true)).thenReturn("# Header RICE\n"); + when(projectSorter.sortByScore(projects, true)).thenReturn(sortedProjects); + when(markdownFormatter.formatProject(project, 1)).thenReturn("## Project\n"); // When markdownWriter.writeMarkdown(projects, true); // Then + verify(markdownFormatter).formatHeader(true); + verify(projectSorter).sortByScore(projects, true); + String content = Files.readString(tempMarkdownFile); - assertAll( - () -> assertTrue(content.contains("Sorted by RICE score")), - () -> assertTrue(content.indexOf("High RICE") < content.indexOf("Low RICE")) - ); + assertTrue(content.contains("# Header RICE")); } @Test void writeMarkdown_WithEmptyList_ShouldWriteNoProjectsMessage() throws IOException { // Given List projects = List.of(); + when(markdownFormatter.formatHeader(false)).thenReturn("# Header\n"); + when(markdownFormatter.formatNoProjectsMessage()).thenReturn("No projects found.\n"); // When markdownWriter.writeMarkdown(projects, false); // Then String content = Files.readString(tempMarkdownFile); - assertAll( - () -> assertTrue(content.contains("# Project Priorities")), - () -> assertTrue(content.contains("No projects found")) - ); + assertTrue(content.contains("# Header")); + assertTrue(content.contains("No projects found")); + + verify(markdownFormatter).formatHeader(false); + verify(markdownFormatter).formatNoProjectsMessage(); + verify(projectSorter, never()).sortByScore(any(), anyBoolean()); + verify(markdownFormatter, never()).formatProject(any(), anyInt()); } @Test @@ -140,33 +140,31 @@ void writeMarkdown_WithNullList_ShouldThrowException() { // When & Then assertThrows(IllegalArgumentException.class, () -> markdownWriter.writeMarkdown(null, false)); + + verifyNoInteractions(markdownFormatter); + verifyNoInteractions(projectSorter); } @Test - void writeMarkdown_ShouldIncludeAllProjectDetails() throws IOException { + void writeMarkdown_ShouldUseCorrectRankingForMultipleProjects() throws IOException { // Given - Project project = Project.builder() - .name("Detailed Project") - .description("A project with all details") - .impact(4) - .confidence(3) - .ease(2) - .reach(5) - .effort(3) - .build(); - List projects = List.of(project); + Project project1 = Project.builder().name("First").build(); + Project project2 = Project.builder().name("Second").build(); + Project project3 = Project.builder().name("Third").build(); + + List projects = Arrays.asList(project1, project2, project3); + List sortedProjects = Arrays.asList(project3, project1, project2); + + when(markdownFormatter.formatHeader(false)).thenReturn("# Header\n"); + when(projectSorter.sortByScore(projects, false)).thenReturn(sortedProjects); + when(markdownFormatter.formatProject(any(), anyInt())).thenReturn("## Project\n"); // When markdownWriter.writeMarkdown(projects, false); // Then - String content = Files.readString(tempMarkdownFile); - assertAll( - () -> assertTrue(content.contains("Impact:** 4/5 (high)")), - () -> assertTrue(content.contains("Confidence:** 3/5 (medium)")), - () -> assertTrue(content.contains("Ease:** 2/5 (low)")), - () -> assertTrue(content.contains("Reach:** 5/5 (very high)")), - () -> assertTrue(content.contains("Effort:** 3/5 (medium)")) - ); + verify(markdownFormatter).formatProject(project3, 1); + verify(markdownFormatter).formatProject(project1, 2); + verify(markdownFormatter).formatProject(project2, 3); } } \ No newline at end of file From 3635da954ef7ac78bcbef5ae689f6129021a2f28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 02:50:42 +0000 Subject: [PATCH 6/6] Add CI pipeline with GitHub Actions for automated testing Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .github/workflows/build.yml | 74 +++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 59 +++++++++++++++++++++++++++++ CONTRIBUTING.md | 22 ++++++++++- README.md | 4 ++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..58cfc56 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,74 @@ +name: Build and Test + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + java-version: [21] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v3 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Compile code + run: ./gradlew compileJava --no-daemon + + - name: Compile test code + run: ./gradlew compileTestJava --no-daemon + + - name: Run unit tests + run: ./gradlew test --no-daemon --continue + + - name: Build application + run: ./gradlew build --no-daemon + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: | + build/test-results/test/*.xml + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + if: success() + with: + name: jar-artifacts-java-${{ matrix.java-version }} + path: build/libs/ + + - name: Upload test coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-coverage-java-${{ matrix.java-version }} + path: build/reports/ \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df1331a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run tests + run: ./gradlew test --no-daemon --stacktrace + + - name: Generate test report + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Test Results + path: 'build/test-results/test/*.xml' + reporter: java-junit + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: build/test-results/test/ + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-reports + path: build/reports/tests/test/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5da23b..f463042 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,10 +10,30 @@ We welcome all kinds of contributions, whether you're fixing a bug, writing docu 2. Set up the project ## Running Tests -To run tests: +To run tests locally: ```bash ./gradlew test ``` +To run the full build (including tests): +```bash +./gradlew build +``` + +## Continuous Integration +This project uses GitHub Actions for continuous integration. All pull requests and pushes to main/develop branches will automatically: +- Build the project with Java 21 +- Run all unit tests (currently 61 tests) +- Generate test reports and artifacts +- Validate the Gradle wrapper + +The CI pipeline ensures code quality by: +- ✅ Running all unit tests including markdown export functionality +- ✅ Compiling both main and test code +- ✅ Generating test coverage reports +- ✅ Uploading build artifacts + +Make sure all tests pass locally before submitting a pull request. The CI checks must pass before merging. + ## License By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/README.md b/README.md index d8d73f0..ea3dc44 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ # Parpt - Project Audit & Revenue Prioritization Tool + +[![CI Pipeline](https://github.com/Stephenson-Software/Parpt/actions/workflows/ci.yml/badge.svg)](https://github.com/Stephenson-Software/Parpt/actions/workflows/ci.yml) +[![Build and Test](https://github.com/Stephenson-Software/Parpt/actions/workflows/build.yml/badge.svg)](https://github.com/Stephenson-Software/Parpt/actions/workflows/build.yml) + Parpt is an interactive CLI tool that helps developers, indie creators and teams evaluate and prioritize their software projects using structured metrics like ICE and RICE. ## Features