From 81551c5636e9d10bb62b83b5da3ea103610dac47 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 30 Oct 2023 14:19:42 -0400 Subject: [PATCH 1/7] Begin --- .../Redux/CharacterInventoryReducer.ts | 45 ++++++++++++++++--- .../Redux/CharacterInventoryReduxTypes.ts | 8 ++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts index afba0b6f91..c933dcd042 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts @@ -1,19 +1,50 @@ +import { createSlice } from "@reduxjs/toolkit"; + import { CharacterInventoryAction, CharacterInventoryType, CharacterInventoryState, CharacterSetEntry, getCharacterStatus, + defaultState, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { StoreAction, StoreActionTypes } from "rootActions"; -export const defaultState: CharacterInventoryState = { - validCharacters: [], - rejectedCharacters: [], - allWords: [], - selectedCharacter: "", - characterSet: [], -}; +const exportProjectSlice = createSlice({ + name: "exportProjectState", + initialState: defaultState, + reducers: { + downloadingAction: (state, action) => { + state.projectId = action.payload; + state.status = ExportStatus.Downloading; + }, + exportingAction: (state, action) => { + state.projectId = action.payload; + state.status = ExportStatus.Exporting; + }, + failureAction: (state, action) => { + state.projectId = action.payload; + state.status = ExportStatus.Failure; + }, + resetAction: () => defaultState, + successAction: (state, action) => { + state.projectId = action.payload; + state.status = ExportStatus.Success; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +export const { + downloadingAction, + exportingAction, + failureAction, + resetAction, + successAction, +} = exportProjectSlice.actions; + +export default exportProjectSlice.reducer; export const characterInventoryReducer = ( state: CharacterInventoryState = defaultState, diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts index f8850c9256..e59fcad4b7 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts @@ -40,6 +40,14 @@ export interface CharacterInventoryState { characterSet: CharacterSetEntry[]; } +export const defaultState: CharacterInventoryState = { + validCharacters: [], + rejectedCharacters: [], + allWords: [], + selectedCharacter: "", + characterSet: [], +}; + /** A character with its occurrences and status, * for sorting and filtering in a list */ export interface CharacterSetEntry { From 2b5c3a71aec65cf620e173feba51f0138f90fabe Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 30 Oct 2023 17:18:52 -0400 Subject: [PATCH 2/7] Port CharInv goal to redux-toolkit --- src/components/App/DefaultState.ts | 8 +- .../CharacterDetail/tests/index.test.tsx | 2 +- .../CharacterList/tests/index.test.tsx | 14 +- .../CharacterInventory/CharInv/index.tsx | 4 +- .../CharInv/tests/index.test.tsx | 4 +- .../Redux/CharacterInventoryActions.ts | 131 +++----- .../Redux/CharacterInventoryReducer.ts | 205 +++++-------- .../Redux/CharacterInventoryReduxTypes.ts | 22 +- .../tests/CharacterInventoryActions.test.tsx | 289 ++++++++++++------ .../tests/CharacterInventoryReducer.test.tsx | 63 ---- src/rootReducer.ts | 8 +- 11 files changed, 348 insertions(+), 402 deletions(-) delete mode 100644 src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx diff --git a/src/components/App/DefaultState.ts b/src/components/App/DefaultState.ts index 1294ddf45d..1277911ef3 100644 --- a/src/components/App/DefaultState.ts +++ b/src/components/App/DefaultState.ts @@ -4,20 +4,20 @@ import { defaultState as currentProjectState } from "components/Project/ProjectR import { defaultState as exportProjectState } from "components/ProjectExport/Redux/ExportProjectReduxTypes"; import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { defaultState as treeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; -import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { defaultState as mergeDuplicateGoal } from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import { defaultState as reviewEntriesState } from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesReduxTypes"; import { defaultState as analyticsState } from "types/Redux/analyticsReduxTypes"; export const defaultState = { - //login + //login and signup loginState: { ...loginState }, //project currentProjectState: { ...currentProjectState }, exportProjectState: { ...exportProjectState }, - //data entry and review entries + //data entry and review entries goal treeViewState: { ...treeViewState }, reviewEntriesState: { ...reviewEntriesState }, pronunciationsState: { ...pronunciationsState }, @@ -25,7 +25,7 @@ export const defaultState = { //goal timeline and current goal goalsState: { ...goalTimelineState }, - //merge duplicates goal + //merge duplicates goal and review deferred duplicates goal mergeDuplicateGoal: { ...mergeDuplicateGoal }, //character inventory goal diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx index a49b42547e..12b4f41014 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx @@ -16,7 +16,7 @@ import { buttonIdSubmit, } from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace"; import CharacterReplaceDialog from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { StoreState } from "types"; // Dialog uses portals, which are not supported in react-test-renderer. diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx index 8f56dad1f5..e501972fbd 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx @@ -1,24 +1,26 @@ import { Provider } from "react-redux"; -import renderer from "react-test-renderer"; +import { ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import CharacterList from "goals/CharacterInventory/CharInv/CharacterList"; import CharacterCard from "goals/CharacterInventory/CharInv/CharacterList/CharacterCard"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; -import { newCharacterSetEntry } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; +import { + defaultState, + newCharacterSetEntry, +} from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; const characterSet = ["q", "w", "e", "r", "t", "y"].map(newCharacterSetEntry); const mockStore = configureMockStore()({ characterInventoryState: { ...defaultState, characterSet }, }); -let testRenderer: renderer.ReactTestRenderer; +let testRenderer: ReactTestRenderer; beforeEach(async () => { - await renderer.act(async () => { - testRenderer = renderer.create( + await act(async () => { + testRenderer = create( diff --git a/src/goals/CharacterInventory/CharInv/index.tsx b/src/goals/CharacterInventory/CharInv/index.tsx index 83376299a7..895ec10b0b 100644 --- a/src/goals/CharacterInventory/CharInv/index.tsx +++ b/src/goals/CharacterInventory/CharInv/index.tsx @@ -18,7 +18,7 @@ import CharacterSetHeader from "goals/CharacterInventory/CharInv/CharacterSetHea import { exit, loadCharInvData, - resetInState, + reset, setSelectedCharacter, uploadInventory, } from "goals/CharacterInventory/Redux/CharacterInventoryActions"; @@ -54,7 +54,7 @@ export default function CharacterInventory(): ReactElement { dispatch(loadCharInvData()); // Call when component unmounts. - () => dispatch(resetInState()); + () => dispatch(reset()); }, [dispatch]); const save = async (): Promise => { diff --git a/src/goals/CharacterInventory/CharInv/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/tests/index.test.tsx index 88a6106331..c8ed1d0c98 100644 --- a/src/goals/CharacterInventory/CharInv/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/tests/index.test.tsx @@ -11,7 +11,7 @@ import CharInv, { dialogButtonIdYes, dialogIdCancel, } from "goals/CharacterInventory/CharInv"; -import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; // Replace Dialog with something that doesn't create portals, // because react-test-renderer does not support portals. @@ -27,7 +27,7 @@ jest.mock("goals/CharacterInventory/CharInv/CharacterDetail", () => "div"); jest.mock("goals/CharacterInventory/Redux/CharacterInventoryActions", () => ({ exit: () => mockExit(), loadCharInvData: () => mockLoadCharInvData(), - resetInState: () => jest.fn(), + reset: () => jest.fn(), setSelectedCharacter: () => mockSetSelectedCharacter(), uploadInventory: () => mockUploadInventory(), })); diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts index fc75d252d6..6a4b5cd6cc 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts @@ -1,3 +1,5 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { Project } from "api/models"; import { getFrontierWords } from "backend"; import router from "browserRouter"; @@ -10,84 +12,59 @@ import { CharacterStatus, CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; +import { + addToRejectedCharactersAction, + addToValidCharactersAction, + resetAction, + setAllWordsAction, + setCharacterSetAction, + setRejectedCharactersAction, + setSelectedCharacterAction, + setValidCharactersAction, +} from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { CharacterInventoryState, CharacterSetEntry, - CharacterInventoryAction, - CharacterInventoryType, getCharacterStatus, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { StoreState } from "types"; import { StoreStateDispatch } from "types/Redux/actions"; import { Path } from "types/path"; -// Action Creators +// Action Creation Functions -export function addToValidCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.ADD_TO_VALID_CHARACTERS, - payload: chars, - }; +export function addToRejectedCharacters(char: string): PayloadAction { + return addToRejectedCharactersAction(char); } -export function addToRejectedCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.ADD_TO_REJECTED_CHARACTERS, - payload: chars, - }; +export function addToValidCharacters(char: string): PayloadAction { + return addToValidCharactersAction(char); } -export function setValidCharacters(chars: string[]): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: chars, - }; +export function reset(): Action { + return resetAction(); } -export function setRejectedCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_REJECTED_CHARACTERS, - payload: chars, - }; +export function setAllWords(words: string[]): PayloadAction { + return setAllWordsAction(words); } -export function setAllWords(words: string[]): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_ALL_WORDS, - payload: words, - }; +export function setCharacterSet( + characterSet: CharacterSetEntry[] +): PayloadAction { + return setCharacterSetAction(characterSet); } -export function setSelectedCharacter( - character: string -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_SELECTED_CHARACTER, - payload: [character], - }; +export function setRejectedCharacters(chars: string[]): PayloadAction { + return setRejectedCharactersAction(chars); } -export function setCharacterSet( - characterSet: CharacterSetEntry[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_CHARACTER_SET, - payload: [], - characterSet, - }; +export function setSelectedCharacter(character: string): PayloadAction { + return setSelectedCharacterAction(character); } -export function resetInState(): CharacterInventoryAction { - return { - type: CharacterInventoryType.RESET, - payload: [], - }; +export function setValidCharacters(chars: string[]): PayloadAction { + return setValidCharactersAction(chars); } // Dispatch Functions @@ -96,10 +73,10 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { return (dispatch: StoreStateDispatch, getState: () => StoreState) => { switch (status) { case CharacterStatus.Accepted: - dispatch(addToValidCharacters([character])); + dispatch(addToValidCharacters(character)); break; case CharacterStatus.Rejected: - dispatch(addToRejectedCharacters([character])); + dispatch(addToRejectedCharacters(character)); break; case CharacterStatus.Undecided: const state = getState().characterInventoryState; @@ -120,7 +97,7 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { }; } -// Sends the character inventory to the server. +/** Sends the in-state character inventory to the server. */ export function uploadInventory() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { const state = getState(); @@ -146,28 +123,17 @@ export function fetchWords() { export function getAllCharacters() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const state = getState(); - const words = await getFrontierWords(); - const charactersWithDuplicates: string[] = []; - words.forEach((word) => charactersWithDuplicates.push(...word.vernacular)); - const characters = [...new Set(charactersWithDuplicates)]; - - const characterSet: CharacterSetEntry[] = []; - characters.forEach((letter) => { - characterSet.push({ - character: letter, - occurrences: countCharacterOccurrences( - letter, - words.map((word) => word.vernacular) - ), - status: getCharacterStatus( - letter, - state.currentProjectState.project.validCharacters, - state.currentProjectState.project.rejectedCharacters - ), - }); - }); - dispatch(setCharacterSet(characterSet)); + const allWords = getState().characterInventoryState.allWords; + const characters = new Set(); + allWords.forEach((w) => [...w].forEach((c) => characters.add(c))); + const { rejectedCharacters, validCharacters } = + getState().currentProjectState.project; + const entries: CharacterSetEntry[] = [...characters].map((c) => ({ + character: c, + occurrences: countOccurrences(c, allWords), + status: getCharacterStatus(c, validCharacters, rejectedCharacters), + })); + dispatch(setCharacterSet(entries)); }; } @@ -188,7 +154,10 @@ export function exit(): void { router.navigate(Path.Goals); } -function countCharacterOccurrences(char: string, words: string[]): number { +function countOccurrences(char: string, words: string[]): number { + if (char.length !== 1) { + console.error(`countOccurrences expects length 1 char, but got: ${char}`); + } let count = 0; for (const word of words) { for (const letter of word) { @@ -263,7 +232,7 @@ function getChange( } function updateCurrentProject(state: StoreState): Project { - const project = state.currentProjectState.project; + const project = { ...state.currentProjectState.project }; project.validCharacters = state.characterInventoryState.validCharacters; project.rejectedCharacters = state.characterInventoryState.rejectedCharacters; return project; diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts index c933dcd042..b489c66a33 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts @@ -1,150 +1,111 @@ import { createSlice } from "@reduxjs/toolkit"; import { - CharacterInventoryAction, - CharacterInventoryType, - CharacterInventoryState, - CharacterSetEntry, getCharacterStatus, defaultState, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { StoreActionTypes } from "rootActions"; -const exportProjectSlice = createSlice({ - name: "exportProjectState", +const characterInventorySlice = createSlice({ + name: "characterInventoryState", initialState: defaultState, reducers: { - downloadingAction: (state, action) => { - state.projectId = action.payload; - state.status = ExportStatus.Downloading; - }, - exportingAction: (state, action) => { - state.projectId = action.payload; - state.status = ExportStatus.Exporting; - }, - failureAction: (state, action) => { - state.projectId = action.payload; - state.status = ExportStatus.Failure; - }, - resetAction: () => defaultState, - successAction: (state, action) => { - state.projectId = action.payload; - state.status = ExportStatus.Success; - }, - }, - extraReducers: (builder) => - builder.addCase(StoreActionTypes.RESET, () => defaultState), -}); - -export const { - downloadingAction, - exportingAction, - failureAction, - resetAction, - successAction, -} = exportProjectSlice.actions; - -export default exportProjectSlice.reducer; - -export const characterInventoryReducer = ( - state: CharacterInventoryState = defaultState, - action: StoreAction | CharacterInventoryAction -): CharacterInventoryState => { - let validCharacters: string[]; - let rejectedCharacters: string[]; - let characterSet: CharacterSetEntry[]; - switch (action.type) { - case CharacterInventoryType.SET_VALID_CHARACTERS: - // Set prevents duplicate characters - validCharacters = [...new Set(action.payload)]; - rejectedCharacters = state.rejectedCharacters.filter( - (char) => !validCharacters.includes(char) + addToRejectedCharactersAction: (state, action) => { + if (!state.rejectedCharacters.includes(action.payload)) { + state.rejectedCharacters.push(action.payload); + } + + const index = state.validCharacters.findIndex((c) => c == action.payload); + if (index !== -1) { + state.validCharacters.splice(index, 1); + } + + const entry = state.characterSet.find( + (e) => e.character === action.payload ); - - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + if (entry) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; + } + }, + addToValidCharactersAction: (state, action) => { + if (!state.validCharacters.includes(action.payload)) { + state.validCharacters.push(action.payload); + } - case CharacterInventoryType.SET_REJECTED_CHARACTERS: - rejectedCharacters = [...new Set(action.payload)]; - validCharacters = state.validCharacters.filter( - (char) => !rejectedCharacters.includes(char) + const index = state.rejectedCharacters.findIndex( + (c) => c == action.payload ); + if (index !== -1) { + state.rejectedCharacters.splice(index, 1); + } - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + const entry = state.characterSet.find( + (e) => e.character === action.payload + ); + if (entry) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.ADD_TO_VALID_CHARACTERS: - validCharacters = [ - ...new Set(state.validCharacters.concat(action.payload)), - ]; - rejectedCharacters = state.rejectedCharacters.filter( - (char) => !validCharacters.includes(char) + } + }, + resetAction: () => defaultState, + setAllWordsAction: (state, action) => { + state.allWords = action.payload; + }, + setCharacterSetAction: (state, action) => { + if (action.payload) { + state.characterSet = action.payload; + } + }, + setRejectedCharactersAction: (state, action) => { + state.rejectedCharacters = [...new Set(action.payload as string)]; + state.validCharacters = state.validCharacters.filter( + (char) => !state.rejectedCharacters.includes(char) ); - - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + for (const entry of state.characterSet) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.ADD_TO_REJECTED_CHARACTERS: - rejectedCharacters = [ - ...new Set(state.rejectedCharacters.concat(action.payload)), - ]; - validCharacters = state.validCharacters.filter( - (char) => !rejectedCharacters.includes(char) + } + }, + setSelectedCharacterAction: (state, action) => { + state.selectedCharacter = action.payload; + }, + setValidCharactersAction: (state, action) => { + state.validCharacters = [...new Set(action.payload as string)]; + state.rejectedCharacters = state.rejectedCharacters.filter( + (char) => !state.validCharacters.includes(char) ); - - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + for (const entry of state.characterSet) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.SET_SELECTED_CHARACTER: - return { ...state, selectedCharacter: action.payload[0] }; - - case CharacterInventoryType.SET_ALL_WORDS: - return { ...state, allWords: action.payload }; - - case CharacterInventoryType.SET_CHARACTER_SET: - return action.characterSet - ? { ...state, characterSet: action.characterSet } - : state; - - case CharacterInventoryType.RESET: - return defaultState; - - case StoreActionTypes.RESET: - return defaultState; + } + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); - default: - return state; - } -}; +export const { + addToRejectedCharactersAction, + addToValidCharactersAction, + resetAction, + setAllWordsAction, + setCharacterSetAction, + setRejectedCharactersAction, + setSelectedCharacterAction, + setValidCharactersAction, +} = characterInventorySlice.actions; + +export default characterInventorySlice.reducer; diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts index e59fcad4b7..d1b7cdbe5f 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts @@ -1,17 +1,6 @@ import { CharacterStatus } from "goals/CharacterInventory/CharacterInventoryTypes"; -export enum CharacterInventoryType { - SET_VALID_CHARACTERS = "SET_VALID_CHARACTERS", - SET_REJECTED_CHARACTERS = "SET_REJECTED_CHARACTERS", - ADD_TO_VALID_CHARACTERS = "ADD_TO_VALID_CHARACTERS", - ADD_TO_REJECTED_CHARACTERS = "ADD_TO_REJECTED_CHARACTERS", - SET_ALL_WORDS = "SET_ALL_WORDS", - SET_SELECTED_CHARACTER = "SET_SELECTED_CHARACTER", - SET_CHARACTER_SET = "SET_CHARACTER_SET", - RESET = "CHAR_INV_RESET", -} - -// Utility function for returning a CharacterStatus from arrays of character data +/** Utility function for returning a CharacterStatus from arrays of character data */ export function getCharacterStatus( char: string, validChars: string[], @@ -26,12 +15,6 @@ export function getCharacterStatus( return CharacterStatus.Undecided; } -export interface CharacterInventoryAction { - type: CharacterInventoryType; - payload: string[]; - characterSet?: CharacterSetEntry[]; -} - export interface CharacterInventoryState { validCharacters: string[]; rejectedCharacters: string[]; @@ -48,8 +31,7 @@ export const defaultState: CharacterInventoryState = { characterSet: [], }; -/** A character with its occurrences and status, - * for sorting and filtering in a list */ +/** A character with its occurrences and status, for sorting and filtering in a list */ export interface CharacterSetEntry { character: string; occurrences: number; diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx index eddf0a64f8..56e376bca2 100644 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx +++ b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx @@ -1,129 +1,224 @@ -import { Action } from "redux"; -import configureMockStore from "redux-mock-store"; -import thunk from "redux-thunk"; +import { Action, PreloadedState } from "redux"; import { Project } from "api/models"; -import { updateProject } from "backend"; -import { ProjectActionType } from "components/Project/ProjectReduxTypes"; +import { defaultState } from "components/App/DefaultState"; import { CharacterStatus, CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; -import * as Actions from "goals/CharacterInventory/Redux/CharacterInventoryActions"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { + fetchWords, + getAllCharacters, + getChanges, + loadCharInvData, + setCharacterStatus, + uploadInventory, +} from "goals/CharacterInventory/Redux/CharacterInventoryActions"; +import { + defaultState as defaultCharInvState, CharacterInventoryState, CharacterSetEntry, - CharacterInventoryType, - newCharacterSetEntry, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreState } from "types"; +import { RootState, setupStore } from "store"; import { newProject } from "types/project"; -import { newUser } from "types/user"; - -const VALID_DATA: string[] = ["a", "b"]; -const REJECT_DATA: string[] = ["y", "z"]; -const CHARACTER_SET_DATA: CharacterSetEntry[] = [ - { ...newCharacterSetEntry("a"), status: CharacterStatus.Accepted }, - { ...newCharacterSetEntry("b"), status: CharacterStatus.Accepted }, - { ...newCharacterSetEntry("y"), status: CharacterStatus.Rejected }, - { ...newCharacterSetEntry("z"), status: CharacterStatus.Rejected }, - { ...newCharacterSetEntry("m"), status: CharacterStatus.Undecided }, -]; - -const characterInventoryState: Partial = { - characterSet: CHARACTER_SET_DATA, - rejectedCharacters: REJECT_DATA, - validCharacters: VALID_DATA, -}; -const project: Partial = { - rejectedCharacters: [], - validCharacters: [], -}; -const MOCK_STATE = { - characterInventoryState, - currentProjectState: { project }, - goalsState: { currentGoal: { changes: {} } }, -}; +import { newWord } from "types/word"; -const mockProjectId = "123"; -const mockUserEditId = "456"; -const mockUserId = "789"; -const mockUser = newUser(); -mockUser.id = mockUserId; -mockUser.workedProjects[mockProjectId] = mockUserEditId; - -jest.mock("backend"); +jest.mock("backend", () => ({ + getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), +})); jest.mock("browserRouter"); jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ asyncUpdateGoal: (...args: any[]) => mockAsyncUpdateGoal(...args), - addCharInvChangesToGoal: (...args: any[]) => mockAddCharInvChanges(...args), + addCharInvChangesToGoal: (...args: any[]) => + mockAddCharInvChangesToGoal(...args), })); +jest.mock("components/Project/ProjectActions", () => ({ + asyncUpdateCurrentProject: (...args: any[]) => + mockAsyncUpdateCurrentProject(...args), +})); + +const mockAddCharInvChangesToGoal = jest.fn(); +const mockAsyncUpdateCurrentProject = jest.fn(); const mockAsyncUpdateGoal = jest.fn(); -const mockAddCharInvChanges = jest.fn(); +const mockGetFrontierWords = jest.fn(); -const createMockStore = configureMockStore([thunk]); +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; beforeEach(() => { jest.resetAllMocks(); }); describe("CharacterInventoryActions", () => { - test("setInventory yields correct action", () => { - expect(Actions.setValidCharacters(VALID_DATA)).toEqual({ - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: VALID_DATA, + describe("setCharacterStatus", () => { + const character = "C"; + const mockState = (status: CharacterStatus): PreloadedState => { + const entry: CharacterSetEntry = { character, occurrences: 0, status }; + const rej = status === CharacterStatus.Rejected ? [character] : []; + const val = status === CharacterStatus.Accepted ? [character] : []; + return { + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + characterSet: [entry], + rejectedCharacters: rej, + validCharacters: val, + }, + }; + }; + + it("changes character from Rejected to Accepted", () => { + const store = setupStore(mockState(CharacterStatus.Rejected)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Accepted)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Accepted); + expect(state.rejectedCharacters).toHaveLength(0); + expect(state.validCharacters).toHaveLength(1); + }); + + it("changes character from Accepted to Undecided", () => { + const store = setupStore(mockState(CharacterStatus.Accepted)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Undecided)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Undecided); + expect(state.rejectedCharacters).toHaveLength(0); + expect(state.validCharacters).toHaveLength(0); + }); + + it("changes character from Undecided to Rejected", () => { + const store = setupStore(mockState(CharacterStatus.Undecided)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Rejected)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Rejected); + expect(state.rejectedCharacters).toHaveLength(1); + expect(state.validCharacters).toHaveLength(0); }); }); - test("uploadInventory dispatches correct action", async () => { - // Mock out the goal-related things called by uploadInventory. - const mockAction: Action = { type: null }; - mockAsyncUpdateGoal.mockReturnValue(mockAction); - mockAddCharInvChanges.mockReturnValue(mockAction); - - const mockStore = createMockStore(MOCK_STATE); - const mockUpload = Actions.uploadInventory(); - await mockUpload( - mockStore.dispatch, - mockStore.getState as () => StoreState - ); - expect(updateProject).toHaveBeenCalledTimes(1); - expect(mockStore.getActions()).toContainEqual({ - type: ProjectActionType.SET_CURRENT_PROJECT, - payload: project, + describe("uploadInventory", () => { + it("dispatches no actions if there are no changes", async () => { + const store = setupStore(); + await store.dispatch(uploadInventory()); + expect(mockAddCharInvChangesToGoal).not.toHaveBeenCalled(); + expect(mockAsyncUpdateCurrentProject).not.toHaveBeenCalled(); + expect(mockAsyncUpdateGoal).not.toHaveBeenCalled(); + }); + + it("dispatches correct action if there are changes", async () => { + // Mock data with distinct characters + const rejectedCharacters = ["r", "e", "j"]; + const validCharacters = ["v", "a", "l", "i", "d"]; + const store = setupStore({ + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + rejectedCharacters, + validCharacters, + }, + }); + + // Mock the dispatch functions called by uploadInventory. + const mockAction: Action = { type: null }; + mockAddCharInvChangesToGoal.mockReturnValue(mockAction); + mockAsyncUpdateCurrentProject.mockReturnValue(mockAction); + mockAsyncUpdateGoal.mockReturnValue(mockAction); + + await store.dispatch(uploadInventory()); + expect(mockAddCharInvChangesToGoal).toHaveBeenCalledTimes(1); + expect(mockAddCharInvChangesToGoal.mock.calls[0][0]).toHaveLength( + rejectedCharacters.length + validCharacters.length + ); + expect(mockAsyncUpdateCurrentProject).toHaveBeenCalledTimes(1); + const proj: Project = mockAsyncUpdateCurrentProject.mock.calls[0][0]; + expect(proj.rejectedCharacters).toHaveLength(rejectedCharacters.length); + expect(proj.validCharacters).toHaveLength(validCharacters.length); + expect(mockAsyncUpdateGoal).toHaveBeenCalledTimes(1); }); }); - test("getChanges returns correct changes", () => { - const accAcc = "accepted"; - const accRej = "accepted->rejected"; - const accUnd = "accepted->undecided"; - const rejAcc = "rejected->accepted"; - const rejRej = "rejected"; - const rejUnd = "rejected->undecided"; - const undAcc = "undecided->accepted"; - const undRej = "undecided->rejected"; - const oldProj = { - ...newProject(), - validCharacters: [accAcc, accRej, accUnd], - rejectedCharacters: [rejAcc, rejRej, rejUnd], - }; - const charInvState: CharacterInventoryState = { - ...defaultState, - validCharacters: [accAcc, rejAcc, undAcc], - rejectedCharacters: [accRej, rejRej, undRej], - }; - const expectedChanges: CharacterChange[] = [ - [accRej, CharacterStatus.Accepted, CharacterStatus.Rejected], - [accUnd, CharacterStatus.Accepted, CharacterStatus.Undecided], - [rejAcc, CharacterStatus.Rejected, CharacterStatus.Accepted], - [rejUnd, CharacterStatus.Rejected, CharacterStatus.Undecided], - [undAcc, CharacterStatus.Undecided, CharacterStatus.Accepted], - [undRej, CharacterStatus.Undecided, CharacterStatus.Rejected], - ]; - const changes = Actions.getChanges(oldProj, charInvState); - expect(changes.length).toEqual(expectedChanges.length); - expectedChanges.forEach((ch) => expect(changes).toContainEqual(ch)); + describe("fetchWords", () => { + it("correctly affects state", async () => { + const store = setupStore(); + const words = [newWord("v1"), newWord("v2")]; + mockGetFrontierWords.mockResolvedValueOnce(words); + await store.dispatch(fetchWords()); + const { allWords } = store.getState().characterInventoryState; + expect(allWords).toHaveLength(words.length); + }); + }); + + describe("getAllCharacters", () => { + it("correctly affects state", async () => { + const store = setupStore({ + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + allWords: ["123", "45246", "735111189"], + }, + }); + await store.dispatch(getAllCharacters()); + const { characterSet } = store.getState().characterInventoryState; + expect(characterSet).toHaveLength(9); + }); + }); + + describe("loadCharInvData", () => { + it("correctly affects state", async () => { + // Mock data with distinct characters + const mockWord = newWord("1234"); + const rejectedCharacters = ["r", "e", "j"]; + const validCharacters = ["v", "a", "l", "i", "d"]; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { + ...persistedDefaultState.currentProjectState, + project: { ...newProject(), rejectedCharacters, validCharacters }, + }, + }); + mockGetFrontierWords.mockResolvedValueOnce([mockWord]); + await store.dispatch(loadCharInvData()); + const state = store.getState().characterInventoryState; + expect(state.allWords).toHaveLength(1); + expect(state.characterSet).toHaveLength(mockWord.vernacular.length); + expect(state.rejectedCharacters).toHaveLength(rejectedCharacters.length); + expect(state.validCharacters).toHaveLength(validCharacters.length); + }); + }); + + describe("getChanges", () => { + it("returns correct changes", () => { + const accAcc = "accepted"; + const accRej = "accepted->rejected"; + const accUnd = "accepted->undecided"; + const rejAcc = "rejected->accepted"; + const rejRej = "rejected"; + const rejUnd = "rejected->undecided"; + const undAcc = "undecided->accepted"; + const undRej = "undecided->rejected"; + const oldProj = { + ...newProject(), + validCharacters: [accAcc, accRej, accUnd], + rejectedCharacters: [rejAcc, rejRej, rejUnd], + }; + const charInvState: CharacterInventoryState = { + ...defaultCharInvState, + validCharacters: [accAcc, rejAcc, undAcc], + rejectedCharacters: [accRej, rejRej, undRej], + }; + const expectedChanges: CharacterChange[] = [ + [accRej, CharacterStatus.Accepted, CharacterStatus.Rejected], + [accUnd, CharacterStatus.Accepted, CharacterStatus.Undecided], + [rejAcc, CharacterStatus.Rejected, CharacterStatus.Accepted], + [rejUnd, CharacterStatus.Rejected, CharacterStatus.Undecided], + [undAcc, CharacterStatus.Undecided, CharacterStatus.Accepted], + [undRej, CharacterStatus.Undecided, CharacterStatus.Rejected], + ]; + const changes = getChanges(oldProj, charInvState); + expect(changes.length).toEqual(expectedChanges.length); + expectedChanges.forEach((ch) => expect(changes).toContainEqual(ch)); + }); }); }); diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx deleted file mode 100644 index ee94d75858..0000000000 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { - characterInventoryReducer, - defaultState, -} from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; -import { - CharacterInventoryState, - CharacterInventoryAction, - CharacterInventoryType, -} from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; - -const DATA: string[] = ["a", "b"]; -const BAD_RESP: string[] = ["If", "this", "appears", "there's", "an", "issue"]; - -describe("Test Character Inventory Reducer", () => { - it("Returns default state when passed no state", () => { - expect( - characterInventoryReducer(undefined, { - type: "" as CharacterInventoryType.SET_VALID_CHARACTERS, - payload: BAD_RESP, - } as CharacterInventoryAction) - ).toEqual(defaultState); - }); - - it("Returns a state with a specified inventory when passed an inventory", () => { - expect( - characterInventoryReducer(undefined, { - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: DATA, - } as CharacterInventoryAction) - ).toEqual({ - validCharacters: DATA, - allWords: [], - characterSet: [], - rejectedCharacters: [], - selectedCharacter: "", - }); - }); - - it("Returns state passed in when passed an undefined action", () => { - const inv = { - validCharacters: DATA, - allWords: [], - characterSet: [], - rejectedCharacters: [], - selectedCharacter: "", - }; - expect( - characterInventoryReducer(inv, { - type: "" as CharacterInventoryType.SET_VALID_CHARACTERS, - payload: BAD_RESP, - } as CharacterInventoryAction) - ).toEqual(inv); - }); - - it("Returns default state when passed reset action", () => { - const action: StoreAction = { type: StoreActionTypes.RESET }; - - expect( - characterInventoryReducer({} as CharacterInventoryState, action) - ).toEqual(defaultState); - }); -}); diff --git a/src/rootReducer.ts b/src/rootReducer.ts index b0863ed7f0..1756cffa6f 100644 --- a/src/rootReducer.ts +++ b/src/rootReducer.ts @@ -6,21 +6,21 @@ import { projectReducer } from "components/Project/ProjectReducer"; import exportProjectReducer from "components/ProjectExport/Redux/ExportProjectReducer"; import { pronunciationsReducer } from "components/Pronunciations/Redux/PronunciationsReducer"; import { treeViewReducer } from "components/TreeView/Redux/TreeViewReducer"; -import { characterInventoryReducer } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import characterInventoryReducer from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { mergeDupStepReducer } from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import { reviewEntriesReducer } from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesReducer"; import { StoreState } from "types"; import { analyticsReducer } from "types/Redux/analytics"; export const rootReducer: Reducer = combineReducers({ - //login + //login and signup loginState: loginReducer, //project currentProjectState: projectReducer, exportProjectState: exportProjectReducer, - //data entry and review entries + //data entry and review entries goal treeViewState: treeViewReducer, reviewEntriesState: reviewEntriesReducer, pronunciationsState: pronunciationsReducer, @@ -28,7 +28,7 @@ export const rootReducer: Reducer = combineReducers({ //goal timeline and current goal goalsState: goalsReducer, - //merge duplicates goal + //merge duplicates goal and review deferred duplicates goal mergeDuplicateGoal: mergeDupStepReducer, //character inventory goal From 0b196a68cb5e96d7217a3bbc34fd80624ff7f499 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 31 Oct 2023 10:22:25 -0400 Subject: [PATCH 3/7] Don't mutate the state! --- src/goals/CharacterInventory/CharInv/CharacterList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx index 6cae3c7e17..78852f1aee 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx @@ -36,7 +36,7 @@ export default function CharacterList(): ReactElement { }; useEffect(() => { - setOrderedChars(sortBy(allChars, sortOrder)); + setOrderedChars(sortBy([...allChars], sortOrder)); }, [allChars, setOrderedChars, sortOrder]); return ( From 9999501bae2f8de26729fd2825b05e578e817c7d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 31 Oct 2023 10:25:24 -0400 Subject: [PATCH 4/7] Add comment; Remove unnecessary dep --- src/goals/CharacterInventory/CharInv/CharacterList/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx index 78852f1aee..b25f18bb31 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx @@ -36,8 +36,9 @@ export default function CharacterList(): ReactElement { }; useEffect(() => { + // Spread allChars to not mutate the Redux state. setOrderedChars(sortBy([...allChars], sortOrder)); - }, [allChars, setOrderedChars, sortOrder]); + }, [allChars, sortOrder]); return ( <> From 58bf016d462e0e752c9e3fdfdfa3669115b22567 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 31 Oct 2023 10:31:16 -0400 Subject: [PATCH 5/7] Shorten function name --- .../Redux/CharacterInventoryActions.ts | 16 ++++++++-------- .../Redux/CharacterInventoryReducer.ts | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts index 6a4b5cd6cc..9f3449fb99 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts @@ -13,8 +13,8 @@ import { CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; import { - addToRejectedCharactersAction, - addToValidCharactersAction, + addRejectedCharacterAction, + addValidCharacterAction, resetAction, setAllWordsAction, setCharacterSetAction, @@ -33,12 +33,12 @@ import { Path } from "types/path"; // Action Creation Functions -export function addToRejectedCharacters(char: string): PayloadAction { - return addToRejectedCharactersAction(char); +export function addRejectedCharacter(char: string): PayloadAction { + return addRejectedCharacterAction(char); } -export function addToValidCharacters(char: string): PayloadAction { - return addToValidCharactersAction(char); +export function addValidCharacter(char: string): PayloadAction { + return addValidCharacterAction(char); } export function reset(): Action { @@ -73,10 +73,10 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { return (dispatch: StoreStateDispatch, getState: () => StoreState) => { switch (status) { case CharacterStatus.Accepted: - dispatch(addToValidCharacters(character)); + dispatch(addValidCharacter(character)); break; case CharacterStatus.Rejected: - dispatch(addToRejectedCharacters(character)); + dispatch(addRejectedCharacter(character)); break; case CharacterStatus.Undecided: const state = getState().characterInventoryState; diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts index b489c66a33..687f03c4f2 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts @@ -10,7 +10,7 @@ const characterInventorySlice = createSlice({ name: "characterInventoryState", initialState: defaultState, reducers: { - addToRejectedCharactersAction: (state, action) => { + addRejectedCharacterAction: (state, action) => { if (!state.rejectedCharacters.includes(action.payload)) { state.rejectedCharacters.push(action.payload); } @@ -31,7 +31,7 @@ const characterInventorySlice = createSlice({ ); } }, - addToValidCharactersAction: (state, action) => { + addValidCharacterAction: (state, action) => { if (!state.validCharacters.includes(action.payload)) { state.validCharacters.push(action.payload); } @@ -98,8 +98,8 @@ const characterInventorySlice = createSlice({ }); export const { - addToRejectedCharactersAction, - addToValidCharactersAction, + addRejectedCharacterAction, + addValidCharacterAction, resetAction, setAllWordsAction, setCharacterSetAction, From 37ee1bd1271f35aff9f5c2a58ed43abe4fc114ca Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 8 Nov 2023 10:03:27 -0500 Subject: [PATCH 6/7] Incorporate review --- .vscode/settings.json | 1 + .../CharacterInventory/CharInv/index.tsx | 4 +-- .../Redux/CharacterInventoryActions.ts | 35 ++++++++----------- .../Redux/CharacterInventoryReducer.ts | 4 +-- .../tests/CharacterInventoryActions.test.tsx | 33 +++++++++++++---- 5 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index baa9e6cdb4..e80e35b6ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -86,6 +86,7 @@ "thecombine", "upsert", "venv", + "verns", "wordlist", "wordlists" ], diff --git a/src/goals/CharacterInventory/CharInv/index.tsx b/src/goals/CharacterInventory/CharInv/index.tsx index 895ec10b0b..34a853cc27 100644 --- a/src/goals/CharacterInventory/CharInv/index.tsx +++ b/src/goals/CharacterInventory/CharInv/index.tsx @@ -18,7 +18,7 @@ import CharacterSetHeader from "goals/CharacterInventory/CharInv/CharacterSetHea import { exit, loadCharInvData, - reset, + resetCharInv, setSelectedCharacter, uploadInventory, } from "goals/CharacterInventory/Redux/CharacterInventoryActions"; @@ -54,7 +54,7 @@ export default function CharacterInventory(): ReactElement { dispatch(loadCharInvData()); // Call when component unmounts. - () => dispatch(reset()); + () => dispatch(resetCharInv()); }, [dispatch]); const save = async (): Promise => { diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts index 9f3449fb99..d5f5bf5b36 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts @@ -41,7 +41,7 @@ export function addValidCharacter(char: string): PayloadAction { return addValidCharacterAction(char); } -export function reset(): Action { +export function resetCharInv(): Action { return resetAction(); } @@ -100,14 +100,20 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { /** Sends the in-state character inventory to the server. */ export function uploadInventory() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const state = getState(); - const changes = getChangesFromState(state); + const charInvState = getState().characterInventoryState; + const project = getState().currentProjectState.project; + const changes = getChanges(project, charInvState); if (!changes.length) { exit(); return; } - const updatedProject = updateCurrentProject(state); - await dispatch(asyncUpdateCurrentProject(updatedProject)); + await dispatch( + asyncUpdateCurrentProject({ + ...project, + rejectedCharacters: charInvState.rejectedCharacters, + validCharacters: charInvState.validCharacters, + }) + ); dispatch(addCharInvChangesToGoal(changes)); await dispatch(asyncUpdateGoal()); exit(); @@ -169,19 +175,13 @@ function countOccurrences(char: string, words: string[]): number { return count; } -function getChangesFromState(state: StoreState): CharacterChange[] { - const proj = state.currentProjectState.project; - const charInvState = state.characterInventoryState; - return getChanges(proj, charInvState); -} - export function getChanges( - proj: Project, + project: Project, charInvState: CharacterInventoryState ): CharacterChange[] { - const oldAcc = proj.validCharacters; + const oldAcc = project.validCharacters; const newAcc = charInvState.validCharacters; - const oldRej = proj.rejectedCharacters; + const oldRej = project.rejectedCharacters; const newRej = charInvState.rejectedCharacters; const allCharacters = [ ...new Set([...oldAcc, ...newAcc, ...oldRej, ...newRej]), @@ -230,10 +230,3 @@ function getChange( } return undefined; } - -function updateCurrentProject(state: StoreState): Project { - const project = { ...state.currentProjectState.project }; - project.validCharacters = state.characterInventoryState.validCharacters; - project.rejectedCharacters = state.characterInventoryState.rejectedCharacters; - return project; -} diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts index 687f03c4f2..1722d90a07 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts @@ -64,7 +64,7 @@ const characterInventorySlice = createSlice({ } }, setRejectedCharactersAction: (state, action) => { - state.rejectedCharacters = [...new Set(action.payload as string)]; + state.rejectedCharacters = [...new Set(action.payload as string[])]; state.validCharacters = state.validCharacters.filter( (char) => !state.rejectedCharacters.includes(char) ); @@ -80,7 +80,7 @@ const characterInventorySlice = createSlice({ state.selectedCharacter = action.payload; }, setValidCharactersAction: (state, action) => { - state.validCharacters = [...new Set(action.payload as string)]; + state.validCharacters = [...new Set(action.payload as string[])]; state.rejectedCharacters = state.rejectedCharacters.filter( (char) => !state.validCharacters.includes(char) ); diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx index 56e376bca2..f5d77559b1 100644 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx +++ b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx @@ -77,6 +77,7 @@ describe("CharacterInventoryActions", () => { expect(state.characterSet[0].status).toEqual(CharacterStatus.Accepted); expect(state.rejectedCharacters).toHaveLength(0); expect(state.validCharacters).toHaveLength(1); + expect(state.validCharacters[0]).toEqual(character); }); it("changes character from Accepted to Undecided", () => { @@ -94,6 +95,7 @@ describe("CharacterInventoryActions", () => { const state = store.getState().characterInventoryState; expect(state.characterSet[0].status).toEqual(CharacterStatus.Rejected); expect(state.rejectedCharacters).toHaveLength(1); + expect(state.rejectedCharacters[0]).toEqual(character); expect(state.validCharacters).toHaveLength(0); }); }); @@ -134,7 +136,11 @@ describe("CharacterInventoryActions", () => { expect(mockAsyncUpdateCurrentProject).toHaveBeenCalledTimes(1); const proj: Project = mockAsyncUpdateCurrentProject.mock.calls[0][0]; expect(proj.rejectedCharacters).toHaveLength(rejectedCharacters.length); + rejectedCharacters.forEach((c) => + expect(proj.rejectedCharacters).toContain(c) + ); expect(proj.validCharacters).toHaveLength(validCharacters.length); + validCharacters.forEach((c) => expect(proj.validCharacters).toContain(c)); expect(mockAsyncUpdateGoal).toHaveBeenCalledTimes(1); }); }); @@ -142,11 +148,12 @@ describe("CharacterInventoryActions", () => { describe("fetchWords", () => { it("correctly affects state", async () => { const store = setupStore(); - const words = [newWord("v1"), newWord("v2")]; - mockGetFrontierWords.mockResolvedValueOnce(words); + const verns = ["v1", "v2", "v3", "v4"]; + mockGetFrontierWords.mockResolvedValueOnce(verns.map((v) => newWord(v))); await store.dispatch(fetchWords()); const { allWords } = store.getState().characterInventoryState; - expect(allWords).toHaveLength(words.length); + expect(allWords).toHaveLength(verns.length); + verns.forEach((v) => expect(allWords).toContain(v)); }); }); @@ -168,9 +175,10 @@ describe("CharacterInventoryActions", () => { describe("loadCharInvData", () => { it("correctly affects state", async () => { // Mock data with distinct characters - const mockWord = newWord("1234"); + const mockVern = "1234"; const rejectedCharacters = ["r", "e", "j"]; const validCharacters = ["v", "a", "l", "i", "d"]; + const store = setupStore({ ...persistedDefaultState, currentProjectState: { @@ -178,13 +186,26 @@ describe("CharacterInventoryActions", () => { project: { ...newProject(), rejectedCharacters, validCharacters }, }, }); - mockGetFrontierWords.mockResolvedValueOnce([mockWord]); + mockGetFrontierWords.mockResolvedValueOnce([newWord(mockVern)]); await store.dispatch(loadCharInvData()); const state = store.getState().characterInventoryState; + expect(state.allWords).toHaveLength(1); - expect(state.characterSet).toHaveLength(mockWord.vernacular.length); + expect(state.allWords[0]).toEqual(mockVern); + + expect(state.characterSet).toHaveLength(mockVern.length); + const chars = state.characterSet.map((char) => char.character); + [...mockVern].forEach((c) => expect(chars).toContain(c)); + expect(state.rejectedCharacters).toHaveLength(rejectedCharacters.length); + rejectedCharacters.forEach((c) => + expect(state.rejectedCharacters).toContain(c) + ); + expect(state.validCharacters).toHaveLength(validCharacters.length); + validCharacters.forEach((c) => + expect(state.validCharacters).toContain(c) + ); }); }); From e4e1aabbe274f120e0fef71d2e97c0a08a4ad82e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 8 Nov 2023 11:35:28 -0500 Subject: [PATCH 7/7] Rename resetAction -> resetCharInvAction; Enhance test --- .../CharacterInventory/Redux/CharacterInventoryActions.ts | 4 ++-- .../CharacterInventory/Redux/CharacterInventoryReducer.ts | 4 ++-- .../Redux/tests/CharacterInventoryActions.test.tsx | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts index d5f5bf5b36..c14ac5bc33 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts @@ -15,7 +15,7 @@ import { import { addRejectedCharacterAction, addValidCharacterAction, - resetAction, + resetCharInvAction, setAllWordsAction, setCharacterSetAction, setRejectedCharactersAction, @@ -42,7 +42,7 @@ export function addValidCharacter(char: string): PayloadAction { } export function resetCharInv(): Action { - return resetAction(); + return resetCharInvAction(); } export function setAllWords(words: string[]): PayloadAction { diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts index 1722d90a07..f40f7b0ce6 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts @@ -54,7 +54,7 @@ const characterInventorySlice = createSlice({ ); } }, - resetAction: () => defaultState, + resetCharInvAction: () => defaultState, setAllWordsAction: (state, action) => { state.allWords = action.payload; }, @@ -100,7 +100,7 @@ const characterInventorySlice = createSlice({ export const { addRejectedCharacterAction, addValidCharacterAction, - resetAction, + resetCharInvAction, setAllWordsAction, setCharacterSetAction, setRejectedCharactersAction, diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx index f5d77559b1..1151c04b77 100644 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx +++ b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx @@ -163,12 +163,15 @@ describe("CharacterInventoryActions", () => { ...persistedDefaultState, characterInventoryState: { ...persistedDefaultState.characterInventoryState, + // Words containing the characters 1 through 9 allWords: ["123", "45246", "735111189"], }, }); await store.dispatch(getAllCharacters()); const { characterSet } = store.getState().characterInventoryState; expect(characterSet).toHaveLength(9); + const chars = characterSet.map((char) => char.character); + [..."123456789"].forEach((c) => expect(chars).toContain(c)); }); });