diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index bae3a78a..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Super-Linter - -on: - push: - branches: [ master, develop ] - pull_request: - branches: [ master, develop ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Lint Code Base - uses: github/super-linter@v3 - env: - VALIDATE_ALL_CODEBASE: false - DEFAULT_BRANCH: develop - # Already checked via Eclipse code formatter and Sonar - VALIDATE_JAVA: false - VALIDATE_TYPESCRIPT_STANDARD: false - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c42fc593..05bfec8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [Release](https://github.com/itsallcode/white-rabbit/releases/tag/v1.1.0) / [Milestone](https://github.com/itsallcode/white-rabbit/milestone/2?closed=1) +### Added + +* [#16](https://github.com/itsallcode/white-rabbit/issues/16): Search-as-you-type for comments, select most common project for new activities. + ## [1.0.1] 2020-11-25 See [Release](https://github.com/itsallcode/white-rabbit/releases/tag/v1.0.1) / [Milestone](https://github.com/itsallcode/white-rabbit/milestone/3?closed=1) diff --git a/README.md b/README.md index 082e6491..0ed94b86 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ A time recording tool [![Build](https://github.com/itsallcode/white-rabbit/workflows/Build/badge.svg)](https://github.com/itsallcode/white-rabbit/actions?query=workflow%3ABuild) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=white-rabbit&metric=alert_status)](https://sonarcloud.io/dashboard?id=white-rabbit) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=white-rabbit&metric=coverage)](https://sonarcloud.io/dashboard?id=white-rabbit) -[![deepcode](https://www.deepcode.ai/api/gh/badge?key=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwbGF0Zm9ybTEiOiJnaCIsIm93bmVyMSI6Iml0c2FsbGNvZGUiLCJyZXBvMSI6IndoaXRlLXJhYmJpdCIsImluY2x1ZGVMaW50IjpmYWxzZSwiYXV0aG9ySWQiOjE1ODQ3LCJpYXQiOjE2MDExMjk1NTB9.J8l6aFttX7uETXvT1KzG2ai2kER_GJF94SZBOX9FTP0)](https://www.deepcode.ai/app/gh/itsallcode/white-rabbit/_/dashboard?utm_content=gh%2Fitsallcode%2Fwhite-rabbit) * [Features](#features) * [Usage](#usage) diff --git a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/activities/ActivitiesTable.java b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/activities/ActivitiesTable.java index 892b589d..c4b6d912 100644 --- a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/activities/ActivitiesTable.java +++ b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/activities/ActivitiesTable.java @@ -9,8 +9,10 @@ import org.apache.logging.log4j.Logger; import org.itsallcode.whiterabbit.jfxui.JavaFxUtil; import org.itsallcode.whiterabbit.jfxui.table.EditListener; -import org.itsallcode.whiterabbit.jfxui.table.PersistOnFocusLossTextFieldTableCell; import org.itsallcode.whiterabbit.jfxui.table.converter.DurationStringConverter; +import org.itsallcode.whiterabbit.jfxui.ui.widget.AutoCompleteTextField; +import org.itsallcode.whiterabbit.jfxui.ui.widget.PersistOnFocusLossTextFieldTableCell; +import org.itsallcode.whiterabbit.logic.autocomplete.AutocompleteService; import org.itsallcode.whiterabbit.logic.model.Activity; import org.itsallcode.whiterabbit.logic.model.DayRecord; import org.itsallcode.whiterabbit.logic.service.FormatterService; @@ -43,15 +45,17 @@ public class ActivitiesTable private final EditListener editListener; private final FormatterService formatterService; private final ProjectService projectService; + private final AutocompleteService autocompleteService; public ActivitiesTable(ReadOnlyProperty selectedDay, SimpleObjectProperty selectedActivity, - EditListener editListener, - FormatterService formatterService, ProjectService projectService) + EditListener editListener, FormatterService formatterService, ProjectService projectService, + AutocompleteService autocompleteService) { this.selectedActivity = selectedActivity; this.editListener = editListener; this.formatterService = formatterService; this.projectService = projectService; + this.autocompleteService = autocompleteService; selectedDay.addListener((observable, oldValue, newValue) -> updateTableValues(newValue)); } @@ -132,7 +136,8 @@ public TableView initTable() final TableColumn remainderCol = column("remainder", "Remainder", cellFactory, data -> data.getValue().remainder); final TableColumn commentCol = column("comment", "Comment", - param -> new PersistOnFocusLossTextFieldTableCell<>(new DefaultStringConverter()), + param -> new PersistOnFocusLossTextFieldTableCell<>(new DefaultStringConverter(), + () -> new AutoCompleteTextField(autocompleteService.activityCommentAutocompleter())), data -> data.getValue().comment); return asList(projectCol, durationCol, remainderCol, commentCol); diff --git a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/days/DayRecordTable.java b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/days/DayRecordTable.java index 3fc0ae37..f499baee 100644 --- a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/days/DayRecordTable.java +++ b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/days/DayRecordTable.java @@ -17,8 +17,10 @@ import org.eclipse.jdt.annotation.NonNull; import org.itsallcode.whiterabbit.jfxui.JavaFxUtil; import org.itsallcode.whiterabbit.jfxui.table.EditListener; -import org.itsallcode.whiterabbit.jfxui.table.PersistOnFocusLossTextFieldTableCell; import org.itsallcode.whiterabbit.jfxui.table.converter.DurationStringConverter; +import org.itsallcode.whiterabbit.jfxui.ui.widget.AutoCompleteTextField; +import org.itsallcode.whiterabbit.jfxui.ui.widget.PersistOnFocusLossTextFieldTableCell; +import org.itsallcode.whiterabbit.logic.autocomplete.AutocompleteService; import org.itsallcode.whiterabbit.logic.model.DayRecord; import org.itsallcode.whiterabbit.logic.model.MonthIndex; import org.itsallcode.whiterabbit.logic.model.json.DayType; @@ -51,14 +53,17 @@ public class DayRecordTable private final SimpleObjectProperty selectedDay; private TableView table; + private final AutocompleteService autocompleteService; + public DayRecordTable(Locale locale, SimpleObjectProperty selectedDay, ObjectProperty currentMonth, EditListener editListener, - FormatterService formatterService) + FormatterService formatterService, AutocompleteService autocompleteService) { this.editListener = editListener; this.formatterService = formatterService; this.locale = locale; this.selectedDay = selectedDay; + this.autocompleteService = autocompleteService; fillTableWith31EmptyRows(); currentMonth.addListener((observable, oldValue, newValue) -> updateTableValues(newValue)); } @@ -124,7 +129,8 @@ public void selectRow(LocalDate date) param -> new PersistOnFocusLossTextFieldTableCell<>(durationConverter), data -> data.getValue().totalOvertime); final TableColumn commentCol = column("comment", "Comment", - param -> new PersistOnFocusLossTextFieldTableCell<>(new DefaultStringConverter()), + param -> new PersistOnFocusLossTextFieldTableCell<>(new DefaultStringConverter(), + () -> new AutoCompleteTextField(autocompleteService.dayCommentAutocompleter())), data -> data.getValue().comment); return asList(dateCol, dayTypeCol, beginCol, endCol, breakCol, interruptionCol, workingTimeCol, overTimeCol, diff --git a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/AppUi.java b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/AppUi.java index e6c92a26..44ce7c43 100644 --- a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/AppUi.java +++ b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/AppUi.java @@ -1,14 +1,18 @@ package org.itsallcode.whiterabbit.jfxui.ui; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDate; -import java.time.YearMonth; -import java.util.Locale; - +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.*; +import javafx.stage.Stage; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.itsallcode.whiterabbit.jfxui.AppState; @@ -24,31 +28,14 @@ import org.itsallcode.whiterabbit.logic.service.AppService; import org.itsallcode.whiterabbit.logic.service.FormatterService; -import javafx.application.Platform; -import javafx.beans.binding.Bindings; -import javafx.event.ActionEvent; -import javafx.event.EventHandler; -import javafx.geometry.Insets; -import javafx.geometry.Orientation; -import javafx.scene.Node; -import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; -import javafx.scene.control.Label; -import javafx.scene.control.MenuBar; -import javafx.scene.control.Separator; -import javafx.scene.control.SplitPane; -import javafx.scene.control.TitledPane; -import javafx.scene.control.ToolBar; -import javafx.scene.control.Tooltip; -import javafx.scene.image.Image; -import javafx.scene.input.KeyCode; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import javafx.stage.Stage; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.Locale; public class AppUi { @@ -113,15 +100,15 @@ public AppUi build() appService.store(record); if (record.getDate().equals(state.getSelectedDay().map(DayRecord::getDate).orElse(null))) { - LOG.debug("Current day {} updated: refresh activieties", record.getDate()); + LOG.debug("Current day {} updated: refresh activities", record.getDate()); activitiesTable.refresh(); } - }, appService.formatter()); + }, appService.formatter(), appService.autocomplete()); activitiesTable = new ActivitiesTable(state.selectedDay, state.selectedActivity, record -> { appService.store(record); activitiesTable.refresh(); - }, appService.formatter(), appService.projects()); + }, appService.formatter(), appService.projects(), appService.autocomplete()); final BorderPane rootPane = new BorderPane(createMainPane()); rootPane.setTop(createTopContainer()); final Scene scene = new Scene(rootPane, 780, 800); diff --git a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/widget/AutoCompleteTextField.java b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/widget/AutoCompleteTextField.java new file mode 100644 index 00000000..fc10abbc --- /dev/null +++ b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/widget/AutoCompleteTextField.java @@ -0,0 +1,88 @@ +package org.itsallcode.whiterabbit.jfxui.ui.widget; + +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.itsallcode.whiterabbit.logic.autocomplete.AutocompleteEntrySupplier; + +import javafx.geometry.Side; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.CustomMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; + +/** + * Based on https://gist.github.com/floralvikings/10290131 + */ +@SuppressWarnings("java:S110") // Deep inheritance tree required by API +public class AutoCompleteTextField extends TextField +{ + private static final Logger LOG = LogManager.getLogger(AutoCompleteTextField.class); + + private static final int MAX_ENTRY_COUNT = 10; + + private final AutocompleteEntrySupplier autocompleteEntriesSupplier; + private final ContextMenu entriesPopup; + + public AutoCompleteTextField(AutocompleteEntrySupplier autocompleteEntriesSupplier) + { + this.autocompleteEntriesSupplier = autocompleteEntriesSupplier; + entriesPopup = new ContextMenu(); + textProperty().addListener((observableValue, oldValue, newValue) -> textUpdated(getText())); + + focusedProperty().addListener((observableValue, oldValue, newValue) -> entriesPopup.hide()); + } + + private void textUpdated(final String currentText) + { + if (currentText.isBlank()) + { + entriesPopup.hide(); + return; + } + final List searchResult = autocompleteEntriesSupplier.getEntries(currentText); + if (searchResult.isEmpty()) + { + entriesPopup.hide(); + return; + } + populatePopup(searchResult); + if (!entriesPopup.isShowing()) + { + showPopup(); + } + } + + private void showPopup() + { + if (getScene() == null) + { + LOG.warn("Scene not available for {}", this); + return; + } + entriesPopup.show(this, Side.BOTTOM, 0, 0); + } + + private void populatePopup(List searchResult) + { + final int count = Math.min(searchResult.size(), MAX_ENTRY_COUNT); + entriesPopup.getItems().clear(); + for (int i = 0; i < count; i++) + { + final String result = searchResult.get(i); + entriesPopup.getItems().add(createMenuItem(result)); + } + } + + private CustomMenuItem createMenuItem(final String result) + { + final Label entryLabel = new Label(result); + final CustomMenuItem item = new CustomMenuItem(entryLabel, true); + item.setOnAction(actionEvent -> { + setText(result); + entriesPopup.hide(); + }); + return item; + } +} \ No newline at end of file diff --git a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/PersistOnFocusLossTextFieldTableCell.java b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/widget/PersistOnFocusLossTextFieldTableCell.java similarity index 87% rename from jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/PersistOnFocusLossTextFieldTableCell.java rename to jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/widget/PersistOnFocusLossTextFieldTableCell.java index 5c2675fc..9ce66bf2 100644 --- a/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/table/PersistOnFocusLossTextFieldTableCell.java +++ b/jfxui/src/main/java/org/itsallcode/whiterabbit/jfxui/ui/widget/PersistOnFocusLossTextFieldTableCell.java @@ -1,6 +1,7 @@ -package org.itsallcode.whiterabbit.jfxui.table; +package org.itsallcode.whiterabbit.jfxui.ui.widget; import java.util.Objects; +import java.util.function.Supplier; import javafx.beans.value.ChangeListener; import javafx.scene.Node; @@ -25,10 +26,18 @@ public class PersistOnFocusLossTextFieldTableCell extends TableCell { private final StringConverter converter; + public final Supplier textFieldSupplier; private TextField textField; public PersistOnFocusLossTextFieldTableCell(final StringConverter converter) { + this(converter, TextField::new); + } + + public PersistOnFocusLossTextFieldTableCell(final StringConverter converter, + Supplier testFieldSupplier) + { + this.textFieldSupplier = testFieldSupplier; this.converter = Objects.requireNonNull(converter); } @@ -47,7 +56,7 @@ public void startEdit() { if (this.textField == null) { - this.textField = createTextField(this, this.converter); + this.textField = createTextField(this, this.converter, this.textFieldSupplier); } startEdit(this, this.converter, this.textField); @@ -55,9 +64,10 @@ public void startEdit() } private static TextField createTextField(final PersistOnFocusLossTextFieldTableCell cell, - final StringConverter converter) + final StringConverter converter, Supplier textFieldSupplier) { - final TextField textField = new TextField(getItemText(cell, converter)); + final TextField textField = textFieldSupplier.get(); + textField.setText(getItemText(cell, converter)); textField.setOnAction(event -> { cell.commitEdit(converter.fromString(textField.getText())); @@ -128,7 +138,8 @@ private static void cancelEdit(final Cell cell, final StringConverter cell.setGraphic(graphic); } - private static void updateItem(final Cell cell, final StringConverter converter, final TextField textField) + private static void updateItem(final Cell cell, final StringConverter converter, + final TextField textField) { if (cell.isEmpty()) { diff --git a/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/TableCellEditTest.java b/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/TableCellEditTest.java index 68cc1307..983b4041 100644 --- a/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/TableCellEditTest.java +++ b/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/TableCellEditTest.java @@ -1,15 +1,10 @@ package org.itsallcode.whiterabbit.jfxui; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.Locale; -import java.util.concurrent.atomic.AtomicReference; - +import javafx.scene.Scene; +import javafx.scene.control.TableCell; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; import org.itsallcode.whiterabbit.jfxui.table.days.DayRecordPropertyAdapter; import org.itsallcode.whiterabbit.jfxui.testutil.DayTableExpectedRow; import org.itsallcode.whiterabbit.jfxui.testutil.DayTableExpectedRow.Builder; @@ -25,11 +20,15 @@ import org.testfx.framework.junit5.Start; import org.testfx.framework.junit5.Stop; -import javafx.scene.Scene; -import javafx.scene.control.TableCell; -import javafx.scene.input.KeyCode; -import javafx.scene.layout.StackPane; -import javafx.stage.Stage; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; @ExtendWith(ApplicationExtension.class) class TableCellEditTest extends JavaFxAppUiTestBase @@ -194,7 +193,7 @@ private void assertCommentCellNotPersistedAfterFocusLostAction(Runnable focusLos assertThat(commentCell.isEditing()).as("cell is editing").isTrue(); - JavaFxUtil.runOnFxApplicationThread(focusLossAction::run); + JavaFxUtil.runOnFxApplicationThread(focusLossAction); dayTable.assertRowContent(rowIndex, expectedCellValues.build()); } diff --git a/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/DayTable.java b/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/DayTable.java index 02b03eb1..b8f75708 100644 --- a/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/DayTable.java +++ b/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/DayTable.java @@ -1,11 +1,7 @@ package org.itsallcode.whiterabbit.jfxui.testutil.model; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -import java.time.Duration; -import java.time.LocalTime; - +import javafx.scene.control.TableCell; +import javafx.scene.input.KeyCode; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.itsallcode.whiterabbit.jfxui.table.days.DayRecordPropertyAdapter; @@ -14,8 +10,11 @@ import org.itsallcode.whiterabbit.logic.model.json.DayType; import org.testfx.api.FxRobot; -import javafx.scene.control.TableCell; -import javafx.scene.input.KeyCode; +import java.time.Duration; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; public class DayTable { @@ -90,6 +89,15 @@ public void typeInterruption(int row, String value) robot.doubleClickOn(tableCell).write(value).type(KeyCode.TAB); } + public void typeComment(int row, String value) + { + robot.doubleClickOn(getCommentCell(row)).write(value).type(KeyCode.TAB); + } + + public TableCell getCommentCell(int row) { + return table.getTableCell(row, "comment"); + } + public void selectDayType(int row, DayType type) { final TableCell tableCell = table.getTableCell(row, "day-type"); diff --git a/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/JavaFxTable.java b/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/JavaFxTable.java index d272055b..e399e2b4 100644 --- a/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/JavaFxTable.java +++ b/jfxui/src/uiTest/java/org/itsallcode/whiterabbit/jfxui/testutil/model/JavaFxTable.java @@ -1,23 +1,27 @@ package org.itsallcode.whiterabbit.jfxui.testutil.model; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -import java.util.ArrayList; -import java.util.List; - +import javafx.scene.control.IndexedCell; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableView; +import javafx.scene.control.skin.VirtualFlow; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.itsallcode.whiterabbit.jfxui.JavaFxUtil; import org.itsallcode.whiterabbit.jfxui.testutil.TableRowExpectedContent; import org.junit.jupiter.api.function.Executable; import org.testfx.api.FxRobot; import org.testfx.assertions.api.Assertions; -import javafx.scene.control.IndexedCell; -import javafx.scene.control.TableCell; -import javafx.scene.control.TableView; -import javafx.scene.control.skin.VirtualFlow; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; public class JavaFxTable { + private static final Logger LOG = LogManager.getLogger(JavaFxTable.class); + private final FxRobot robot; private final TableView table; @@ -51,6 +55,7 @@ public void assertRowContent(final int rowIndex, final TableRowExpectedContent e public TableCell getTableCell(final int rowIndex, final String columnId) { + LOG.debug("Getting row {} / column {}", rowIndex, columnId); final IndexedCell row = getTableRow(rowIndex); return row.getChildrenUnmodifiable().stream() .filter(cell -> cell.getId().equals(columnId)) @@ -65,8 +70,10 @@ public IndexedCell getTableRow(final int rowIndex) .filter(VirtualFlow.class::isInstance) .map(VirtualFlow.class::cast) .findFirst().orElseThrow(); - assertThat(virtualFlow.getCellCount()).isGreaterThan(rowIndex); - return virtualFlow.getCell(rowIndex); + assertThat(virtualFlow.getCellCount()).as("row count of " + virtualFlow).isGreaterThan(rowIndex); + final IndexedCell row = JavaFxUtil.runOnFxApplicationThread(() -> virtualFlow.getCell(rowIndex)); + LOG.debug("Got row #{} of {}: {}", rowIndex, virtualFlow, row); + return row; } public JavaFxTable clickRow(int rowIndex) diff --git a/launch/WhiteRabbit - JavaFXUI.launch b/launch/WhiteRabbit - JavaFXUI.launch index 09ec6c5c..d371aeb5 100644 --- a/launch/WhiteRabbit - JavaFXUI.launch +++ b/launch/WhiteRabbit - JavaFXUI.launch @@ -1,19 +1,20 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/launch/WhiteRabbit - TextUI.launch b/launch/WhiteRabbit - TextUI.launch index 306d8929..70470c1d 100644 --- a/launch/WhiteRabbit - TextUI.launch +++ b/launch/WhiteRabbit - TextUI.launch @@ -1,19 +1,20 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/Config.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/Config.java index f4554e13..47ccdfd1 100644 --- a/logic/src/main/java/org/itsallcode/whiterabbit/logic/Config.java +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/Config.java @@ -7,6 +7,8 @@ public interface Config { + static final String PROJECTS_JSON = "projects.json"; + Path getDataDir(); Locale getLocale(); @@ -17,7 +19,7 @@ public interface Config default Path getProjectFile() { - return getDataDir().resolve("projects.json"); + return getDataDir().resolve(PROJECTS_JSON); } Path getConfigFile(); diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteEntrySupplier.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteEntrySupplier.java new file mode 100644 index 00000000..a4141ac9 --- /dev/null +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteEntrySupplier.java @@ -0,0 +1,9 @@ +package org.itsallcode.whiterabbit.logic.autocomplete; + +import java.util.List; + +@FunctionalInterface +public interface AutocompleteEntrySupplier +{ + List getEntries(String currentText); +} \ No newline at end of file diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteService.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteService.java new file mode 100644 index 00000000..6694205a --- /dev/null +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteService.java @@ -0,0 +1,114 @@ +package org.itsallcode.whiterabbit.logic.autocomplete; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.itsallcode.whiterabbit.logic.model.Activity; +import org.itsallcode.whiterabbit.logic.model.DayActivities; +import org.itsallcode.whiterabbit.logic.model.DayRecord; +import org.itsallcode.whiterabbit.logic.service.ClockService; +import org.itsallcode.whiterabbit.logic.service.project.Project; +import org.itsallcode.whiterabbit.logic.storage.CachingStorage; + +import java.time.LocalDate; +import java.time.Period; +import java.util.*; +import java.util.stream.Stream; + +import static java.util.Collections.emptyList; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.*; + +public class AutocompleteService +{ + private static final Logger LOG = LogManager.getLogger(AutocompleteService.class); + + private static final Period MAX_AGE = Period.ofMonths(2); + private final CachingStorage storage; + private final ClockService clockService; + + public AutocompleteService(CachingStorage storage, ClockService clockService) + { + this.storage = storage; + this.clockService = clockService; + } + + public AutocompleteEntrySupplier dayCommentAutocompleter() + { + return autocompleter(getDayComments()); + } + + public AutocompleteEntrySupplier activityCommentAutocompleter() + { + return autocompleter(getActivityComments()); + } + + private List getDayComments() + { + return getLatestDays().stream() + .map(DayRecord::getComment) + .filter(Objects::nonNull) + .filter(comment -> !comment.isBlank()) + .distinct() + .collect(toList()); + } + + private List getActivityComments() + { + return getActivities() + .map(Activity::getComment) + .filter(Objects::nonNull) + .filter(comment -> !comment.isBlank()) + .distinct() + .collect(toList()); + } + + private Stream getActivities() + { + return getLatestDays().stream() + .map(DayRecord::activities) + .map(DayActivities::getAll) + .flatMap(List::stream); + } + + private List getLatestDays() + { + final LocalDate maxAge = clockService.getCurrentDate().minus(MAX_AGE); + return storage.getLatestDays(maxAge); + } + + AutocompleteEntrySupplier autocompleter(Collection allEntries) + { + LOG.debug("Creating autocompleter for {} entries: {}", allEntries.size(), allEntries); + final Map> lowerCaseIndex = allEntries.stream().collect(groupingBy(String::toLowerCase)); + final SortedSet lowerCaseValues = new TreeSet<>(lowerCaseIndex.keySet()); + return currentText -> { + if (currentText == null || currentText.isBlank()) + { + return emptyList(); + } + final SortedSet lowerCaseMatches = lowerCaseValues.subSet(currentText.toLowerCase(), + currentText.toLowerCase() + Character.MAX_VALUE); + return lowerCaseMatches.stream().map(lowerCaseIndex::get).flatMap(List::stream).collect(toList()); + }; + } + + public Optional getSuggestedProject() + { + final List projects = getActivities().map(Activity::getProject) + .filter(Objects::nonNull) + .collect(toList()); + final Map> groupedProjects = projects.stream() + .filter(Objects::nonNull) + .collect(groupingBy(Project::getProjectId)); + final Map frequencyMap = projects.stream() + .map(Project::getProjectId) + .collect(groupingBy(identity(), counting())); + final Optional mostFrequentlyUsedProject = frequencyMap + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .map(projectId -> groupedProjects.get(projectId).get(0)); + LOG.debug("Project frequency: {}, most frequently: {}", frequencyMap, mostFrequentlyUsedProject); + return mostFrequentlyUsedProject; + } +} diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/model/DayRecord.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/model/DayRecord.java index a2d6c429..35234bba 100644 --- a/logic/src/main/java/org/itsallcode/whiterabbit/logic/model/DayRecord.java +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/model/DayRecord.java @@ -175,8 +175,11 @@ public MonthIndex getMonth() public boolean isDummyDay() { - return day.getBegin() == null && day.getEnd() == null // - && day.getType() == null && day.getComment() == null && day.getInterruption() == null; + return day.getBegin() == null + && day.getEnd() == null + && day.getType() == null + && day.getComment() == null + && day.getInterruption() == null; } public DayActivities activities() diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/ActivityService.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/ActivityService.java index 2d3725e1..85d36aec 100644 --- a/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/ActivityService.java +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/ActivityService.java @@ -2,20 +2,26 @@ import java.time.LocalDate; import java.time.YearMonth; +import java.util.Optional; +import org.itsallcode.whiterabbit.logic.autocomplete.AutocompleteService; import org.itsallcode.whiterabbit.logic.model.Activity; import org.itsallcode.whiterabbit.logic.model.DayRecord; import org.itsallcode.whiterabbit.logic.model.MonthIndex; +import org.itsallcode.whiterabbit.logic.service.project.Project; import org.itsallcode.whiterabbit.logic.storage.Storage; public class ActivityService { private final Storage storage; private final AppServiceCallback appServiceCallback; + private final AutocompleteService autocompleteService; - public ActivityService(Storage storage, AppServiceCallback appServiceCallback) + public ActivityService(Storage storage, AutocompleteService autocompleteService, + AppServiceCallback appServiceCallback) { this.storage = storage; + this.autocompleteService = autocompleteService; this.appServiceCallback = appServiceCallback; } @@ -24,7 +30,12 @@ public void addActivity(LocalDate date) final MonthIndex monthIndex = storage.loadMonth(YearMonth.from(date)).orElseThrow(); final DayRecord day = monthIndex.getDay(date); - day.activities().add(); + final Activity newActivity = day.activities().add(); + final Optional suggestedProject = autocompleteService.getSuggestedProject(); + if (suggestedProject.isPresent()) + { + newActivity.setProject(suggestedProject.get()); + } storage.storeMonth(monthIndex); appServiceCallback.recordUpdated(day); diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/AppService.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/AppService.java index b35ac02c..886fc2cd 100644 --- a/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/AppService.java +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/service/AppService.java @@ -1,23 +1,9 @@ package org.itsallcode.whiterabbit.logic.service; -import static java.util.Collections.emptyList; -import static java.util.stream.Collectors.toList; - -import java.io.Closeable; -import java.time.Clock; -import java.time.Duration; -import java.time.LocalDate; -import java.time.YearMonth; -import java.time.temporal.ChronoUnit; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledThreadPoolExecutor; - import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.itsallcode.whiterabbit.logic.Config; +import org.itsallcode.whiterabbit.logic.autocomplete.AutocompleteService; import org.itsallcode.whiterabbit.logic.model.DayRecord; import org.itsallcode.whiterabbit.logic.model.MonthIndex; import org.itsallcode.whiterabbit.logic.service.AppPropertiesService.AppProperties; @@ -33,9 +19,24 @@ import org.itsallcode.whiterabbit.logic.service.singleinstance.SingleInstanceService; import org.itsallcode.whiterabbit.logic.service.vacation.VacationReport; import org.itsallcode.whiterabbit.logic.service.vacation.VacationReportGenerator; -import org.itsallcode.whiterabbit.logic.storage.DateToFileMapper; +import org.itsallcode.whiterabbit.logic.storage.CachingStorage; import org.itsallcode.whiterabbit.logic.storage.Storage; +import java.io.Closeable; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; + public class AppService implements Closeable { private static final Logger LOG = LogManager.getLogger(AppService.class); @@ -54,11 +55,14 @@ public class AppService implements Closeable private RegistrationResult singleInstanceRegistration; + private final AutocompleteService autocompleteService; + @SuppressWarnings("java:S107") // Large number of parameters is ok here. AppService(WorkingTimeService workingTimeService, Storage storage, FormatterService formatterService, ClockService clock, SchedulingService schedulingService, SingleInstanceService singleInstanceService, DelegatingAppServiceCallback appServiceCallback, VacationReportGenerator vacationService, - ActivityService activityService, ProjectService projectService, AppPropertiesService appPropertiesService) + ActivityService activityService, ProjectService projectService, AutocompleteService autocompleteService, + AppPropertiesService appPropertiesService) { this.workingTimeService = workingTimeService; this.storage = storage; @@ -70,6 +74,7 @@ public class AppService implements Closeable this.vacationService = vacationService; this.activityService = activityService; this.projectService = projectService; + this.autocompleteService = autocompleteService; this.appPropertiesService = appPropertiesService; } @@ -82,17 +87,20 @@ public static AppService create(final Config config, Clock clock, ScheduledExecu { final SingleInstanceService singleInstanceService = SingleInstanceService.create(config); final ProjectService projectService = new ProjectService(config); - final Storage storage = new Storage(new DateToFileMapper(config.getDataDir()), - new ContractTermsService(config), projectService); + + final CachingStorage storage = CachingStorage.create(config.getDataDir(), new ContractTermsService(config), + projectService); final ClockService clockService = new ClockService(clock); + final AutocompleteService autocompleteService = new AutocompleteService(storage, clockService); final SchedulingService schedulingService = new SchedulingService(clockService, scheduledExecutor); final DelegatingAppServiceCallback appServiceCallback = new DelegatingAppServiceCallback(); final WorkingTimeService workingTimeService = new WorkingTimeService(storage, clockService, appServiceCallback); final VacationReportGenerator vacationService = new VacationReportGenerator(storage); - final ActivityService activityService = new ActivityService(storage, appServiceCallback); + final ActivityService activityService = new ActivityService(storage, autocompleteService, appServiceCallback); final FormatterService formatterService = new FormatterService(config.getLocale(), clock.getZone()); return new AppService(workingTimeService, storage, formatterService, clockService, schedulingService, singleInstanceService, appServiceCallback, vacationService, activityService, projectService, + autocompleteService, new AppPropertiesService()); } @@ -240,6 +248,11 @@ public AppProperties getAppProperties() return appPropertiesService.load(); } + public AutocompleteService autocomplete() + { + return autocompleteService; + } + @Override public void close() { diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/CachingStorage.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/CachingStorage.java new file mode 100644 index 00000000..41b7b245 --- /dev/null +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/CachingStorage.java @@ -0,0 +1,26 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import org.itsallcode.whiterabbit.logic.model.DayRecord; +import org.itsallcode.whiterabbit.logic.service.contract.ContractTermsService; +import org.itsallcode.whiterabbit.logic.service.project.ProjectService; + +import javax.json.bind.Jsonb; +import javax.json.bind.JsonbBuilder; +import javax.json.bind.JsonbConfig; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; + +public interface CachingStorage extends Storage +{ + static CachingStorage create(Path dataDir, ContractTermsService contractTerms, ProjectService projectService) + { + final DateToFileMapper dateToFileMapper = new DateToFileMapper(dataDir); + final Jsonb jsonb = JsonbBuilder.create(new JsonbConfig().withFormatting(true)); + final JsonFileStorage fileStorage = new JsonFileStorage(jsonb, dateToFileMapper); + final MonthIndexStorage monthIndexStorage = new MonthIndexStorage(contractTerms, projectService, fileStorage); + return new CachingStorageImpl(monthIndexStorage); + } + + List getLatestDays(LocalDate maxAge); +} diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/CachingStorageImpl.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/CachingStorageImpl.java new file mode 100644 index 00000000..c2d2f8f8 --- /dev/null +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/CachingStorageImpl.java @@ -0,0 +1,102 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import static java.util.stream.Collectors.toList; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.itsallcode.whiterabbit.logic.model.DayRecord; +import org.itsallcode.whiterabbit.logic.model.MonthIndex; +import org.itsallcode.whiterabbit.logic.model.MultiMonthIndex; + +class CachingStorageImpl implements CachingStorage +{ + private static final Logger LOG = LogManager.getLogger(CachingStorageImpl.class); + + private final Storage delegateStorage; + private final MonthCache cache; + + CachingStorageImpl(Storage delegateStorage) + { + this(delegateStorage, new MonthCache()); + } + + CachingStorageImpl(Storage delegateStorage, MonthCache cache) + { + this.delegateStorage = delegateStorage; + this.cache = cache; + } + + @Override + public Optional loadMonth(YearMonth date) + { + return delegateStorage.loadMonth(date).map(this::updateCache); + } + + @Override + public MonthIndex loadOrCreate(final YearMonth yearMonth) + { + return updateCache(delegateStorage.loadOrCreate(yearMonth)); + } + + @Override + public void storeMonth(MonthIndex month) + { + delegateStorage.storeMonth(updateCache(month)); + } + + @Override + public MultiMonthIndex loadAll() + { + return updateCache(delegateStorage.loadAll()); + } + + private MultiMonthIndex updateCache(MultiMonthIndex index) + { + index.getMonths().forEach(this::updateCache); + return index; + } + + private MonthIndex updateCache(MonthIndex month) + { + cache.update(month); + return month; + } + + @Override + public List getAvailableDataYearMonth() + { + return delegateStorage.getAvailableDataYearMonth(); + } + + @Override + public List getLatestDays(LocalDate maxAge) + { + ensureLatestDaysCached(maxAge); + return cache.getLatestDays(maxAge); + } + + void ensureLatestDaysCached(LocalDate maxAge) + { + for (final YearMonth requiredMonth : getRequiredYearMonths(maxAge)) + { + if (!cache.contains(requiredMonth)) + { + LOG.debug("Loading month {} into cache", requiredMonth); + delegateStorage.loadMonth(requiredMonth).ifPresent(cache::update); + } + } + } + + List getRequiredYearMonths(LocalDate maxAge) + { + final YearMonth oldestYearMonth = YearMonth.from(maxAge); + return delegateStorage.getAvailableDataYearMonth().stream() + .filter(month -> !month.isBefore(oldestYearMonth)) + .collect(toList()); + } +} diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapper.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapper.java index d89a34d1..663e4c65 100644 --- a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapper.java +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapper.java @@ -23,18 +23,18 @@ public class DateToFileMapper private final DateTimeFormatter formatter; private final Pattern fileNamePattern = Pattern.compile("^(\\d\\d\\d\\d)-(\\d\\d)\\.json$"); - public DateToFileMapper(Path dataDir) + DateToFileMapper(Path dataDir) { formatter = DateTimeFormatter.ofPattern("yyyy-MM", Locale.ENGLISH); this.dataDir = dataDir; } - public Path getPathForDate(YearMonth date) + Path getPathForDate(YearMonth date) { return dataDir.resolve(String.valueOf(date.getYear())).resolve(getFileName(date)); } - public Path getLegacyPathForDate(YearMonth date) + Path getLegacyPathForDate(YearMonth date) { return dataDir.resolve(getFileName(date)); } @@ -44,7 +44,7 @@ private String getFileName(YearMonth date) return date.format(formatter) + ".json"; } - public Stream getAllFiles() + Stream getAllFiles() { LOG.debug("Reading all files in {}", dataDir); try @@ -59,7 +59,7 @@ public Stream getAllFiles() } } - public Stream getAllYearMonths() + Stream getAllYearMonths() { return getAllFiles() // .map(Path::getFileName) // diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/JsonFileStorage.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/JsonFileStorage.java new file mode 100644 index 00000000..d46fe265 --- /dev/null +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/JsonFileStorage.java @@ -0,0 +1,113 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +import javax.json.bind.Jsonb; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.itsallcode.whiterabbit.logic.Config; +import org.itsallcode.whiterabbit.logic.model.json.JsonMonth; + +public class JsonFileStorage +{ + private static final Logger LOG = LogManager.getLogger(JsonFileStorage.class); + + private final Jsonb jsonb; + private final DateToFileMapper dateToFileMapper; + + JsonFileStorage(Jsonb jsonb, DateToFileMapper dateToFileMapper) + { + this.jsonb = jsonb; + this.dateToFileMapper = dateToFileMapper; + } + + private JsonMonth loadFromFile(Path file) + { + LOG.trace("Reading file {}", file); + try (InputStream stream = Files.newInputStream(file)) + { + return jsonb.fromJson(stream, JsonMonth.class); + } + catch (final IOException e) + { + throw new UncheckedIOException("Error reading file " + file, e); + } + } + + Optional loadMonthRecord(YearMonth date) + { + final Path file = dateToFileMapper.getPathForDate(date); + if (file.toFile().exists()) + { + LOG.trace("Found file {} for month {}", file, date); + return Optional.of(loadFromFile(file)); + } + final Path legacyFile = dateToFileMapper.getLegacyPathForDate(date); + if (legacyFile.toFile().exists()) + { + LOG.trace("Found legacy file {} for month {}", file, date); + return Optional.of(loadFromFile(legacyFile)); + } + LOG.debug("File {} not found for month {}", file, date); + return Optional.empty(); + } + + void writeToFile(YearMonth yearMonth, JsonMonth record) + { + final Path file = dateToFileMapper.getPathForDate(yearMonth); + LOG.info("Write month {} to file {}", yearMonth, file); + createDirectory(file.getParent()); + try (OutputStream stream = Files.newOutputStream(file, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING)) + { + jsonb.toJson(record, stream); + } + catch (final IOException e) + { + throw new UncheckedIOException("Error writing file " + file, e); + } + } + + private void createDirectory(Path dir) + { + if (dir.toFile().isDirectory()) + { + return; + } + try + { + Files.createDirectories(dir); + } + catch (final IOException e) + { + throw new UncheckedIOException("Error creating dir " + dir, e); + } + } + + List getAvailableDataYearMonth() + { + return dateToFileMapper.getAllYearMonths().sorted().collect(toList()); + } + + List loadAll() + { + return dateToFileMapper.getAllFiles() + .filter(file -> !file.getFileName().toString().equals(Config.PROJECTS_JSON)) + .map(this::loadFromFile) + .sorted(comparing(JsonMonth::getYear).thenComparing(JsonMonth::getMonth)) + .collect(toList()); + } +} diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/MonthCache.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/MonthCache.java new file mode 100644 index 00000000..7c659683 --- /dev/null +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/MonthCache.java @@ -0,0 +1,38 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import org.itsallcode.whiterabbit.logic.model.DayRecord; +import org.itsallcode.whiterabbit.logic.model.MonthIndex; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.Comparator; +import java.util.List; +import java.util.TreeMap; + +import static java.util.stream.Collectors.toList; + +class MonthCache +{ + private final TreeMap cache = new TreeMap<>( + Comparator. naturalOrder().reversed()); + + void update(MonthIndex month) + { + cache.put(month.getYearMonth(), month); + } + + boolean contains(YearMonth month) + { + return cache.containsKey(month); + } + + List getLatestDays(LocalDate maxAge) + { + final YearMonth oldestYearMonth = YearMonth.from(maxAge); + return cache.values().stream() + .filter(month -> !month.getYearMonth().isBefore(oldestYearMonth)) + .flatMap(MonthIndex::getSortedDays) + .filter(day -> !day.getDate().isBefore(maxAge)) + .collect(toList()); + } +} diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/MonthIndexStorage.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/MonthIndexStorage.java new file mode 100644 index 00000000..37687b84 --- /dev/null +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/MonthIndexStorage.java @@ -0,0 +1,93 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import static java.util.stream.Collectors.toList; + +import java.time.Duration; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.itsallcode.whiterabbit.logic.model.MonthIndex; +import org.itsallcode.whiterabbit.logic.model.MultiMonthIndex; +import org.itsallcode.whiterabbit.logic.model.json.JsonMonth; +import org.itsallcode.whiterabbit.logic.service.contract.ContractTermsService; +import org.itsallcode.whiterabbit.logic.service.project.ProjectService; + +class MonthIndexStorage implements Storage +{ + private static final Logger LOG = LogManager.getLogger(MonthIndexStorage.class); + + private final ContractTermsService contractTerms; + private final ProjectService projectService; + private final JsonFileStorage fileStorage; + + MonthIndexStorage(ContractTermsService contractTerms, ProjectService projectService, + JsonFileStorage fileStorage) + { + this.contractTerms = contractTerms; + this.projectService = projectService; + this.fileStorage = fileStorage; + } + + @Override + public Optional loadMonth(YearMonth date) + { + return fileStorage.loadMonthRecord(date).map(this::createMonthIndex); + } + + @Override + public MonthIndex loadOrCreate(final YearMonth yearMonth) + { + final Optional month = loadMonth(yearMonth); + return month.orElseGet(() -> createNewMonth(yearMonth)); + } + + @Override + public void storeMonth(MonthIndex month) + { + fileStorage.writeToFile(month.getYearMonth(), month.getMonthRecord()); + } + + @Override + public MultiMonthIndex loadAll() + { + return new MultiMonthIndex(fileStorage.loadAll().stream() + .map(this::createMonthIndex) + .collect(toList())); + } + + @Override + public List getAvailableDataYearMonth() + { + return fileStorage.getAvailableDataYearMonth(); + } + + private MonthIndex createNewMonth(YearMonth date) + { + final JsonMonth month = new JsonMonth(); + month.setYear(date.getYear()); + month.setMonth(date.getMonth()); + month.setDays(new ArrayList<>()); + month.setOvertimePreviousMonth(loadPreviousMonthOvertime(date)); + return createMonthIndex(month); + } + + private MonthIndex createMonthIndex(final JsonMonth jsonMonth) + { + return MonthIndex.create(contractTerms, jsonMonth, projectService); + } + + Duration loadPreviousMonthOvertime(YearMonth date) + { + final YearMonth previousYearMonth = date.minus(1, ChronoUnit.MONTHS); + final Duration overtime = loadMonth(previousYearMonth) + .map(m -> m.getTotalOvertime().truncatedTo(ChronoUnit.MINUTES)) + .orElse(Duration.ZERO); + LOG.info("Found overtime {} for previous month {}", overtime, previousYearMonth); + return overtime; + } +} diff --git a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/Storage.java b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/Storage.java index 8df67480..011ad687 100644 --- a/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/Storage.java +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/storage/Storage.java @@ -1,179 +1,22 @@ package org.itsallcode.whiterabbit.logic.storage; -import static java.util.stream.Collectors.toList; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.time.Duration; import java.time.YearMonth; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.Optional; -import javax.json.bind.Jsonb; -import javax.json.bind.JsonbBuilder; -import javax.json.bind.JsonbConfig; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.itsallcode.whiterabbit.logic.model.MonthIndex; import org.itsallcode.whiterabbit.logic.model.MultiMonthIndex; -import org.itsallcode.whiterabbit.logic.model.json.JsonMonth; -import org.itsallcode.whiterabbit.logic.service.contract.ContractTermsService; -import org.itsallcode.whiterabbit.logic.service.project.ProjectService; -public class Storage +public interface Storage { - private static final Logger LOG = LogManager.getLogger(Storage.class); - - private final Jsonb jsonb; - private final DateToFileMapper dateToFileMapper; - - private final ContractTermsService contractTerms; - private final ProjectService projectService; - - private Storage(DateToFileMapper dateToFileMapper, ContractTermsService contractTerms, - ProjectService projectService, Jsonb jsonb) - { - this.dateToFileMapper = dateToFileMapper; - this.contractTerms = contractTerms; - this.projectService = projectService; - this.jsonb = jsonb; - } - - public Storage(DateToFileMapper dateToFileMapper, ContractTermsService contractTerms, ProjectService projectService) - { - this(dateToFileMapper, contractTerms, projectService, - JsonbBuilder.create(new JsonbConfig().withFormatting(true))); - } - - public Optional loadMonth(YearMonth date) - { - return loadMonthRecord(date).map(this::createMonthIndex); - } - - public MonthIndex loadOrCreate(final YearMonth yearMonth) - { - final Optional month = loadMonth(yearMonth); - return month.orElseGet(() -> createNewMonth(yearMonth)); - } - - private MonthIndex createNewMonth(YearMonth date) - { - final JsonMonth month = new JsonMonth(); - month.setYear(date.getYear()); - month.setMonth(date.getMonth()); - month.setDays(new ArrayList<>()); - month.setOvertimePreviousMonth(loadPreviousMonthOvertime(date)); - return createMonthIndex(month); - } - - private MonthIndex createMonthIndex(final JsonMonth month) - { - return MonthIndex.create(contractTerms, month, projectService); - } - - public void storeMonth(MonthIndex month) - { - writeToFile(month); - } - - public MultiMonthIndex loadAll() - { - final List months = new ArrayList<>(); - for (final Path file : dateToFileMapper.getAllFiles().collect(toList())) - { - final JsonMonth jsonMonth = loadFromFile(file); - months.add(createMonthIndex(jsonMonth)); - } - - months.sort(Comparator.comparing(MonthIndex::getYearMonth)); - - return new MultiMonthIndex(months); - } - - private void writeToFile(MonthIndex month) - { - final Path file = dateToFileMapper.getPathForDate(month.getYearMonth()); - LOG.info("Write month {} to file {}", month.getYearMonth(), file); - createDirectory(file.getParent()); - try (OutputStream stream = Files.newOutputStream(file, StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING)) - { - jsonb.toJson(month.getMonthRecord(), stream); - } - catch (final IOException e) - { - throw new UncheckedIOException("Error writing file " + file, e); - } - } + Optional loadMonth(YearMonth date); - private void createDirectory(Path dir) - { - if (dir.toFile().isDirectory()) - { - return; - } - try - { - Files.createDirectories(dir); - } - catch (final IOException e) - { - throw new UncheckedIOException("Error creating dir " + dir, e); - } - } + MonthIndex loadOrCreate(YearMonth yearMonth); - private Optional loadMonthRecord(YearMonth date) - { - final Path file = dateToFileMapper.getPathForDate(date); - if (file.toFile().exists()) - { - LOG.trace("Found file {} for month {}", file, date); - return Optional.of(loadFromFile(file)); - } - final Path legacyFile = dateToFileMapper.getLegacyPathForDate(date); - if (legacyFile.toFile().exists()) - { - LOG.trace("Found legacy file {} for month {}", file, date); - return Optional.of(loadFromFile(legacyFile)); - } - LOG.debug("File {} not found for month {}", file, date); - return Optional.empty(); - } + void storeMonth(MonthIndex month); - public Duration loadPreviousMonthOvertime(YearMonth date) - { - final YearMonth previousYearMonth = date.minus(1, ChronoUnit.MONTHS); - final Duration overtime = loadMonth(previousYearMonth) // - .map(m -> m.getTotalOvertime().truncatedTo(ChronoUnit.MINUTES)) // - .orElse(Duration.ZERO); - LOG.info("Found overtime {} for previous month {}", overtime, previousYearMonth); - return overtime; - } + MultiMonthIndex loadAll(); - private JsonMonth loadFromFile(Path file) - { - LOG.trace("Reading file {}", file); - try (InputStream stream = Files.newInputStream(file)) - { - return jsonb.fromJson(stream, JsonMonth.class); - } - catch (final IOException e) - { - throw new UncheckedIOException("Error reading file " + file, e); - } - } + List getAvailableDataYearMonth(); - public List getAvailableDataYearMonth() - { - return dateToFileMapper.getAllYearMonths().sorted().collect(toList()); - } -} +} \ No newline at end of file diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteServiceTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteServiceTest.java new file mode 100644 index 00000000..b5b96b5d --- /dev/null +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteServiceTest.java @@ -0,0 +1,174 @@ +package org.itsallcode.whiterabbit.logic.autocomplete; + +import org.itsallcode.whiterabbit.logic.model.Activity; +import org.itsallcode.whiterabbit.logic.model.DayActivities; +import org.itsallcode.whiterabbit.logic.model.DayRecord; +import org.itsallcode.whiterabbit.logic.service.ClockService; +import org.itsallcode.whiterabbit.logic.service.project.Project; +import org.itsallcode.whiterabbit.logic.storage.CachingStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.Month; +import java.time.Period; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static java.util.Arrays.asList; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AutocompleteServiceTest +{ + @Mock + CachingStorage storageMock; + @Mock + private ClockService clockServiceMock; + + private AutocompleteService autocompleteService; + + @BeforeEach + void setUp() + { + autocompleteService = new AutocompleteService(storageMock, clockServiceMock); + } + + @Test + void dayCommentAutocompleter() + { + simulateDays(dayRecordsWithComments(null, "", "Comment A", "Comment B")); + assertThat(autocompleteService.dayCommentAutocompleter().getEntries("comm")) + .containsExactly("Comment A", "Comment B"); + } + + @Test + void activityCommentAutocompleter() + { + simulateDays(createDayRecordsWithActivityComments(null, "", "Comment A", "Comment B")); + assertThat(autocompleteService.activityCommentAutocompleter().getEntries("comm")) + .containsExactly("Comment A", "Comment B"); + } + + @ParameterizedTest(name = "[{index}] available values {0}, search text ''{1}''") + @ArgumentsSource(AutocompleterArgumentsProvider.class) + void autocompleter(List availableEntries, String searchText, List expectedResult) + { + assertThat(autocompleteService.autocompleter(availableEntries).getEntries(searchText)) + .as("autocomplete for available values " + availableEntries + " and search text '" + searchText + "'") + .containsExactly(expectedResult.toArray(new String[0])); + } + + private static class AutocompleterArgumentsProvider implements ArgumentsProvider + { + @Override + public Stream provideArguments(ExtensionContext context) throws Exception + { + return Stream.of( + Arguments.of(List.of(), "text", List.of()), + Arguments.of(List.of("text"), null, List.of()), + Arguments.of(List.of("text"), "", List.of()), + Arguments.of(List.of("text"), " ", List.of()), + Arguments.of(List.of("TEXT"), "text", List.of("TEXT")), + Arguments.of(List.of("match", "nomatch"), "ma", List.of("match")), + Arguments.of(List.of("match1", "match2"), "ma", List.of("match1", "match2")), + Arguments.of(List.of("first second"), "fi", List.of("first second")), + Arguments.of(List.of("first second"), "sec", List.of())); + } + } + + @Test + void getSuggestedProject_returnsEmptyOptional_whenNoDataAvailable() + { + simulateDays(createDayRecordsWithActivityProjects()); + assertThat(autocompleteService.getSuggestedProject()).isEmpty(); + } + + @Test + void getSuggestedProject_singleProject() + { + simulateDays(createDayRecordsWithActivityProjects("p1")); + assertProjectFound("p1"); + } + + @Test + void getSuggestedProject_mostFrequentProjectReturned() + { + simulateDays(createDayRecordsWithActivityProjects("p1", "p2", "p2")); + assertProjectFound("p2"); + } + + private void assertProjectFound(String expectedProjectId) + { + final Optional suggestedProject = autocompleteService.getSuggestedProject(); + assertThat(suggestedProject).isPresent(); + assertThat(suggestedProject.get().getProjectId()).isEqualTo(expectedProjectId); + } + + private void simulateDays(final List days) + { + final LocalDate now = LocalDate.of(2020, Month.APRIL, 1); + final LocalDate maxAge = now.minus(Period.ofMonths(2)); + when(clockServiceMock.getCurrentDate()).thenReturn(now); + when(storageMock.getLatestDays(maxAge)).thenReturn(days); + } + + private List createDayRecordsWithActivityComments(String... comments) + { + return Arrays.stream(comments).map(this::createDayRecordWithActivityComment).collect(toList()); + } + + private List createDayRecordsWithActivityProjects(String... projectIds) + { + return Arrays.stream(projectIds).map(this::createDayRecordWithProject).collect(toList()); + } + + private DayRecord createDayRecordWithProject(String projectId) + { + final Activity activity = mock(Activity.class); + final Project project = new Project(projectId, "Project " + projectId, null); + when(activity.getProject()).thenReturn(project); + return dayWithActivities(activity); + } + + private DayRecord createDayRecordWithActivityComment(String comment) + { + final Activity activity = mock(Activity.class); + when(activity.getComment()).thenReturn(comment); + return dayWithActivities(activity); + } + + private DayRecord dayWithActivities(Activity... activityList) + { + final DayActivities activities = mock(DayActivities.class); + when(activities.getAll()).thenReturn(asList(activityList)); + final DayRecord dayRecord = mock(DayRecord.class); + when(dayRecord.activities()).thenReturn(activities); + return dayRecord; + } + + private List dayRecordsWithComments(String... comments) + { + return Arrays.stream(comments).map(this::createDayRecord).collect(toList()); + } + + private DayRecord createDayRecord(String comment) + { + final DayRecord dayRecord = mock(DayRecord.class); + when(dayRecord.getComment()).thenReturn(comment); + return dayRecord; + } +} \ No newline at end of file diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/ActivityServiceTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/ActivityServiceTest.java index dda284a2..f1ce61cf 100644 --- a/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/ActivityServiceTest.java +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/ActivityServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -13,9 +14,11 @@ import java.util.NoSuchElementException; import java.util.Optional; +import org.itsallcode.whiterabbit.logic.autocomplete.AutocompleteService; import org.itsallcode.whiterabbit.logic.model.MonthIndex; import org.itsallcode.whiterabbit.logic.model.json.JsonMonth; import org.itsallcode.whiterabbit.logic.service.contract.ContractTermsService; +import org.itsallcode.whiterabbit.logic.service.project.Project; import org.itsallcode.whiterabbit.logic.service.project.ProjectService; import org.itsallcode.whiterabbit.logic.storage.Storage; import org.junit.jupiter.api.BeforeEach; @@ -36,13 +39,15 @@ class ActivityServiceTest private AppServiceCallback appServiceCallbackMock; @Mock private ProjectService projectService; + @Mock + private AutocompleteService autocompleteServiceMock; private ActivityService service; @BeforeEach void setUp() { - service = new ActivityService(storageMock, appServiceCallbackMock); + service = new ActivityService(storageMock, autocompleteServiceMock, appServiceCallbackMock); } @Test @@ -68,6 +73,38 @@ void addActivity() assertThat(month.getDay(DATE).activities().getAll()).hasSize(1); } + @Test + void addActivityWithProposedProject() + { + final MonthIndex month = createMonth(); + when(storageMock.loadMonth(YearMonth.from(DATE))) + .thenReturn(Optional.of(month)); + + final Project project = mock(Project.class); + when(project.getProjectId()).thenReturn("projectId"); + when(projectService.getProjectById(project.getProjectId())).thenReturn(Optional.of(project)); + + when(autocompleteServiceMock.getSuggestedProject()).thenReturn(Optional.of(project)); + + service.addActivity(DATE); + + assertThat(month.getDay(DATE).activities().getAll().get(0).getProject()).isSameAs(project); + } + + @Test + void addActivityWithoutProposedProject() + { + final MonthIndex month = createMonth(); + when(storageMock.loadMonth(YearMonth.from(DATE))) + .thenReturn(Optional.of(month)); + + when(autocompleteServiceMock.getSuggestedProject()).thenReturn(Optional.empty()); + + service.addActivity(DATE); + + assertThat(month.getDay(DATE).activities().getAll().get(0).getProject()).isNull(); + } + @Test void removeActivity() { diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/AppServiceTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/AppServiceTest.java index 8e0444c0..38f11f30 100644 --- a/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/AppServiceTest.java +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/service/AppServiceTest.java @@ -22,6 +22,7 @@ import java.time.temporal.ChronoUnit; import org.itsallcode.whiterabbit.logic.Config; +import org.itsallcode.whiterabbit.logic.autocomplete.AutocompleteService; import org.itsallcode.whiterabbit.logic.model.DayRecord; import org.itsallcode.whiterabbit.logic.model.MonthIndex; import org.itsallcode.whiterabbit.logic.model.json.JsonDay; @@ -72,6 +73,8 @@ class AppServiceTest private ProjectService projectServiceMock; @Mock private AppPropertiesService appPropertiesServiceMock; + @Mock + private AutocompleteService autocompleteServiceMock; private AppService appService; @@ -83,7 +86,8 @@ void setUp() appServiceCallback); appService = new AppService(workingTimeService, storageMock, formatterServiceMock, clockMock, schedulingServiceMock, singleInstanceService, appServiceCallback, - vacationServiceMock, activityService, projectServiceMock, appPropertiesServiceMock); + vacationServiceMock, activityService, projectServiceMock, autocompleteServiceMock, + appPropertiesServiceMock); appService.setUpdateListener(updateListenerMock); } diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/CachingStorageImplTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/CachingStorageImplTest.java new file mode 100644 index 00000000..81934975 --- /dev/null +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/CachingStorageImplTest.java @@ -0,0 +1,243 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import org.itsallcode.whiterabbit.logic.model.DayRecord; +import org.itsallcode.whiterabbit.logic.model.MonthIndex; +import org.itsallcode.whiterabbit.logic.model.MultiMonthIndex; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.Month; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CachingStorageImplTest +{ + private static final YearMonth YEAR_MONTH = YearMonth.of(2020, Month.NOVEMBER); + + @Mock + MonthCache cacheMock; + @Mock + Storage delegateStorageMock; + @Mock + MonthIndex monthIndexMock1; + @Mock + MonthIndex monthIndexMock2; + + CachingStorageImpl storage; + + @BeforeEach + void setUp() + { + storage = new CachingStorageImpl(delegateStorageMock, cacheMock); + } + + @Test + void loadMonth_updatesCache_whenMonthFound() + { + when(delegateStorageMock.loadMonth(YEAR_MONTH)).thenReturn(Optional.of(monthIndexMock1)); + storage.loadMonth(YEAR_MONTH); + + verifyListenerUpdated(); + } + + @Test + void loadMonth_doesNotUpdateCache_whenMonthNotFound() + { + when(delegateStorageMock.loadMonth(YEAR_MONTH)).thenReturn(Optional.empty()); + storage.loadMonth(YEAR_MONTH); + + verifyNoInteractions(cacheMock); + } + + @Test + void loadOrCreate_updatesCache() + { + when(delegateStorageMock.loadOrCreate(YEAR_MONTH)).thenReturn(monthIndexMock1); + storage.loadOrCreate(YEAR_MONTH); + verifyListenerUpdated(); + } + + @Test + void storeMonth_updatesCache() + { + storage.storeMonth(monthIndexMock1); + verifyListenerUpdated(); + } + + @Test + void loadAll_updatesCache_whenEntriesFound() + { + when(delegateStorageMock.loadAll()).thenReturn(new MultiMonthIndex(List.of(monthIndexMock1))); + + storage.loadAll(); + verifyListenerUpdated(); + } + + @Test + void loadAll_doesNotUpdateCache_whenNoEntriesFound() + { + when(delegateStorageMock.loadAll()).thenReturn(new MultiMonthIndex(emptyList())); + + storage.loadAll(); + verifyNoInteractions(cacheMock); + } + + @Test + void getRequiredYearMonths_returnsEmptyList_whenNoDataAvailable() + { + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(emptyList()); + + assertThat(storage.getRequiredYearMonths(LocalDate.of(2020, Month.JANUARY, 1))).isEmpty(); + } + + @Test + void getRequiredYearMonths_returnsRequiredMonths() + { + final YearMonth january = YearMonth.of(2020, Month.JANUARY); + final YearMonth february = YearMonth.of(2020, Month.FEBRUARY); + final YearMonth march = YearMonth.of(2020, Month.MARCH); + final YearMonth april = YearMonth.of(2020, Month.APRIL); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(List.of(january, february, march, april)); + + assertThat(storage.getRequiredYearMonths(LocalDate.of(2020, Month.JANUARY, 1))) + .containsExactly(january, february, march, april); + } + + @Test + void getRequiredYearMonths_includesMonthForLastDayOfTheMonth() + { + final YearMonth january = YearMonth.of(2020, Month.JANUARY); + final YearMonth february = YearMonth.of(2020, Month.FEBRUARY); + final YearMonth march = YearMonth.of(2020, Month.MARCH); + final YearMonth april = YearMonth.of(2020, Month.APRIL); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(List.of(january, february, march, april)); + + assertThat(storage.getRequiredYearMonths(LocalDate.of(2020, Month.JANUARY, 31))) + .containsExactly(january, february, march, april); + } + + @Test + void getRequiredYearMonths_omitsOlderMonths() + { + final YearMonth january = YearMonth.of(2020, Month.JANUARY); + final YearMonth february = YearMonth.of(2020, Month.FEBRUARY); + final YearMonth march = YearMonth.of(2020, Month.MARCH); + final YearMonth april = YearMonth.of(2020, Month.APRIL); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(List.of(january, february, march, april)); + + assertThat(storage.getRequiredYearMonths(LocalDate.of(2020, Month.FEBRUARY, 1))) + .containsExactly(february, march, april); + } + + @Test + void ensureLatestDaysCached_dataAvailable() + { + final YearMonth january = YearMonth.of(2020, Month.JANUARY); + final YearMonth february = YearMonth.of(2020, Month.FEBRUARY); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(List.of(january, february)); + + when(cacheMock.contains(january)).thenReturn(false); + when(cacheMock.contains(february)).thenReturn(false); + + when(delegateStorageMock.loadMonth(january)).thenReturn(Optional.of(monthIndexMock1)); + when(delegateStorageMock.loadMonth(february)).thenReturn(Optional.of(monthIndexMock2)); + + storage.ensureLatestDaysCached(LocalDate.of(2020, Month.JANUARY, 1)); + + final InOrder inOrder = inOrder(cacheMock, delegateStorageMock); + inOrder.verify(delegateStorageMock).loadMonth(january); + inOrder.verify(cacheMock).update(monthIndexMock1); + inOrder.verify(delegateStorageMock).loadMonth(february); + inOrder.verify(cacheMock).update(monthIndexMock2); + + inOrder.verifyNoMoreInteractions(); + } + + @Test + void ensureLatestDaysCached_dataAvailable_alreadyCached() + { + final YearMonth january = YearMonth.of(2020, Month.JANUARY); + final YearMonth february = YearMonth.of(2020, Month.FEBRUARY); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(List.of(january, february)); + + when(cacheMock.contains(january)).thenReturn(true); + when(cacheMock.contains(february)).thenReturn(true); + + storage.ensureLatestDaysCached(LocalDate.of(2020, Month.JANUARY, 1)); + + verify(delegateStorageMock, never()).loadMonth(any()); + verify(cacheMock, never()).update(any()); + } + + @Test + void ensureLatestDaysCached_cacheNotUpdatedWhenMonthNotFound() + { + final YearMonth january = YearMonth.of(2020, Month.JANUARY); + final YearMonth february = YearMonth.of(2020, Month.FEBRUARY); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(List.of(january, february)); + + when(delegateStorageMock.loadMonth(january)).thenReturn(Optional.empty()); + when(delegateStorageMock.loadMonth(february)).thenReturn(Optional.empty()); + + storage.ensureLatestDaysCached(LocalDate.of(2020, Month.JANUARY, 1)); + + verify(cacheMock, never()).update(any()); + } + + @Test + void getLatestDays_delegatesToCache() + { + + final YearMonth january = YearMonth.of(2020, Month.JANUARY); + final YearMonth february = YearMonth.of(2020, Month.FEBRUARY); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(List.of(january, february)); + + when(cacheMock.contains(january)).thenReturn(false); + when(cacheMock.contains(february)).thenReturn(false); + + when(delegateStorageMock.loadMonth(january)).thenReturn(Optional.of(monthIndexMock1)); + when(delegateStorageMock.loadMonth(february)).thenReturn(Optional.of(monthIndexMock2)); + + final LocalDate firstOfJanuary = LocalDate.of(2020, Month.JANUARY, 1); + final List days = new ArrayList<>(); + when(cacheMock.getLatestDays(firstOfJanuary)).thenReturn(days); + + assertThat(storage.getLatestDays(firstOfJanuary)).isSameAs(days); + + final InOrder inOrder = inOrder(cacheMock, delegateStorageMock); + inOrder.verify(delegateStorageMock).loadMonth(january); + inOrder.verify(cacheMock).update(monthIndexMock1); + inOrder.verify(delegateStorageMock).loadMonth(february); + inOrder.verify(cacheMock).update(monthIndexMock2); + inOrder.verify(cacheMock).getLatestDays(firstOfJanuary); + + inOrder.verifyNoMoreInteractions(); + } + + private void verifyListenerUpdated() + { + verify(cacheMock).update(same(monthIndexMock1)); + } + + @Test + void getAvailableDataYearMonth_delegates() + { + final List list = new ArrayList<>(); + when(delegateStorageMock.getAvailableDataYearMonth()).thenReturn(list); + assertThat(storage.getAvailableDataYearMonth()).isSameAs(list); + } +} diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapperTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapperTest.java index 8b854faa..cbf7af96 100644 --- a/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapperTest.java +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/DateToFileMapperTest.java @@ -32,6 +32,13 @@ void testGetPathForDate(@TempDir Path tempDir) assertThat(path).isEqualTo(tempDir.resolve("2019/2019-05.json")); } + @Test + void testGetLegacyPathForDate(@TempDir Path tempDir) + { + final Path path = mapper.getLegacyPathForDate(YearMonth.of(2019, Month.MAY)); + assertThat(path).isEqualTo(tempDir.resolve("2019-05.json")); + } + @Test void testGetAllFilesDirDoesNotExist() { diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/JsonFileStorageTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/JsonFileStorageTest.java new file mode 100644 index 00000000..863c9f98 --- /dev/null +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/JsonFileStorageTest.java @@ -0,0 +1,216 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Month; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import javax.json.bind.Jsonb; +import javax.json.bind.JsonbBuilder; +import javax.json.bind.JsonbConfig; + +import org.itsallcode.whiterabbit.logic.model.json.JsonMonth; +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; + +@ExtendWith(MockitoExtension.class) +class JsonFileStorageTest +{ + private static final YearMonth YEAR_MONTH = YearMonth.of(2020, Month.NOVEMBER); + @TempDir + Path tempDir; + @Mock + DateToFileMapper dateToFileMapperMock; + + JsonFileStorage jsonFileStorage; + Jsonb jsonb; + + @BeforeEach + void setUp() + { + jsonb = JsonbBuilder.create(new JsonbConfig().withFormatting(false)); + jsonFileStorage = new JsonFileStorage(jsonb, dateToFileMapperMock); + } + + @Test + void sortsAvailableYearMonths() + { + final YearMonth april2020 = YearMonth.of(2020, Month.APRIL); + final YearMonth january2020 = YearMonth.of(2020, Month.JANUARY); + final YearMonth december2019 = YearMonth.of(2019, Month.DECEMBER); + when(dateToFileMapperMock.getAllYearMonths()).thenReturn(Stream.of(april2020, january2020, december2019)); + assertThat(jsonFileStorage.getAvailableDataYearMonth()).containsExactly(december2019, january2020, april2020); + } + + @Test + void returnsEmptyAvailableMonths() + { + when(dateToFileMapperMock.getAllYearMonths()).thenReturn(Stream.empty()); + assertThat(jsonFileStorage.getAvailableDataYearMonth()).isEmpty(); + } + + @Test + void loadMonthReturnsEmptyOptional_WhenLegacyFileDoesNotExist() + { + when(dateToFileMapperMock.getPathForDate(YEAR_MONTH)).thenReturn(tempDir.resolve("does-not-exist")); + when(dateToFileMapperMock.getLegacyPathForDate(YEAR_MONTH)).thenReturn(tempDir.resolve("does-not-exist")); + assertThat(jsonFileStorage.loadMonthRecord(YEAR_MONTH)).isEmpty(); + } + + @Test + void loadMonthReturnsMonth_WhenLegacyFileExists() throws IOException + { + final JsonMonth month = new JsonMonth(); + month.setYear(2020); + final Path file = writeTempFile(month); + + when(dateToFileMapperMock.getPathForDate(YEAR_MONTH)).thenReturn(tempDir.resolve("does-not-exist")); + when(dateToFileMapperMock.getLegacyPathForDate(YEAR_MONTH)).thenReturn(file); + final Optional loadedMonth = jsonFileStorage.loadMonthRecord(YEAR_MONTH); + assertThat(loadedMonth).isNotEmpty(); + assertThat(loadedMonth.get().getYear()).isEqualTo(2020); + } + + @Test + void loadMonthReturnsMonth_WhenFileExists() throws IOException + { + final JsonMonth month = new JsonMonth(); + month.setYear(2020); + final Path file = writeTempFile(month); + + when(dateToFileMapperMock.getPathForDate(YEAR_MONTH)).thenReturn(file); + final Optional loadedMonth = jsonFileStorage.loadMonthRecord(YEAR_MONTH); + assertThat(loadedMonth).isNotEmpty(); + assertThat(loadedMonth.get().getYear()).isEqualTo(2020); + } + + @Test + void writeToFile_writesFile() throws IOException + { + final Path file = createTempFile(); + when(dateToFileMapperMock.getPathForDate(YEAR_MONTH)).thenReturn(file); + + final JsonMonth month = new JsonMonth(); + month.setYear(2020); + jsonFileStorage.writeToFile(YEAR_MONTH, month); + + assertThat(file).exists().hasContent("{\"year\":2020}"); + } + + @Test + void writeToFile_createsDirectory() throws IOException + { + final Path file = tempDir.resolve("sub-dir1").resolve("sub-dir2").resolve("file.json"); + when(dateToFileMapperMock.getPathForDate(YEAR_MONTH)).thenReturn(file); + + final JsonMonth month = new JsonMonth(); + month.setYear(2020); + jsonFileStorage.writeToFile(YEAR_MONTH, month); + + assertThat(file).exists().hasContent("{\"year\":2020}"); + } + + @Test + void loadAll_returnsEmptyList_WhenNoFileExists() + { + when(dateToFileMapperMock.getAllFiles()).thenReturn(Stream.empty()); + assertThat(jsonFileStorage.loadAll()).isEmpty(); + } + + @Test + void loadAll_failsWhenFileDoesNotExist() + { + final Path notExistingFile = tempDir.resolve("does-not-exist"); + when(dateToFileMapperMock.getAllFiles()).thenReturn(Stream.of(notExistingFile)); + assertThatThrownBy(() -> jsonFileStorage.loadAll()) + .isInstanceOf(UncheckedIOException.class) + .hasMessage("Error reading file " + notExistingFile); + } + + @Test + void loadAll_returnsFiles() throws IOException + { + final Path file1 = tempDir.resolve("file1.json"); + final Path file2 = tempDir.resolve("file2.json"); + when(dateToFileMapperMock.getAllFiles()).thenReturn(Stream.of(file1, file2)); + + final JsonMonth month1 = month(2019, Month.DECEMBER); + final JsonMonth month2 = month(2020, Month.JANUARY); + writeMonth(month1, file1); + writeMonth(month2, file2); + + final List months = jsonFileStorage.loadAll(); + assertThat(months).hasSize(2); + + assertMonth(months.get(0), month1); + assertMonth(months.get(1), month2); + } + + @Test + void loadAll_sortsByYearMonth() throws IOException + { + final Path file1 = tempDir.resolve("file1.json"); + final Path file2 = tempDir.resolve("file2.json"); + final Path file3 = tempDir.resolve("file3.json"); + when(dateToFileMapperMock.getAllFiles()).thenReturn(Stream.of(file3, file2, file1)); + + final JsonMonth month1 = month(2019, Month.DECEMBER); + final JsonMonth month2 = month(2020, Month.JANUARY); + final JsonMonth month3 = month(2020, Month.APRIL); + writeMonth(month1, file1); + writeMonth(month2, file2); + writeMonth(month3, file3); + + final List months = jsonFileStorage.loadAll(); + assertThat(months).hasSize(3); + + assertMonth(months.get(0), month1); + assertMonth(months.get(1), month2); + assertMonth(months.get(2), month3); + } + + private void assertMonth(JsonMonth actual, JsonMonth expected) + { + assertAll(() -> assertThat(actual.getYear()).isEqualTo(expected.getYear()), + () -> assertThat(actual.getMonth()).isEqualTo(expected.getMonth())); + } + + private JsonMonth month(int year, Month month) + { + final JsonMonth jsonMonth = new JsonMonth(); + jsonMonth.setYear(year); + jsonMonth.setMonth(month); + return jsonMonth; + } + + private Path writeTempFile(JsonMonth month) throws IOException + { + final Path file = createTempFile(); + writeMonth(month, file); + return file; + } + + private void writeMonth(JsonMonth month, final Path file) throws IOException + { + Files.writeString(file, jsonb.toJson(month)); + } + + private Path createTempFile() throws IOException + { + return Files.createTempFile(tempDir, "month", ".json"); + } +} diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/MonthCacheTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/MonthCacheTest.java new file mode 100644 index 00000000..e8a75008 --- /dev/null +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/MonthCacheTest.java @@ -0,0 +1,108 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.time.Month; +import java.time.YearMonth; +import java.util.stream.Stream; + +import org.itsallcode.whiterabbit.logic.model.DayRecord; +import org.itsallcode.whiterabbit.logic.model.MonthIndex; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MonthCacheTest +{ + private static final LocalDate VERY_OLD = LocalDate.of(1900, Month.JANUARY, 1); + private static final LocalDate RECENT = LocalDate.of(2020, Month.SEPTEMBER, 15); + + MonthCache cache; + + @BeforeEach + void setUp() + { + cache = new MonthCache(); + } + + @Test + void getLatestDays_emptyCache_returnsEmptyList() + { + assertThat(cache.getLatestDays(VERY_OLD)).isEmpty(); + } + + @Test + void getLatestDays_singleResult() + { + final DayRecord day1 = day(LocalDate.of(2020, Month.OCTOBER, 25)); + cache.update(month(YearMonth.of(2020, Month.OCTOBER), day1)); + assertThat(cache.getLatestDays(VERY_OLD)).containsExactly(day1); + } + + @Test + void updateOverwritesExistingEntry() + { + final DayRecord day1 = day(LocalDate.of(2020, Month.OCTOBER, 25)); + final DayRecord day2 = day(LocalDate.of(2020, Month.OCTOBER, 26)); + + cache.update(month(YearMonth.of(2020, Month.OCTOBER), day1)); + assertThat(cache.getLatestDays(VERY_OLD)).containsExactly(day1); + + cache.update(month(YearMonth.of(2020, Month.OCTOBER), day2)); + assertThat(cache.getLatestDays(VERY_OLD)).containsExactly(day2); + } + + @Test + void getLatestDays_filtersOldMonths() + { + final DayRecord day1 = day(LocalDate.of(2020, Month.OCTOBER, 25)); + cache.update(month(YearMonth.from(RECENT).minusMonths(1), day1)); + + assertThat(cache.getLatestDays(RECENT)).isEmpty(); + } + + @Test + void getLatestDays_includesExactMonth() + { + final DayRecord day1 = day(LocalDate.of(2020, Month.OCTOBER, 25)); + cache.update(month(YearMonth.from(RECENT), day1)); + + assertThat(cache.getLatestDays(RECENT)).containsExactly(day1); + } + + @Test + void getLatestDays_filtersOldDays() + { + final DayRecord day1 = day(RECENT.minusDays(1)); + cache.update(month(YearMonth.from(RECENT), day1)); + + assertThat(cache.getLatestDays(RECENT)).isEmpty(); + } + + @Test + void getLatestDays_includesExactDay() + { + final DayRecord day1 = day(RECENT); + cache.update(month(YearMonth.from(RECENT), day1)); + + assertThat(cache.getLatestDays(RECENT)).containsExactly(day1); + } + + private DayRecord day(LocalDate date) + { + final DayRecord day = mock(DayRecord.class); + when(day.getDate()).thenReturn(date); + return day; + } + + private MonthIndex month(YearMonth yearMonth, DayRecord... days) + { + final MonthIndex monthMock = mock(MonthIndex.class); + when(monthMock.getYearMonth()).thenReturn(yearMonth); + when(monthMock.getSortedDays()).thenReturn(Stream.of(days)); + return monthMock; + } + +} diff --git a/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/MonthIndexStorageTest.java b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/MonthIndexStorageTest.java new file mode 100644 index 00000000..f010aa1a --- /dev/null +++ b/logic/src/test/java/org/itsallcode/whiterabbit/logic/storage/MonthIndexStorageTest.java @@ -0,0 +1,157 @@ +package org.itsallcode.whiterabbit.logic.storage; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Month; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.itsallcode.whiterabbit.logic.model.MonthIndex; +import org.itsallcode.whiterabbit.logic.model.MultiMonthIndex; +import org.itsallcode.whiterabbit.logic.model.json.JsonMonth; +import org.itsallcode.whiterabbit.logic.service.contract.ContractTermsService; +import org.itsallcode.whiterabbit.logic.service.project.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; + +@ExtendWith(MockitoExtension.class) +class MonthIndexStorageTest +{ + private static final YearMonth YEAR_MONTH = YearMonth.of(2020, Month.NOVEMBER); + private static final YearMonth PREVIOUS_MONTH = YearMonth.of(2020, Month.OCTOBER); + + @Mock + ContractTermsService contractTermsMock; + @Mock + ProjectService projectServiceMock; + @Mock + JsonFileStorage fileStorageMock; + @Mock + MonthIndex monthIndexMock; + + private MonthIndexStorage storage; + + @BeforeEach + void setUp() + { + storage = new MonthIndexStorage(contractTermsMock, projectServiceMock, fileStorageMock); + } + + @Test + void loadPreviousMonthOvertime_returnsZero_whenNoPreviousMonth() + { + when(fileStorageMock.loadMonthRecord(YearMonth.of(2020, Month.OCTOBER))).thenReturn(Optional.empty()); + + assertThat(storage.loadPreviousMonthOvertime(YEAR_MONTH)).isZero(); + } + + @Test + void loadPreviousMonthOvertime_returnsPreviousMonthOvertime() + { + when(fileStorageMock.loadMonthRecord(PREVIOUS_MONTH)) + .thenReturn(Optional.of(jsonMonth(PREVIOUS_MONTH, Duration.ofMinutes(5)))); + + assertThat(storage.loadPreviousMonthOvertime(YEAR_MONTH)).hasMinutes(5); + } + + @Test + void loadPreviousMonthOvertime_truncatesToMinute() + { + when(fileStorageMock.loadMonthRecord(PREVIOUS_MONTH)) + .thenReturn(Optional.of(jsonMonth(PREVIOUS_MONTH, Duration.ofMinutes(5).plusSeconds(50)))); + + assertThat(storage.loadPreviousMonthOvertime(YEAR_MONTH)).hasMinutes(5); + } + + @Test + void loadOrCreate_createsNewMonthIfNotExists() + { + when(fileStorageMock.loadMonthRecord(YEAR_MONTH)).thenReturn(Optional.empty()); + + final MonthIndex newMonth = storage.loadOrCreate(YEAR_MONTH); + + assertThat(newMonth.getYearMonth()).isEqualTo(YEAR_MONTH); + assertThat(newMonth.getSortedDays()).hasSize(30); + assertThat(newMonth.getTotalOvertime()).isZero(); + assertThat(newMonth.getVacationDayCount()).isZero(); + } + + @Test + void loadOrCreate_loadsExistingMonth() + { + final JsonMonth month = jsonMonth(YEAR_MONTH, Duration.ofMinutes(4)); + when(fileStorageMock.loadMonthRecord(YEAR_MONTH)).thenReturn(Optional.of(month)); + + final MonthIndex newMonth = storage.loadOrCreate(YEAR_MONTH); + + assertThat(newMonth.getYearMonth()).isEqualTo(YEAR_MONTH); + assertThat(newMonth.getSortedDays()).hasSize(30); + assertThat(newMonth.getTotalOvertime()).hasMinutes(4); + assertThat(newMonth.getVacationDayCount()).isZero(); + assertThat(newMonth.getOvertimePreviousMonth()).hasMinutes(4); + } + + @Test + void storeMonth() + { + final JsonMonth month = jsonMonth(YEAR_MONTH, Duration.ofMinutes(4)); + when(monthIndexMock.getMonthRecord()).thenReturn(month); + when(monthIndexMock.getYearMonth()).thenReturn(YEAR_MONTH); + + storage.storeMonth(monthIndexMock); + + verify(fileStorageMock).writeToFile(eq(YEAR_MONTH), same(month)); + } + + @Test + void loadAll_empty() + { + when(fileStorageMock.loadAll()).thenReturn(emptyList()); + + final MultiMonthIndex index = storage.loadAll(); + + assertThat(index.getDays()).isEmpty(); + assertThat(index.getMonths()).isEmpty(); + } + + @Test + void loadAll_nonEmpty() + { + when(fileStorageMock.loadAll()).thenReturn(List.of(jsonMonth(YEAR_MONTH, Duration.ZERO))); + + final MultiMonthIndex index = storage.loadAll(); + + assertThat(index.getDays()).hasSize(30); + assertThat(index.getMonths()).hasSize(1); + } + + @Test + void getAvailableDataYearMonth() + { + final List availableYearMonths = List.of(YEAR_MONTH); + when(fileStorageMock.getAvailableDataYearMonth()).thenReturn(availableYearMonths); + + assertThat(storage.getAvailableDataYearMonth()).isSameAs(availableYearMonths); + } + + private JsonMonth jsonMonth(YearMonth yearMonth, Duration overtimePreviousMonth) + { + final JsonMonth month = new JsonMonth(); + month.setYear(yearMonth.getYear()); + month.setMonth(yearMonth.getMonth()); + month.setOvertimePreviousMonth(overtimePreviousMonth); + month.setDays(new ArrayList<>()); + return month; + } +}