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 99debd1..ea3dc44 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # 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 - 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 +34,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/gradlew b/gradlew old mode 100644 new mode 100755 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/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/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..113c47e --- /dev/null +++ b/src/main/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImpl.java @@ -0,0 +1,55 @@ +package com.preponderous.parpt.repo; + +import com.preponderous.parpt.domain.Project; +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.util.List; + +@Component +public class ProjectMarkdownWriterImpl implements ProjectMarkdownWriter { + + private final String markdownFilePath; + private final MarkdownFormatter markdownFormatter; + private final ProjectSorter projectSorter; + + public ProjectMarkdownWriterImpl( + @Value("${app.projects.markdown-file}") String markdownFilePath, + MarkdownFormatter markdownFormatter, + ProjectSorter projectSorter) { + this.markdownFilePath = markdownFilePath; + this.markdownFormatter = markdownFormatter; + this.projectSorter = projectSorter; + } + + @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(markdownFormatter.formatHeader(sortByRice)); + + if (projects.isEmpty()) { + writer.write(markdownFormatter.formatNoProjectsMessage()); + return; + } + + // Sort projects and write them + List sortedProjects = projectSorter.sortByScore(projects, sortByRice); + for (int i = 0; i < sortedProjects.size(); i++) { + Project project = sortedProjects.get(i); + writer.write(markdownFormatter.formatProject(project, i + 1)); + } + + } catch (IOException e) { + throw new RuntimeException("Failed to write projects to Markdown file", e); + } + } +} \ 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/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/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..9c3a839 --- /dev/null +++ b/src/test/java/com/preponderous/parpt/repo/ProjectMarkdownWriterImplTest.java @@ -0,0 +1,170 @@ +package com.preponderous.parpt.repo; + +import com.preponderous.parpt.domain.Project; +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; +import java.nio.file.Path; +import java.util.Arrays; +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 Path tempMarkdownFile; + + @BeforeEach + void setUp(@TempDir Path tempDir) throws IOException { + tempMarkdownFile = tempDir.resolve("test-projects.md"); + markdownWriter = new ProjectMarkdownWriterImpl( + tempMarkdownFile.toString(), + markdownFormatter, + projectSorter + ); + } + + @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); + 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); + + // Then + assertTrue(Files.exists(tempMarkdownFile)); + String content = Files.readString(tempMarkdownFile); + 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_ShouldDelegateToSorterWithCorrectParameters() throws IOException { + // Given + 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, true); + + // Then + verify(projectSorter).sortByScore(projects, true); + verify(markdownFormatter).formatHeader(true); + verify(markdownFormatter).formatProject(project2, 1); + verify(markdownFormatter).formatProject(project1, 2); + } + + @Test + void writeMarkdown_WithRiceSort_ShouldPassCorrectParameterToFormatter() throws IOException { + // Given + 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); + 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); + 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 + void writeMarkdown_WithNullList_ShouldThrowException() { + // When & Then + assertThrows(IllegalArgumentException.class, + () -> markdownWriter.writeMarkdown(null, false)); + + verifyNoInteractions(markdownFormatter); + verifyNoInteractions(projectSorter); + } + + @Test + void writeMarkdown_ShouldUseCorrectRankingForMultipleProjects() throws IOException { + // Given + 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 + verify(markdownFormatter).formatProject(project3, 1); + verify(markdownFormatter).formatProject(project1, 2); + verify(markdownFormatter).formatProject(project2, 3); + } +} \ 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