From 207346802c4b3f072643b1dd26012a50534ece5a Mon Sep 17 00:00:00 2001 From: kaklakariada Date: Sat, 28 Nov 2020 17:46:27 +0100 Subject: [PATCH] #16 Autocomplete for projects --- .../jfxui/testutil/model/JavaFxTable.java | 20 +- .../autocomplete/AutocompleteService.java | 42 ++++- .../autocomplete/AutocompleteServiceTest.java | 174 ++++++++++++++++++ 3 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 logic/src/test/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteServiceTest.java 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 1716dbc3..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,11 +1,9 @@ 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; @@ -14,10 +12,11 @@ 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 { @@ -72,7 +71,6 @@ public IndexedCell getTableRow(final int rowIndex) .map(VirtualFlow.class::cast) .findFirst().orElseThrow(); assertThat(virtualFlow.getCellCount()).as("row count of " + virtualFlow).isGreaterThan(rowIndex); - System.out.println("Found flow " + virtualFlow + " with cells# " + virtualFlow.getCellCount()); final IndexedCell row = JavaFxUtil.runOnFxApplicationThread(() -> virtualFlow.getCell(rowIndex)); LOG.debug("Got row #{} of {}: {}", rowIndex, virtualFlow, row); return row; 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 index bd00e215..6694205a 100644 --- a/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteService.java +++ b/logic/src/main/java/org/itsallcode/whiterabbit/logic/autocomplete/AutocompleteService.java @@ -12,9 +12,11 @@ import java.time.LocalDate; import java.time.Period; import java.util.*; +import java.util.stream.Stream; -import static java.util.stream.Collectors.groupingBy; -import static java.util.stream.Collectors.toList; +import static java.util.Collections.emptyList; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.*; public class AutocompleteService { @@ -52,10 +54,7 @@ private List getDayComments() private List getActivityComments() { - return getLatestDays().stream() - .map(DayRecord::activities) - .map(DayActivities::getAll) - .flatMap(List::stream) + return getActivities() .map(Activity::getComment) .filter(Objects::nonNull) .filter(comment -> !comment.isBlank()) @@ -63,18 +62,30 @@ private List getActivityComments() .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); } - private AutocompleteEntrySupplier autocompleter(Collection allEntries) + 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()); @@ -83,6 +94,21 @@ private AutocompleteEntrySupplier autocompleter(Collection allEntries) public Optional getSuggestedProject() { - return Optional.empty(); + 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/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