diff --git a/Backend.Tests/Controllers/MergeControllerTests.cs b/Backend.Tests/Controllers/MergeControllerTests.cs index c32ef329b3..fb4658c3da 100644 --- a/Backend.Tests/Controllers/MergeControllerTests.cs +++ b/Backend.Tests/Controllers/MergeControllerTests.cs @@ -148,5 +148,21 @@ public void TestGetGraylistEntriesNoPermission() var result = _mergeController.GetGraylistEntries("projId", 3, "userId").Result; Assert.That(result, Is.InstanceOf()); } + + [Test] + public void TestFindIdenticalPotentialDuplicatesNoPermission() + { + _mergeController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _mergeController.FindIdenticalPotentialDuplicates("projId", 2, 1, false).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestFindIdenticalPotentialDuplicates() + { + // This test verifies the endpoint returns OK and data + var result = _mergeController.FindIdenticalPotentialDuplicates(ProjId, 5, 10, false).Result; + Assert.That(result, Is.InstanceOf()); + } } } diff --git a/Backend/Controllers/MergeController.cs b/Backend/Controllers/MergeController.cs index a6ffbdf0d5..8b1fdff517 100644 --- a/Backend/Controllers/MergeController.cs +++ b/Backend/Controllers/MergeController.cs @@ -121,6 +121,35 @@ public async Task GraylistAdd(string projectId, [FromBody, BindRe return Ok(graylistEntry.WordIds); } + /// Find and return lists of potential duplicates with identical vernacular. + /// Id of project in which to search the frontier for potential duplicates. + /// Max number of words allowed within a list of potential duplicates. + /// Max number of lists of potential duplicates. + /// Whether to require each set to have at least one unprotected word. + /// List of Lists of s, each sublist a set of potential duplicates. + [HttpGet("findidenticaldups/{maxInList:int}/{maxLists:int}/{ignoreProtected:bool}", Name = "FindIdenticalPotentialDuplicates")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List>))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task FindIdenticalPotentialDuplicates( + string projectId, int maxInList, int maxLists, bool ignoreProtected) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "finding identical potential duplicates"); + + if (!await _permissionService.HasProjectPermission( + HttpContext, Permission.MergeAndReviewEntries, projectId)) + { + return Forbid(); + } + + await _mergeService.UpdateMergeBlacklist(projectId); + + var userId = _permissionService.GetUserId(HttpContext); + var dups = await _mergeService.GetPotentialDuplicates( + projectId, maxInList, maxLists, identicalVernacular: true, userId, ignoreProtected); + + return Ok(dups); + } + /// Start finding lists of potential duplicates for merging. /// Id of project in which to search the frontier for potential duplicates. /// Max number of words allowed within a list of potential duplicates. diff --git a/Backend/Interfaces/IMergeService.cs b/Backend/Interfaces/IMergeService.cs index 7bcda787dc..352ba3d504 100644 --- a/Backend/Interfaces/IMergeService.cs +++ b/Backend/Interfaces/IMergeService.cs @@ -17,6 +17,9 @@ public interface IMergeService Task UpdateMergeGraylist(string projectId); Task GetAndStorePotentialDuplicates( string projectId, int maxInList, int maxLists, string userId, bool ignoreProtected = false); + Task>> GetPotentialDuplicates( + string projectId, int maxInList, int maxLists, bool identicalVernacular, + string? userId = null, bool ignoreProtected = false); List>? RetrieveDups(string userId); Task HasGraylistEntries(string projectId, string? userId = null); Task>> GetGraylistEntries(string projectId, int maxLists, string? userId = null); diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index 039965eaca..12018cb591 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -413,7 +413,7 @@ public async Task GetAndStorePotentialDuplicates( { return false; } - var dups = await GetPotentialDuplicates(projectId, maxInList, maxLists, userId, ignoreProtected); + var dups = await GetPotentialDuplicates(projectId, maxInList, maxLists, false, userId, ignoreProtected); // Store the potential duplicates for user to retrieve later. return StoreDups(userId, counter, dups) == counter; } @@ -421,8 +421,8 @@ public async Task GetAndStorePotentialDuplicates( /// /// Get Lists of potential duplicate s in specified 's frontier. /// - private async Task>> GetPotentialDuplicates( - string projectId, int maxInList, int maxLists, string? userId = null, bool ignoreProtected = false) + public async Task>> GetPotentialDuplicates(string projectId, int maxInList, int maxLists, + bool identicalVernacular, string? userId = null, bool ignoreProtected = false) { var dupFinder = new DuplicateFinder(maxInList, maxLists, 2); @@ -431,17 +431,9 @@ async Task isUnavailableSet(List wordIds) => (await IsInMergeBlacklist(projectId, wordIds, userId)) || (await IsInMergeGraylist(projectId, wordIds, userId)); - // First pass, only look for words with identical vernacular. - var wordLists = await dupFinder.GetIdenticalVernWords(collection, isUnavailableSet, ignoreProtected); - - // If no such sets found, look for similar words. - if (wordLists.Count == 0) - { - collection = await _wordRepo.GetFrontier(projectId); - wordLists = await dupFinder.GetSimilarWords(collection, isUnavailableSet, ignoreProtected); - } - - return wordLists; + return identicalVernacular + ? await dupFinder.GetIdenticalVernWords(collection, isUnavailableSet, ignoreProtected) + : await dupFinder.GetSimilarWords(collection, isUnavailableSet, ignoreProtected); } public sealed class InvalidMergeWordSetException : Exception diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 29c3d3b82f..c6714e2abc 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -516,6 +516,15 @@ "yes": "Yes", "no": "No" }, + "identicalCompleted": { + "title": "All Identical Duplicates Processed", + "congratulations": "Congratulations! You have processed all sets of words with identical vernacular forms.", + "hasDeferred": "You have deferred duplicate sets that can be reviewed later.", + "findingSimilar": "The Combine will now search for potential duplicates with similar (non-identical) vernacular forms.", + "warning": "Finding similar duplicates may take several minutes.", + "reviewDeferred": "Review Deferred", + "continue": "Continue" + }, "undo": { "undo": "Undo Merge", "undoDialog": "Undo this merge?", diff --git a/src/api/api/merge-api.ts b/src/api/api/merge-api.ts index 2b098697c1..d844eecefc 100644 --- a/src/api/api/merge-api.ts +++ b/src/api/api/merge-api.ts @@ -107,6 +107,84 @@ export const MergeApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * + * @param {string} projectId + * @param {number} maxInList + * @param {number} maxLists + * @param {boolean} ignoreProtected + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + findIdenticalPotentialDuplicates: async ( + projectId: string, + maxInList: number, + maxLists: number, + ignoreProtected: boolean, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists( + "findIdenticalPotentialDuplicates", + "projectId", + projectId + ); + // verify required parameter 'maxInList' is not null or undefined + assertParamExists( + "findIdenticalPotentialDuplicates", + "maxInList", + maxInList + ); + // verify required parameter 'maxLists' is not null or undefined + assertParamExists( + "findIdenticalPotentialDuplicates", + "maxLists", + maxLists + ); + // verify required parameter 'ignoreProtected' is not null or undefined + assertParamExists( + "findIdenticalPotentialDuplicates", + "ignoreProtected", + ignoreProtected + ); + const localVarPath = + `/v1/projects/{projectId}/merge/findidenticaldups/{maxInList}/{maxLists}/{ignoreProtected}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"maxInList"}}`, encodeURIComponent(String(maxInList))) + .replace(`{${"maxLists"}}`, encodeURIComponent(String(maxLists))) + .replace( + `{${"ignoreProtected"}}`, + encodeURIComponent(String(ignoreProtected)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} projectId @@ -526,6 +604,42 @@ export const MergeApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} projectId + * @param {number} maxInList + * @param {number} maxLists + * @param {boolean} ignoreProtected + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async findIdenticalPotentialDuplicates( + projectId: string, + maxInList: number, + maxLists: number, + ignoreProtected: boolean, + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise>> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.findIdenticalPotentialDuplicates( + projectId, + maxInList, + maxLists, + ignoreProtected, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} projectId @@ -754,6 +868,32 @@ export const MergeApiFactory = function ( .blacklistAdd(projectId, requestBody, options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} projectId + * @param {number} maxInList + * @param {number} maxLists + * @param {boolean} ignoreProtected + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + findIdenticalPotentialDuplicates( + projectId: string, + maxInList: number, + maxLists: number, + ignoreProtected: boolean, + options?: any + ): AxiosPromise>> { + return localVarFp + .findIdenticalPotentialDuplicates( + projectId, + maxInList, + maxLists, + ignoreProtected, + options + ) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} projectId @@ -900,6 +1040,41 @@ export interface MergeApiBlacklistAddRequest { readonly requestBody: Array; } +/** + * Request parameters for findIdenticalPotentialDuplicates operation in MergeApi. + * @export + * @interface MergeApiFindIdenticalPotentialDuplicatesRequest + */ +export interface MergeApiFindIdenticalPotentialDuplicatesRequest { + /** + * + * @type {string} + * @memberof MergeApiFindIdenticalPotentialDuplicates + */ + readonly projectId: string; + + /** + * + * @type {number} + * @memberof MergeApiFindIdenticalPotentialDuplicates + */ + readonly maxInList: number; + + /** + * + * @type {number} + * @memberof MergeApiFindIdenticalPotentialDuplicates + */ + readonly maxLists: number; + + /** + * + * @type {boolean} + * @memberof MergeApiFindIdenticalPotentialDuplicates + */ + readonly ignoreProtected: boolean; +} + /** * Request parameters for findPotentialDuplicates operation in MergeApi. * @export @@ -1088,6 +1263,28 @@ export class MergeApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {MergeApiFindIdenticalPotentialDuplicatesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof MergeApi + */ + public findIdenticalPotentialDuplicates( + requestParameters: MergeApiFindIdenticalPotentialDuplicatesRequest, + options?: any + ) { + return MergeApiFp(this.configuration) + .findIdenticalPotentialDuplicates( + requestParameters.projectId, + requestParameters.maxInList, + requestParameters.maxLists, + requestParameters.ignoreProtected, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {MergeApiFindPotentialDuplicatesRequest} requestParameters Request parameters. diff --git a/src/backend/index.ts b/src/backend/index.ts index 20a226062b..47ae48e377 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -362,6 +362,20 @@ export async function graylistAdd(wordIds: string[]): Promise { ); } +/** Find and return lists of potential duplicates with identical vernacular. */ +export async function findIdenticalDuplicates( + maxInList: number, + maxLists: number, + ignoreProtected = false +): Promise { + const projectId = LocalStorage.getProjectId(); + const resp = await mergeApi.findIdenticalPotentialDuplicates( + { ignoreProtected, maxInList, maxLists, projectId }, + defaultOptions() + ); + return resp.data; +} + /** Start finding list of potential duplicates for merging. */ export async function findDuplicates( maxInList: number, diff --git a/src/goals/DefaultGoal/BaseGoalScreen.tsx b/src/goals/DefaultGoal/BaseGoalScreen.tsx index 855fd6a78b..66196b24f7 100644 --- a/src/goals/DefaultGoal/BaseGoalScreen.tsx +++ b/src/goals/DefaultGoal/BaseGoalScreen.tsx @@ -1,12 +1,14 @@ import loadable from "@loadable/component"; -import { type ReactElement, useEffect } from "react"; +import { type ReactElement, useEffect, useState } from "react"; import { useNavigate } from "react-router"; import PageNotFound from "components/PageNotFound/component"; import DisplayProgress from "goals/DefaultGoal/DisplayProgress"; import Loading from "goals/DefaultGoal/Loading"; +import IdenticalDuplicatesDialog from "goals/MergeDuplicates/IdenticalDuplicatesDialog"; import { clearTree } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { setCurrentGoal } from "goals/Redux/GoalActions"; +import { DataLoadStatus } from "goals/Redux/GoalReduxTypes"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; import { Goal, GoalStatus, GoalType } from "types/goals"; @@ -39,8 +41,13 @@ export default function LoadingGoalScreen(): ReactElement { const { goalType, status } = useAppSelector( (state: StoreState) => state.goalsState.currentGoal ); + const { dataLoadStatus } = useAppSelector( + (state: StoreState) => state.goalsState + ); const navigate = useNavigate(); + const [openDupsDialog, setOpenDupsDialog] = useState(false); + useEffect(() => { // Prevent getting stuck on loading screen when user clicks the back button. if (goalType === GoalType.Default) { @@ -48,7 +55,26 @@ export default function LoadingGoalScreen(): ReactElement { } }, [goalType, navigate]); - return status === GoalStatus.Loading ? : ; + useEffect(() => { + if (goalType === GoalType.MergeDups && status !== GoalStatus.Completed) { + setOpenDupsDialog( + (prev) => prev || dataLoadStatus === DataLoadStatus.Loading + ); + } else { + setOpenDupsDialog(false); + } + }, [dataLoadStatus, goalType, status]); + + return ( + <> + {status === GoalStatus.Loading ? : } + {openDupsDialog && ( + + )} + + ); } /** diff --git a/src/goals/MergeDuplicates/IdenticalDuplicatesDialog.tsx b/src/goals/MergeDuplicates/IdenticalDuplicatesDialog.tsx new file mode 100644 index 0000000000..0b28b7ca00 --- /dev/null +++ b/src/goals/MergeDuplicates/IdenticalDuplicatesDialog.tsx @@ -0,0 +1,98 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from "@mui/material"; +import { ReactElement, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { getFrontierWords, hasGraylistEntries } from "backend"; +import { ReviewDeferredDups } from "goals/MergeDuplicates/MergeDupsTypes"; +import { asyncAddGoal, setDataLoadStatus } from "goals/Redux/GoalActions"; +import { DataLoadStatus } from "goals/Redux/GoalReduxTypes"; +import { useAppDispatch } from "rootRedux/hooks"; +import router from "router/browserRouter"; +import { Path } from "types/path"; + +// Threshold for warning about long processing time +const LARGE_PROJECT_THRESHOLD = 1000; + +export default function IdenticalDuplicatesDialog(props: { + loading?: boolean; +}): ReactElement { + const dispatch = useAppDispatch(); + + const [open, setOpen] = useState(true); + const [hasDeferred, setHasDeferred] = useState(false); + const [frontierCount, setFrontierCount] = useState(0); + + const { t } = useTranslation(); + + const handleCancel = (): void => { + dispatch(setDataLoadStatus(DataLoadStatus.Default)); + setOpen(false); + router.navigate(Path.Goals); + }; + + const handleReviewDeferred = (): void => { + dispatch(setDataLoadStatus(DataLoadStatus.Default)); + dispatch(asyncAddGoal(new ReviewDeferredDups())); + setOpen(false); + }; + + useEffect(() => { + hasGraylistEntries().then(setHasDeferred); + getFrontierWords().then((words) => { + setFrontierCount(words.length); + }); + }, []); + + return ( + + {t("mergeDups.identicalCompleted.title")} + + + + {t("mergeDups.identicalCompleted.congratulations")} + + + {hasDeferred && ( + + {t("mergeDups.identicalCompleted.hasDeferred")} + + )} + +
+ + {t("mergeDups.identicalCompleted.findingSimilar")} + + {frontierCount > LARGE_PROJECT_THRESHOLD && props.loading && ( + + {t("mergeDups.identicalCompleted.warning")} + + )} +
+
+
+ + {props.loading && ( + + )} + {hasDeferred && ( + + )} + + +
+ ); +} diff --git a/src/goals/MergeDuplicates/MergeHub.tsx b/src/goals/MergeDuplicates/MergeHub.tsx index 0f24355fc9..f1bc6550e2 100644 --- a/src/goals/MergeDuplicates/MergeHub.tsx +++ b/src/goals/MergeDuplicates/MergeHub.tsx @@ -18,9 +18,8 @@ export default function MergeHub(): ReactElement { /** Update the Redux state and trigger continuation of goal loading. */ const successAction = async (dispatch: StoreStateDispatch): Promise => { dispatch(setDataLoadStatus(DataLoadStatus.Success)); - await dispatch(asyncLoadNewGoalData()).then(() => - setDataLoadStatus(DataLoadStatus.Default) - ); + await dispatch(asyncLoadNewGoalData()); + dispatch(setDataLoadStatus(DataLoadStatus.Default)); }; return ( diff --git a/src/goals/Redux/GoalActions.ts b/src/goals/Redux/GoalActions.ts index 152fce6c8e..f54fd4a96a 100644 --- a/src/goals/Redux/GoalActions.ts +++ b/src/goals/Redux/GoalActions.ts @@ -1,6 +1,6 @@ import { Action, PayloadAction } from "@reduxjs/toolkit"; -import { MergeUndoIds, OffOnSetting, Word } from "api/models"; +import { MergeUndoIds, OffOnSetting, Project, Word } from "api/models"; import * as Backend from "backend"; import { getCurrentUser, getProjectId } from "backend/localStorage"; import { CharInvChanges } from "goals/CharacterInventory/CharacterInventoryTypes"; @@ -73,7 +73,7 @@ export function updateStepFromData(): Action { // Dispatch Functions export function asyncAddGoal(goal: Goal) { - return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { + return async (dispatch: StoreStateDispatch) => { const userEditId = getUserEditId(); if (userEditId) { dispatch(setCurrentGoal(goal)); @@ -83,21 +83,8 @@ export function asyncAddGoal(goal: Goal) { await Backend.addGoalToUserEdit(userEditId, goal); dispatch(setCurrentGoal(goal)); - // Start loading goal data. - if (goal.goalType === GoalType.MergeDups) { - // Initialize data loading in the backend. - dispatch(setDataLoadStatus(DataLoadStatus.Loading)); - const currentProj = getState().currentProjectState.project; - await Backend.findDuplicates( - 5, // More than 5 entries doesn't fit well. - maxNumSteps(goal.goalType), - currentProj.protectedDataMergeAvoidEnabled === OffOnSetting.On - ); - // Don't load goal data, since it'll be triggered by a signal from the backend when data is ready. - } else { - // Load the goal data, but don't await, to allow a loading screen. - dispatch(asyncLoadNewGoalData()); - } + // Load the goal data, but don't await, to allow a loading screen. + dispatch(asyncLoadNewGoalData()); } // Serve goal. @@ -159,8 +146,13 @@ export function asyncLoadExistingUserEdits( export function asyncLoadNewGoalData() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const currentGoal = getState().goalsState.currentGoal; - const goalData = await loadGoalData(currentGoal.goalType).catch(() => { + const { goalsState, currentProjectState } = getState(); + const { currentGoal, dataLoadStatus } = goalsState; + const goalData = await loadGoalData( + currentGoal.goalType, + dataLoadStatus, + currentProjectState.project + ).catch(() => { dispatch(setDataLoadStatus(DataLoadStatus.Failure)); alert("Failed to load data."); router.navigate(Path.Goals); @@ -175,6 +167,20 @@ export function asyncLoadNewGoalData() { dispatch(dispatchStepData(updatedGoal)); await Backend.addGoalToUserEdit(getUserEditId()!, updatedGoal); await saveCurrentStep(updatedGoal); + } else if ( + currentGoal.goalType === GoalType.MergeDups && + dataLoadStatus !== DataLoadStatus.Success + ) { + // All identical-vernacular duplicates have been processed. + // Initialize similar-vernacular duplicate finding in the backend. + dispatch(setDataLoadStatus(DataLoadStatus.Loading)); + const currentProj = getState().currentProjectState.project; + await Backend.findDuplicates( + 5, // More than 5 entries doesn't fit well. + maxNumSteps(currentGoal.goalType), + currentProj.protectedDataMergeAvoidEnabled === OffOnSetting.On + ); + return; } dispatch(setGoalStatus(GoalStatus.InProgress)); }; @@ -242,11 +248,22 @@ function goalCleanup(goal: Goal): void { } /** Returns goal data for some goal types. */ -async function loadGoalData(goalType: GoalType): Promise { +async function loadGoalData( + goalType: GoalType, + dataLoadStatus: DataLoadStatus, + project: Project +): Promise { switch (goalType) { case GoalType.MergeDups: // Catch failure and pass to caller to allow for error dispatch. - const dups = await Backend.retrieveDuplicates().catch(() => {}); + const dups = + dataLoadStatus === DataLoadStatus.Success + ? await Backend.retrieveDuplicates().catch(() => {}) + : await Backend.findIdenticalDuplicates( + 5, // More than 5 entries doesn't fit well. + maxNumSteps(goalType), + project.protectedDataMergeAvoidEnabled === OffOnSetting.On + ).catch(() => {}); return dups ? checkMergeData(dups) : Promise.reject(); case GoalType.ReviewDeferredDups: return checkMergeData( diff --git a/src/goals/Redux/tests/GoalRedux.test.ts b/src/goals/Redux/tests/GoalRedux.test.ts index fb46cd9534..6b46fdedf9 100644 --- a/src/goals/Redux/tests/GoalRedux.test.ts +++ b/src/goals/Redux/tests/GoalRedux.test.ts @@ -32,11 +32,14 @@ import { Path } from "types/path"; import { newUser } from "types/user"; import * as goalUtilities from "utilities/goalUtilities"; +global.alert = jest.fn(); + jest.mock("backend", () => ({ addGoalToUserEdit: (...args: any[]) => mockAddGoalToUserEdit(...args), addStepToGoal: jest.fn(), createUserEdit: () => mockCreateUserEdit(), findDuplicates: jest.fn(), + findIdenticalDuplicates: () => mockFindIdenticalDuplicates(), getGraylistEntries: () => Promise.resolve([]), getUserEditById: (...args: any[]) => mockGetUserEditById(...args), hasGraylistEntries: jest.fn(), @@ -48,12 +51,15 @@ jest.mock("router/browserRouter", () => ({ const mockAddGoalToUserEdit = jest.fn(); const mockCreateUserEdit = jest.fn(); +const mockFindIdenticalDuplicates = jest.fn(); const mockGetUserEditById = jest.fn(); const mockNavigate = jest.fn(); const mockRetrieveDuplicates = jest.fn(); + function setMockFunctions(): void { mockAddGoalToUserEdit.mockResolvedValue(0); mockCreateUserEdit.mockResolvedValue(mockUser()); + mockFindIdenticalDuplicates.mockResolvedValue([]); mockGetUserEditById.mockResolvedValue(mockUserEdit(true)); mockRetrieveDuplicates.mockResolvedValue(goalDataMock.plannedWords); } @@ -150,12 +156,29 @@ describe("asyncGetUserEdits", () => { }); describe("asyncAddGoal", () => { - it("adds new MergeDups goal", async () => { + it("adds new MergeDups goal with identical-vernacular duplicates", async () => { + mockFindIdenticalDuplicates.mockResolvedValue(goalDataMock.plannedWords); + const store = setupStore(); + await act(async () => { + await store.dispatch(asyncAddGoal(new MergeDups())); + }); + // verify the new goal was loaded with data + const currentGoal = store.getState().goalsState.currentGoal as MergeDups; + expect(currentGoal.goalType).toEqual(GoalType.MergeDups); + expect(currentGoal.status).toEqual(GoalStatus.InProgress); + expect((currentGoal.data as MergeDupsData).plannedWords).toEqual( + goalDataMock.plannedWords + ); + expect(mockNavigate).toHaveBeenCalledWith(Path.GoalCurrent); + }); + + it("adds new MergeDups goal without identical-vernacular duplicates", async () => { + mockFindIdenticalDuplicates.mockResolvedValue([]); const store = setupStore(); await act(async () => { await store.dispatch(asyncAddGoal(new MergeDups())); }); - // verify the new goal was loaded but its data was not loaded + // verify the new goal was loaded without data const currentGoal = store.getState().goalsState.currentGoal as MergeDups; expect(currentGoal.goalType).toEqual(GoalType.MergeDups); expect(currentGoal.status).toEqual(GoalStatus.Loading); @@ -184,6 +207,7 @@ describe("asyncAddGoal", () => { describe("asyncLoadNewGoalData", () => { it("loads data for MergeDups goal", async () => { + mockFindIdenticalDuplicates.mockResolvedValue(goalDataMock.plannedWords); const store = setupStore(); await act(async () => { await store.dispatch(asyncAddGoal(new MergeDups())); @@ -193,7 +217,7 @@ describe("asyncLoadNewGoalData", () => { const currentGoal = store.getState().goalsState.currentGoal as MergeDups; expect(currentGoal.goalType).toEqual(GoalType.MergeDups); expect(currentGoal.status).toEqual(GoalStatus.InProgress); - expect(currentGoal.numSteps).toEqual(8); + expect(currentGoal.numSteps).toEqual(goalDataMock.plannedWords.length); expect(currentGoal.currentStep).toEqual(0); expect(currentGoal.data as MergeDupsData).toEqual(goalDataMock); }); @@ -202,6 +226,7 @@ describe("asyncLoadNewGoalData", () => { describe("asyncAdvanceStep", () => { it("advance MergeDups goal", async () => { // setup the test scenario + mockFindIdenticalDuplicates.mockResolvedValue(goalDataMock.plannedWords); const store = setupStore(); // create mergeDups goal await act(async () => { @@ -210,7 +235,7 @@ describe("asyncAdvanceStep", () => { }); let currentGoal = store.getState().goalsState.currentGoal as MergeDups; expect(currentGoal.currentStep).toBe(0); - expect(currentGoal.numSteps).toEqual(8); + expect(currentGoal.numSteps).toEqual(goalDataMock.plannedWords.length); // iterate over all but the last step const numSteps = currentGoal.numSteps; for (let i = 0; i < numSteps - 1; i++) {