diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs index 5b99f812a3..54a0f57e8d 100644 --- a/Backend/Controllers/SpeakerController.cs +++ b/Backend/Controllers/SpeakerController.cs @@ -116,6 +116,13 @@ public async Task DeleteSpeaker(string projectId, string speakerI return NotFound(speakerId); } + // Delete consent file + var path = FileStorage.GetConsentFilePath(speakerId); + if (path is not null) + { + IO.File.Delete(path); + } + // Delete speaker and return success return Ok(await _speakerRepo.Delete(projectId, speakerId)); } @@ -145,8 +152,8 @@ public async Task RemoveConsent(string projectId, string speakerI { return StatusCode(StatusCodes.Status304NotModified, speakerId); } - var path = FileStorage.GenerateConsentFilePath(speaker.Id); - if (IO.File.Exists(path)) + var path = FileStorage.GetConsentFilePath(speaker.Id); + if (path is not null) { IO.File.Delete(path); } @@ -232,9 +239,12 @@ public async Task UploadConsent( { return BadRequest("Empty File"); } + + var extension = IO.Path.GetExtension(file.FileName) ?? ""; if (file.ContentType.Contains("audio")) { speaker.Consent = ConsentType.Audio; + extension = ".webm"; } else if (file.ContentType.Contains("image")) { @@ -245,8 +255,15 @@ public async Task UploadConsent( return BadRequest("File should be audio or image"); } + // Delete old consent file + var old = FileStorage.GetConsentFilePath(speaker.Id); + if (old is not null) + { + IO.File.Delete(old); + } + // Copy file data to a new local file - var path = FileStorage.GenerateConsentFilePath(speakerId); + var path = FileStorage.GenerateConsentFilePath(speakerId, extension); await using (var fs = new IO.FileStream(path, IO.FileMode.OpenOrCreate)) { await file.CopyToAsync(fs); @@ -284,8 +301,8 @@ public IActionResult DownloadConsent(string speakerId) } // Ensure file exists - var path = FileStorage.GenerateConsentFilePath(speakerId); - if (!IO.File.Exists(path)) + var path = FileStorage.GetConsentFilePath(speakerId); + if (path is null) { return NotFound(speakerId); } diff --git a/Backend/Helper/FileStorage.cs b/Backend/Helper/FileStorage.cs index 60b02ffa4b..124c658978 100644 --- a/Backend/Helper/FileStorage.cs +++ b/Backend/Helper/FileStorage.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Runtime.Serialization; namespace BackendFramework.Helper @@ -96,9 +97,20 @@ public static string GenerateAvatarFilePath(string userId) /// Generate the path to where Consent audio/images are stored. /// /// Throws when id invalid. - public static string GenerateConsentFilePath(string speakerId) + public static string GenerateConsentFilePath(string speakerId, string? extension = null) { - return GenerateFilePath(ConsentDir, Sanitization.SanitizeId(speakerId)); + var fileName = Path.ChangeExtension(Sanitization.SanitizeId(speakerId), extension); + return GenerateFilePath(ConsentDir, fileName); + } + + /// + /// Get the path of a Consent audio/images, or null if it doesn't exist. + /// + /// Throws when id invalid. + public static string? GetConsentFilePath(string speakerId) + { + var searchPattern = $"*{Sanitization.SanitizeId(speakerId)}*"; + return Directory.GetFiles(GenerateDirPath(ConsentDir, true), searchPattern).FirstOrDefault(); } /// diff --git a/Backend/Models/MergeWordSet.cs b/Backend/Models/MergeWordSet.cs index f88dcfb594..48a962278c 100644 --- a/Backend/Models/MergeWordSet.cs +++ b/Backend/Models/MergeWordSet.cs @@ -27,23 +27,18 @@ public MergeWordSet() Id = ""; ProjectId = ""; UserId = ""; - WordIds = new List(); + WordIds = new(); } public MergeWordSet Clone() { - var clone = new MergeWordSet + return new() { Id = Id, ProjectId = ProjectId, UserId = UserId, - WordIds = new List() + WordIds = WordIds.Select(id => id).ToList() }; - foreach (var id in WordIds) - { - clone.WordIds.Add(id); - } - return clone; } public bool ContentEquals(MergeWordSet other) diff --git a/Backend/Models/MergeWords.cs b/Backend/Models/MergeWords.cs index 2490938770..a3cd3e4a67 100644 --- a/Backend/Models/MergeWords.cs +++ b/Backend/Models/MergeWords.cs @@ -22,8 +22,8 @@ public class MergeWords public MergeWords() { - Parent = new Word(); - Children = new List(); + Parent = new(); + Children = new(); DeleteOnly = false; } } @@ -59,8 +59,8 @@ public class MergeUndoIds public MergeUndoIds() { - ParentIds = new List(); - ChildIds = new List(); + ParentIds = new(); + ChildIds = new(); } public MergeUndoIds(List parentIds, List childIds) @@ -71,23 +71,11 @@ public MergeUndoIds(List parentIds, List childIds) public MergeUndoIds Clone() { - var clone = new MergeUndoIds + return new() { - ParentIds = new List(), - ChildIds = new List() + ParentIds = ParentIds.Select(id => id).ToList(), + ChildIds = ChildIds.Select(id => id).ToList() }; - - foreach (var id in ParentIds) - { - clone.ParentIds.Add(id); - } - - foreach (var id in ChildIds) - { - clone.ChildIds.Add(id); - } - - return clone; } public bool ContentEquals(MergeUndoIds other) diff --git a/Backend/Models/Project.cs b/Backend/Models/Project.cs index ccfe2c4463..3725c8b390 100644 --- a/Backend/Models/Project.cs +++ b/Backend/Models/Project.cs @@ -92,22 +92,22 @@ public Project() DefinitionsEnabled = false; GrammaticalInfoEnabled = false; AutocompleteSetting = AutocompleteSetting.On; - SemDomWritingSystem = new WritingSystem(); - VernacularWritingSystem = new WritingSystem(); - AnalysisWritingSystems = new List(); - SemanticDomains = new List(); - ValidCharacters = new List(); - RejectedCharacters = new List(); - CustomFields = new List(); - WordFields = new List(); - PartsOfSpeech = new List(); - InviteTokens = new List(); - WorkshopSchedule = new List(); + SemDomWritingSystem = new(); + VernacularWritingSystem = new(); + AnalysisWritingSystems = new(); + SemanticDomains = new(); + ValidCharacters = new(); + RejectedCharacters = new(); + CustomFields = new(); + WordFields = new(); + PartsOfSpeech = new(); + InviteTokens = new(); + WorkshopSchedule = new(); } public Project Clone() { - var clone = new Project + return new() { Id = Id, Name = Name, @@ -118,55 +118,16 @@ public Project Clone() AutocompleteSetting = AutocompleteSetting, SemDomWritingSystem = SemDomWritingSystem.Clone(), VernacularWritingSystem = VernacularWritingSystem.Clone(), - AnalysisWritingSystems = new List(), - SemanticDomains = new List(), - ValidCharacters = new List(), - RejectedCharacters = new List(), - CustomFields = new List(), - WordFields = new List(), - PartsOfSpeech = new List(), - InviteTokens = new List(), - WorkshopSchedule = new List(), + AnalysisWritingSystems = AnalysisWritingSystems.Select(ws => ws.Clone()).ToList(), + SemanticDomains = SemanticDomains.Select(sd => sd.Clone()).ToList(), + ValidCharacters = ValidCharacters.Select(vc => vc).ToList(), + RejectedCharacters = RejectedCharacters.Select(rc => rc).ToList(), + CustomFields = CustomFields.Select(cf => cf.Clone()).ToList(), + WordFields = WordFields.Select(wf => wf).ToList(), + PartsOfSpeech = PartsOfSpeech.Select(ps => ps).ToList(), + InviteTokens = InviteTokens.Select(it => it.Clone()).ToList(), + WorkshopSchedule = WorkshopSchedule.Select(dt => dt).ToList(), }; - - foreach (var aw in AnalysisWritingSystems) - { - clone.AnalysisWritingSystems.Add(aw.Clone()); - } - foreach (var sd in SemanticDomains) - { - clone.SemanticDomains.Add(sd.Clone()); - } - foreach (var cs in ValidCharacters) - { - clone.ValidCharacters.Add(cs); - } - foreach (var cs in RejectedCharacters) - { - clone.RejectedCharacters.Add(cs); - } - foreach (var cf in CustomFields) - { - clone.CustomFields.Add(cf.Clone()); - } - foreach (var wf in WordFields) - { - clone.WordFields.Add(wf); - } - foreach (var pos in PartsOfSpeech) - { - clone.PartsOfSpeech.Add(pos); - } - foreach (var it in InviteTokens) - { - clone.InviteTokens.Add(it.Clone()); - } - foreach (var dt in WorkshopSchedule) - { - clone.WorkshopSchedule.Add(dt); - } - - return clone; } public bool ContentEquals(Project other) @@ -362,8 +323,8 @@ public class UserCreatedProject public UserCreatedProject() { - Project = new Project(); - User = new User(); + Project = new(); + User = new(); } } diff --git a/Backend/Models/SemanticDomain.cs b/Backend/Models/SemanticDomain.cs index fc076dae2f..f359a995f7 100644 --- a/Backend/Models/SemanticDomain.cs +++ b/Backend/Models/SemanticDomain.cs @@ -94,7 +94,7 @@ public SemanticDomainFull() Name = ""; Id = ""; Description = ""; - Questions = new List(); + Questions = new(); Lang = ""; } @@ -102,13 +102,7 @@ public SemanticDomainFull() { var clone = (SemanticDomainFull)base.Clone(); clone.Description = Description; - clone.Questions = new List(); - - foreach (var question in Questions) - { - clone.Questions.Add(question); - } - + clone.Questions = Questions.Select(q => q).ToList(); return clone; } @@ -173,7 +167,7 @@ public SemanticDomainTreeNode(SemanticDomain sd) Lang = sd.Lang; Name = sd.Name; Id = sd.Id; - Children = new List(); + Children = new(); } } } diff --git a/Backend/Models/Sense.cs b/Backend/Models/Sense.cs index 9f18baad47..4f9e09991d 100644 --- a/Backend/Models/Sense.cs +++ b/Backend/Models/Sense.cs @@ -49,44 +49,25 @@ public Sense() // By default generate a new, unique Guid for each new Sense. Guid = Guid.NewGuid(); Accessibility = Status.Active; - GrammaticalInfo = new GrammaticalInfo(); - Definitions = new List(); - Glosses = new List(); - ProtectReasons = new List(); - SemanticDomains = new List(); + GrammaticalInfo = new(); + Definitions = new(); + Glosses = new(); + ProtectReasons = new(); + SemanticDomains = new(); } public Sense Clone() { - var clone = new Sense + return new() { Guid = Guid, Accessibility = Accessibility, GrammaticalInfo = GrammaticalInfo.Clone(), - Definitions = new List(), - Glosses = new List(), - ProtectReasons = new List(), - SemanticDomains = new List(), + Definitions = Definitions.Select(d => d.Clone()).ToList(), + Glosses = Glosses.Select(g => g.Clone()).ToList(), + ProtectReasons = ProtectReasons.Select(pr => pr.Clone()).ToList(), + SemanticDomains = SemanticDomains.Select(sd => sd.Clone()).ToList(), }; - - foreach (var definition in Definitions) - { - clone.Definitions.Add(definition.Clone()); - } - foreach (var gloss in Glosses) - { - clone.Glosses.Add(gloss.Clone()); - } - foreach (var reason in ProtectReasons) - { - clone.ProtectReasons.Add(reason.Clone()); - } - foreach (var sd in SemanticDomains) - { - clone.SemanticDomains.Add(sd.Clone()); - } - - return clone; } public override bool Equals(object? obj) diff --git a/Backend/Models/Statistics.cs b/Backend/Models/Statistics.cs index ef1d153090..65dc326b1a 100644 --- a/Backend/Models/Statistics.cs +++ b/Backend/Models/Statistics.cs @@ -27,7 +27,7 @@ public SemanticDomainUserCount() { Id = ""; Username = ""; - DomainSet = new HashSet(); + DomainSet = new(); DomainCount = 0; WordCount = 0; } @@ -90,8 +90,8 @@ public class ChartRootData public ChartRootData() { - Dates = new List(); - Datasets = new List(); + Dates = new(); + Datasets = new(); } } @@ -107,7 +107,7 @@ public class Dataset public Dataset(string userName, int data) { UserName = userName; - Data = new List() { data }; + Data = new() { data }; } } diff --git a/Backend/Models/User.cs b/Backend/Models/User.cs index 26ec30be15..85bbdd6de7 100644 --- a/Backend/Models/User.cs +++ b/Backend/Models/User.cs @@ -96,13 +96,13 @@ public User() UILang = ""; Token = ""; IsAdmin = false; - WorkedProjects = new Dictionary(); - ProjectRoles = new Dictionary(); + WorkedProjects = new(); + ProjectRoles = new(); } public User Clone() { - var clone = new User + return new() { Id = Id, Avatar = Avatar, @@ -117,21 +117,9 @@ public User Clone() UILang = UILang, Token = Token, IsAdmin = IsAdmin, - WorkedProjects = new Dictionary(), - ProjectRoles = new Dictionary() + WorkedProjects = WorkedProjects.ToDictionary(kv => kv.Key, kv => kv.Value), + ProjectRoles = ProjectRoles.ToDictionary(kv => kv.Key, kv => kv.Value), }; - - foreach (var projId in WorkedProjects.Keys) - { - clone.WorkedProjects.Add(projId, WorkedProjects[projId]); - } - - foreach (var projId in ProjectRoles.Keys) - { - clone.ProjectRoles.Add(projId, ProjectRoles[projId]); - } - - return clone; } public bool ContentEquals(User other) diff --git a/Backend/Models/UserEdit.cs b/Backend/Models/UserEdit.cs index 7d683e740e..3f1c51263f 100644 --- a/Backend/Models/UserEdit.cs +++ b/Backend/Models/UserEdit.cs @@ -27,24 +27,17 @@ public UserEdit() { Id = ""; ProjectId = ""; - Edits = new List(); + Edits = new(); } public UserEdit Clone() { - var clone = new UserEdit + return new() { Id = Id, ProjectId = ProjectId, - Edits = new List() + Edits = Edits.Select(e => e.Clone()).ToList() }; - - foreach (var edit in Edits) - { - clone.Edits.Add(edit.Clone()); - } - - return clone; } public bool ContentEquals(UserEdit other) @@ -133,26 +126,19 @@ public Edit() { Guid = Guid.NewGuid(); GoalType = 0; - StepData = new List(); + StepData = new(); Changes = "{}"; } public Edit Clone() { - var clone = new Edit + return new() { Guid = Guid, GoalType = GoalType, - StepData = new List(), + StepData = StepData.Select(sd => sd).ToList(), Changes = Changes }; - - foreach (var step in StepData) - { - clone.StepData.Add(step); - } - - return clone; } public override bool Equals(object? obj) diff --git a/Backend/Models/Word.cs b/Backend/Models/Word.cs index 2a5e03e917..3266fbbc68 100644 --- a/Backend/Models/Word.cs +++ b/Backend/Models/Word.cs @@ -94,18 +94,18 @@ public Word() OtherField = ""; ProjectId = ""; Accessibility = Status.Active; - Audio = new List(); - EditedBy = new List(); - History = new List(); - ProtectReasons = new List(); - Senses = new List(); - Note = new Note(); - Flag = new Flag(); + Audio = new(); + EditedBy = new(); + History = new(); + ProtectReasons = new(); + Senses = new(); + Note = new(); + Flag = new(); } public Word Clone() { - var clone = new Word + return new() { Id = Id, Guid = Guid, @@ -116,37 +116,14 @@ public Word Clone() OtherField = OtherField, ProjectId = ProjectId, Accessibility = Accessibility, - Audio = new List(), - EditedBy = new List(), - History = new List(), - ProtectReasons = new List(), - Senses = new List(), + Audio = Audio.Select(p => p.Clone()).ToList(), + EditedBy = EditedBy.Select(id => id).ToList(), + History = History.Select(id => id).ToList(), + ProtectReasons = ProtectReasons.Select(pr => pr.Clone()).ToList(), + Senses = Senses.Select(s => s.Clone()).ToList(), Note = Note.Clone(), Flag = Flag.Clone(), }; - - foreach (var audio in Audio) - { - clone.Audio.Add(audio.Clone()); - } - foreach (var id in EditedBy) - { - clone.EditedBy.Add(id); - } - foreach (var id in History) - { - clone.History.Add(id); - } - foreach (var reason in ProtectReasons) - { - clone.ProtectReasons.Add(reason.Clone()); - } - foreach (var sense in Senses) - { - clone.Senses.Add(sense.Clone()); - } - - return clone; } public bool ContentEquals(Word other) diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 9acb4a412d..d9f082b2d0 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -347,12 +347,19 @@ public async Task LiftExport( { if (speaker.Consent != ConsentType.None) { - var src = FileStorage.GenerateConsentFilePath(speaker.Id); - if (File.Exists(src)) + var src = FileStorage.GetConsentFilePath(speaker.Id); + if (src is not null) { - var dest = Path.Combine(consentDir, speaker.Id); - File.Copy(src, dest, true); - + var dest = Path.Combine(consentDir, Path.GetFileName(src)); + if (Path.GetExtension(dest).Equals(".webm", StringComparison.OrdinalIgnoreCase)) + { + dest = Path.ChangeExtension(dest, ".wav"); + await FFmpeg.Conversions.New().Start($"-y -i \"{src}\" \"{dest}\""); + } + else + { + File.Copy(src, dest); + } } } } diff --git a/src/components/AppBar/tests/NavigationButtons.test.tsx b/src/components/AppBar/tests/NavigationButtons.test.tsx index 2136106f80..429fdfeed0 100644 --- a/src/components/AppBar/tests/NavigationButtons.test.tsx +++ b/src/components/AppBar/tests/NavigationButtons.test.tsx @@ -27,7 +27,7 @@ const mockStore = configureMockStore()({ let testRenderer: renderer.ReactTestRenderer; let entryButton: ReactTestInstance; -let cleanButton: ReactTestInstance; +let cleanButton: ReactTestInstance | null; const renderNavButtons = async ( path: Path, @@ -57,9 +57,7 @@ const renderNavButtonsWithPermission = async ( const cleanupButtons = testRenderer.root.findAllByProps({ id: dataCleanupButtonId, }); - if (cleanupButtons.length) { - cleanButton = cleanupButtons[0]; - } + cleanButton = cleanupButtons.length ? cleanupButtons[0] : null; }; beforeEach(() => { @@ -74,9 +72,9 @@ describe("NavigationButtons", () => { perm === Permission.CharacterInventory || perm === Permission.MergeAndReviewEntries ) { - expect(cleanButton).toBeTruthy; + expect(cleanButton).toBeTruthy(); } else { - expect(cleanButton).toBeUndefined; + expect(cleanButton).toBeNull(); } } }); diff --git a/src/components/AppBar/tests/SpeakerMenu.test.tsx b/src/components/AppBar/tests/SpeakerMenu.test.tsx index f1f19ed782..cd8c94b536 100644 --- a/src/components/AppBar/tests/SpeakerMenu.test.tsx +++ b/src/components/AppBar/tests/SpeakerMenu.test.tsx @@ -54,7 +54,7 @@ describe("SpeakerMenuList", () => { it("has one disabled menu item if no speakers", async () => { await renderMenuList(); const menuItem = testRenderer.root.findByType(MenuItem); - expect(menuItem).toBeDisabled; + expect(menuItem.props.disabled).toBeTruthy(); }); it("has divider and one more menu item than speakers", async () => { diff --git a/src/components/Buttons/IconButtonWithTooltip.tsx b/src/components/Buttons/IconButtonWithTooltip.tsx index 51527916db..fd854f1e77 100644 --- a/src/components/Buttons/IconButtonWithTooltip.tsx +++ b/src/components/Buttons/IconButtonWithTooltip.tsx @@ -3,6 +3,7 @@ import { MouseEventHandler, ReactElement, ReactNode } from "react"; import { useTranslation } from "react-i18next"; interface IconButtonWithTooltipProps { + disabled?: boolean; icon: ReactElement; text?: ReactNode; textId?: string; @@ -27,7 +28,7 @@ export default function IconButtonWithTooltip( onClick={props.onClick} size={props.size || "medium"} id={props.buttonId} - disabled={!props.onClick} + disabled={props.disabled || !props.onClick} > {props.icon} diff --git a/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx b/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx index 5e6377311c..7ab950514c 100644 --- a/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx @@ -12,6 +12,7 @@ interface DeleteEntryProps { // if no confirmId is specified, then there is no popup // and deletion will happen when the button is pressed confirmId?: string; + disabled?: boolean; wordId?: string; } @@ -34,6 +35,7 @@ export default function DeleteEntry(props: DeleteEntryProps): ReactElement { <> void | Promise; } @@ -18,11 +19,16 @@ export default function EntryNote(props: EntryNoteProps): ReactElement { <> t.palette.grey[700] }} /> + t.palette.grey[props.disabled ? 400 : 700] }} + /> ) : ( - t.palette.grey[700] }} /> + t.palette.grey[props.disabled ? 400 : 700] }} + /> ) } onClick={props.updateNote ? () => setNoteOpen(true) : undefined} diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx index db872fcdf5..08478b014c 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx @@ -26,7 +26,13 @@ import { StoreState } from "types"; import theme from "types/theme"; import { FileWithSpeakerId } from "types/word"; -const idAffix = "new-entry"; +export enum NewEntryId { + ButtonDelete = "new-entry-delete-button", + ButtonNote = "new-entry-note-button", + GridNewEntry = "new-entry", + TextFieldGloss = "new-entry-gloss-textfield", + TextFieldVern = "new-entry-vernacular-textfield", +} export enum FocusTarget { Gloss, @@ -103,6 +109,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement { const [senseOpen, setSenseOpen] = useState(false); const [shouldFocus, setShouldFocus] = useState(); + const [submitting, setSubmitting] = useState(false); const [vernOpen, setVernOpen] = useState(false); const [wasTreeClosed, setWasTreeClosed] = useState(false); @@ -124,6 +131,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement { const resetState = useCallback((): void => { resetNewEntry(); + setSubmitting(false); setVernOpen(false); focus(FocusTarget.Vernacular); }, [focus, resetNewEntry]); @@ -169,6 +177,11 @@ export default function NewEntry(props: NewEntryProps): ReactElement { }; const addNewEntryAndReset = async (): Promise => { + // Prevent double-submission + if (submitting) { + return; + } + setSubmitting(true); await addNewEntry(); resetState(); }; @@ -228,7 +241,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement { }; return ( - + handleEnter(true)} vernacularLang={vernacularLang} - textFieldId={`${idAffix}-vernacular`} + textFieldId={NewEntryId.TextFieldVern} onUpdate={() => conditionalFocus(FocusTarget.Vernacular)} /> handleEnter(false)} analysisLang={analysisLang} - textFieldId={`${idAffix}-gloss`} + textFieldId={NewEntryId.TextFieldGloss} onUpdate={() => conditionalFocus(FocusTarget.Gloss)} /> @@ -289,9 +302,9 @@ export default function NewEntry(props: NewEntryProps): ReactElement { {!selectedDup?.id && ( // note is not available if user selected to modify an exiting entry )} @@ -306,8 +319,8 @@ export default function NewEntry(props: NewEntryProps): ReactElement { resetState()} - buttonId={`${idAffix}-delete`} /> diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx index 113d5bfdeb..0214822a07 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx @@ -1,50 +1,184 @@ -import { createRef } from "react"; +import { type ReactElement, createRef } from "react"; import { Provider } from "react-redux"; -import renderer from "react-test-renderer"; +import { type ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; -import NewEntry from "components/DataEntry/DataEntryTable/NewEntry"; +import { + GlossWithSuggestions, + VernWithSuggestions, +} from "components/DataEntry/DataEntryTable/EntryCellComponents"; +import NewEntry, { + NewEntryId, +} from "components/DataEntry/DataEntryTable/NewEntry"; import { newWritingSystem } from "types/writingSystem"; -jest.mock("@mui/material/Autocomplete", () => "div"); +jest.mock( + "@mui/material/Autocomplete", + () => (props: any) => mockAutocomplete(props) +); jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); +/** Bypass the Autocomplete and render its internal input with the props of both. */ +const mockAutocomplete = (props: { + renderInput: (params: any) => ReactElement; +}): ReactElement => { + const { renderInput, ...params } = props; + return renderInput(params); +}; + +const mockAddNewAudio = jest.fn(); +const mockAddNewEntry = jest.fn(); +const mockDelNewAudio = jest.fn(); +const mockSetNewGloss = jest.fn(); +const mockSetNewNote = jest.fn(); +const mockSetNewVern = jest.fn(); +const mockSetSelectedDup = jest.fn(); +const mockSetSelectedSense = jest.fn(); +const mockRepNewAudio = jest.fn(); +const mockResetNewEntry = jest.fn(); +const mockUpdateWordWithNewGloss = jest.fn(); + const mockStore = configureMockStore()({ treeViewState: { open: false } }); +let renderer: ReactTestRenderer; + +const renderNewEntry = async ( + vern = "", + gloss = "", + note = "" +): Promise => { + await act(async () => { + renderer = create( + + ()} + // Parent component handles vern suggestion state: + setSelectedDup={mockSetSelectedDup} + setSelectedSense={mockSetSelectedSense} + suggestedVerns={[]} + suggestedDups={[]} + /> + + ); + }); +}; + +beforeEach(() => { + jest.resetAllMocks(); +}); + describe("NewEntry", () => { - it("renders without crashing", () => { - renderer.act(() => { - renderer.create( - - ()} - // Parent component handles vern suggestion state: - setSelectedDup={jest.fn()} - setSelectedSense={jest.fn()} - suggestedVerns={[]} - suggestedDups={[]} - /> - - ); + it("does not submit without a vernacular", async () => { + await renderNewEntry("", "gloss"); + await act(async () => { + renderer.root.findByType(GlossWithSuggestions).props.handleEnter(); + }); + expect(mockAddNewEntry).not.toHaveBeenCalled(); + }); + + it("does not submit with vernacular Enter if gloss is empty", async () => { + await renderNewEntry("vern", ""); + await act(async () => { + renderer.root.findByType(VernWithSuggestions).props.handleEnter(); + }); + expect(mockAddNewEntry).not.toHaveBeenCalled(); + }); + + it("does submit with gloss Enter if gloss is empty", async () => { + await renderNewEntry("vern", ""); + await act(async () => { + renderer.root.findByType(GlossWithSuggestions).props.handleEnter(); + }); + expect(mockAddNewEntry).toHaveBeenCalledTimes(1); + }); + + it("resets when the delete button is clicked", async () => { + await renderNewEntry(); + expect(mockResetNewEntry).not.toHaveBeenCalled(); + await act(async () => { + renderer.root + .findByProps({ id: NewEntryId.ButtonDelete }) + .props.onClick(); + }); + expect(mockResetNewEntry).toHaveBeenCalledTimes(1); + }); + + it("resets new entry after awaiting add", async () => { + await renderNewEntry("vern", "gloss"); + + // Use a mock timer to control when addNewEntry completes + jest.useFakeTimers(); + mockAddNewEntry.mockImplementation( + async () => await new Promise((res) => setTimeout(res, 1000)) + ); + + // Submit a new entry + await act(async () => { + renderer.root.findByType(GlossWithSuggestions).props.handleEnter(); }); + expect(mockAddNewEntry).toHaveBeenCalledTimes(1); + expect(mockResetNewEntry).not.toHaveBeenCalled(); + + // Run the timers and confirm a reset + await act(async () => { + jest.runAllTimers(); + }); + expect(mockAddNewEntry).toHaveBeenCalledTimes(1); + expect(mockResetNewEntry).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + + it("doesn't allow double submission", async () => { + await renderNewEntry("vern", "gloss"); + + // Use a mock timer to control when addNewEntry completes + jest.useFakeTimers(); + mockAddNewEntry.mockImplementation( + async () => await new Promise((res) => setTimeout(res, 1000)) + ); + + // Submit a new entry + const gloss = renderer.root.findByType(GlossWithSuggestions); + await act(async () => { + gloss.props.handleEnter(); + }); + expect(mockAddNewEntry).toHaveBeenCalledTimes(1); + expect(mockResetNewEntry).not.toHaveBeenCalled(); + + // Attempt a second submission before the first one completes + await act(async () => { + gloss.props.handleEnter(); + }); + expect(mockAddNewEntry).toHaveBeenCalledTimes(1); + expect(mockResetNewEntry).not.toHaveBeenCalled(); + + // Run the timers and confirm no second submission + await act(async () => { + jest.runAllTimers(); + }); + expect(mockAddNewEntry).toHaveBeenCalledTimes(1); + expect(mockResetNewEntry).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); }); }); diff --git a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx index 95264f1f85..84419bc9d1 100644 --- a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx @@ -40,9 +40,19 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { if (sense.glosses.length < 1) { sense.glosses.push(newGloss("", props.analysisLang.bcp47)); } + const [editing, setEditing] = useState(false); const [gloss, setGloss] = useState(firstGlossText(sense)); const [vernacular, setVernacular] = useState(props.entry.vernacular); + const updateGlossField = (gloss: string): void => { + setEditing(gloss !== firstGlossText(sense)); + setGloss(gloss); + }; + const updateVernField = (vern: string): void => { + setEditing(vern !== props.entry.vernacular); + setVernacular(vern); + }; + function conditionallyUpdateGloss(): void { if (firstGlossText(sense) !== gloss) { props.updateGloss(props.rowIndex, gloss); @@ -77,7 +87,7 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { 1} - updateVernField={setVernacular} + updateVernField={updateVernField} onBlur={() => conditionallyUpdateVern()} handleEnter={() => { vernacular && props.focusNewEntry(); @@ -98,7 +108,7 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { conditionallyUpdateGloss()} handleEnter={() => { gloss && props.focusNewEntry(); @@ -116,13 +126,12 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { position: "relative", }} > - {!props.disabled && ( - - )} + - {!props.disabled && ( - { - props.delAudioFromWord(props.entry.id, fileName); - }} - replaceAudio={(audio) => - props.repAudioInWord(props.entry.id, audio) - } - uploadAudio={(file) => { - props.addAudioToWord(props.entry.id, file); - }} - /> - )} + { + props.delAudioFromWord(props.entry.id, fileName); + }} + replaceAudio={(audio) => props.repAudioInWord(props.entry.id, audio)} + uploadAudio={(file) => { + props.addAudioToWord(props.entry.id, file); + }} + /> - {!props.disabled && ( - - )} + ); diff --git a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx index 1101939a44..3735826258 100644 --- a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx @@ -13,6 +13,7 @@ import "tests/reactI18nextMock"; import { Word } from "api/models"; import { defaultState } from "components/App/DefaultState"; import { + DeleteEntry, EntryNote, GlossWithSuggestions, VernWithSuggestions, @@ -21,6 +22,7 @@ import RecentEntry from "components/DataEntry/DataEntryTable/RecentEntry"; import { EditTextDialog } from "components/Dialogs"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; +import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; import theme from "types/theme"; import { newPronunciation, simpleWord } from "types/word"; import { newWritingSystem } from "types/writingSystem"; @@ -89,6 +91,34 @@ describe("ExistingEntry", () => { }); describe("vernacular", () => { + it("disables buttons if changing", async () => { + await renderWithWord(mockWord); + const vern = testHandle.findByType(VernWithSuggestions); + const note = testHandle.findByType(EntryNote); + const audio = testHandle.findByType(PronunciationsBackend); + const del = testHandle.findByType(DeleteEntry); + + expect(note.props.disabled).toBeFalsy(); + expect(audio.props.disabled).toBeFalsy(); + expect(del.props.disabled).toBeFalsy(); + + async function updateVern(text: string): Promise { + await act(async () => { + await vern.props.updateVernField(text); + }); + } + + await updateVern(mockText); + expect(note.props.disabled).toBeTruthy(); + expect(audio.props.disabled).toBeTruthy(); + expect(del.props.disabled).toBeTruthy(); + + await updateVern(mockVern); + expect(note.props.disabled).toBeFalsy(); + expect(audio.props.disabled).toBeFalsy(); + expect(del.props.disabled).toBeFalsy(); + }); + it("updates if changed", async () => { await renderWithWord(mockWord); testHandle = testHandle.findByType(VernWithSuggestions); @@ -102,11 +132,39 @@ describe("ExistingEntry", () => { await updateVernAndBlur(mockVern); expect(mockUpdateVern).toHaveBeenCalledTimes(0); await updateVernAndBlur(mockText); - expect(mockUpdateVern).toBeCalledWith(0, mockText); + expect(mockUpdateVern).toHaveBeenCalledWith(0, mockText); }); }); describe("gloss", () => { + it("disables buttons if changing", async () => { + await renderWithWord(mockWord); + const gloss = testHandle.findByType(GlossWithSuggestions); + const note = testHandle.findByType(EntryNote); + const audio = testHandle.findByType(PronunciationsBackend); + const del = testHandle.findByType(DeleteEntry); + + expect(note.props.disabled).toBeFalsy(); + expect(audio.props.disabled).toBeFalsy(); + expect(del.props.disabled).toBeFalsy(); + + async function updateGloss(text: string): Promise { + await act(async () => { + await gloss.props.updateGlossField(text); + }); + } + + await updateGloss(mockText); + expect(note.props.disabled).toBeTruthy(); + expect(audio.props.disabled).toBeTruthy(); + expect(del.props.disabled).toBeTruthy(); + + await updateGloss(mockGloss); + expect(note.props.disabled).toBeFalsy(); + expect(audio.props.disabled).toBeFalsy(); + expect(del.props.disabled).toBeFalsy(); + }); + it("updates if changed", async () => { await renderWithWord(mockWord); testHandle = testHandle.findByType(GlossWithSuggestions); @@ -120,7 +178,7 @@ describe("ExistingEntry", () => { await updateGlossAndBlur(mockGloss); expect(mockUpdateGloss).toHaveBeenCalledTimes(0); await updateGlossAndBlur(mockText); - expect(mockUpdateGloss).toBeCalledWith(0, mockText); + expect(mockUpdateGloss).toHaveBeenCalledWith(0, mockText); }); }); @@ -131,7 +189,7 @@ describe("ExistingEntry", () => { await act(async () => { testHandle.props.updateText(mockText); }); - expect(mockUpdateNote).toBeCalledWith(0, mockText); + expect(mockUpdateNote).toHaveBeenCalledWith(0, mockText); }); }); }); diff --git a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx index baf6075ee7..067d47be7b 100644 --- a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx @@ -151,7 +151,6 @@ describe("DataEntryTable", () => { it("creates word when new entry has vernacular", async () => { expect(mockCreateWord).not.toHaveBeenCalled(); testHandle = testRenderer.root.findByType(NewEntry); - expect(testHandle).not.toBeNull; // Set newVern but not newGloss. await act(async () => testHandle.props.setNewVern("hasVern")); testHandle = testRenderer.root.findByProps({ id: exitButtonId }); @@ -161,7 +160,6 @@ describe("DataEntryTable", () => { it("doesn't create word when new entry has no vernacular", async () => { testHandle = testRenderer.root.findByType(NewEntry); - expect(testHandle).not.toBeNull; // Set newGloss but not newVern. await act(async () => testHandle.props.setNewGloss("hasGloss")); testHandle = testRenderer.root.findByProps({ id: exitButtonId }); @@ -231,8 +229,8 @@ describe("DataEntryTable", () => { describe("makeSemDomCurrent", () => { it("adds timestamp and the current user", () => { - expect(mockSemDom.created).toBeUndefined; - expect(mockSemDom.userId).toBeUndefined; + expect(mockSemDom.created).toBeUndefined(); + expect(mockSemDom.userId).toBeUndefined(); const currentDom = makeSemDomCurrent(mockSemDom); expect(currentDom.created).not.toBeUndefined(); diff --git a/src/components/DataEntry/tests/index.test.tsx b/src/components/DataEntry/tests/index.test.tsx index a459dc7ef9..0e272443c8 100644 --- a/src/components/DataEntry/tests/index.test.tsx +++ b/src/components/DataEntry/tests/index.test.tsx @@ -58,13 +58,13 @@ describe("DataEntry", () => { it("displays TreeView when state says the tree is open", async () => { await renderDataEntry({ currentDomain: mockDomain, open: true }); const dialog = testHandle.root.findByProps({ id: treeViewDialogId }); - expect(dialog.props.open).toBeTruthy; + expect(dialog.props.open).toBeTruthy(); }); it("doesn't displays TreeView when state says the tree is closed", async () => { await renderDataEntry({ currentDomain: mockDomain, open: false }); const dialog = testHandle.root.findByProps({ id: treeViewDialogId }); - expect(dialog.props.open).toBeFalsy; + expect(dialog.props.open).toBeFalsy(); }); it("dispatches to open the tree", async () => { diff --git a/src/components/GoalTimeline/tests/GoalRedux.test.tsx b/src/components/GoalTimeline/tests/GoalRedux.test.tsx index 906e4b1438..0a62d9bde8 100644 --- a/src/components/GoalTimeline/tests/GoalRedux.test.tsx +++ b/src/components/GoalTimeline/tests/GoalRedux.test.tsx @@ -33,7 +33,7 @@ import { GoalStatus, GoalType } from "types/goals"; import { Path } from "types/path"; import { newUser } from "types/user"; import * as goalUtilities from "utilities/goalUtilities"; -import { renderWithProviders } from "utilities/testUtilities"; +import { renderWithProviders } from "utilities/testingLibraryUtilities"; jest.mock("backend", () => ({ addGoalToUserEdit: (...args: any[]) => mockAddGoalToUserEdit(...args), diff --git a/src/components/PasswordReset/tests/ResetPage.test.tsx b/src/components/PasswordReset/tests/ResetPage.test.tsx index 85468addb8..ae7dd7bed7 100644 --- a/src/components/PasswordReset/tests/ResetPage.test.tsx +++ b/src/components/PasswordReset/tests/ResetPage.test.tsx @@ -178,6 +178,6 @@ describe("PasswordReset", () => { expect(screen.queryAllByTestId(id)).toHaveLength(0); } // The textId will show up as text because t() is mocked to return its input. - expect(screen.queryAllByText("passwordReset.invalidURL")).toBeTruthy; + expect(screen.queryAllByText("passwordReset.invalidURL")).toBeTruthy(); }); }); diff --git a/src/components/ProjectScreen/tests/ChooseProject.test.tsx b/src/components/ProjectScreen/tests/ChooseProject.test.tsx index c65824877a..1c9a838d7f 100644 --- a/src/components/ProjectScreen/tests/ChooseProject.test.tsx +++ b/src/components/ProjectScreen/tests/ChooseProject.test.tsx @@ -3,9 +3,10 @@ import renderer from "react-test-renderer"; import "tests/reactI18nextMock"; -import { Project } from "api/models"; +import { type Project } from "api/models"; import ChooseProject from "components/ProjectScreen/ChooseProject"; import { newProject } from "types/project"; +import { testInstanceHasText } from "utilities/testRendererUtilities"; import { randomIntString } from "utilities/utilities"; jest.mock("backend", () => ({ @@ -27,13 +28,6 @@ const mockProj = (name: string): Project => ({ let testRenderer: renderer.ReactTestRenderer; -const hasText = (item: renderer.ReactTestInstance, text: string): boolean => { - const found = item.findAll( - (node) => node.children.length === 1 && node.children[0] === text - ); - return found.length !== 0; -}; - it("renders with projects in alphabetical order", async () => { const unordered = ["In the middle", "should be last", "alphabetically first"]; mockGetProjects.mockResolvedValue(unordered.map((name) => mockProj(name))); @@ -42,10 +36,10 @@ it("renders with projects in alphabetical order", async () => { }); const items = testRenderer.root.findAllByType(ListItemButton); expect(items).toHaveLength(unordered.length); - expect(hasText(items[0], unordered[0])).toBeFalsy; - expect(hasText(items[1], unordered[1])).toBeFalsy; - expect(hasText(items[2], unordered[2])).toBeFalsy; - expect(hasText(items[0], unordered[2])).toBeTruthy; - expect(hasText(items[1], unordered[0])).toBeTruthy; - expect(hasText(items[2], unordered[1])).toBeTruthy; + expect(testInstanceHasText(items[0], unordered[0])).toBeFalsy(); + expect(testInstanceHasText(items[1], unordered[1])).toBeFalsy(); + expect(testInstanceHasText(items[2], unordered[2])).toBeFalsy(); + expect(testInstanceHasText(items[0], unordered[2])).toBeTruthy(); + expect(testInstanceHasText(items[1], unordered[0])).toBeTruthy(); + expect(testInstanceHasText(items[2], unordered[1])).toBeTruthy(); }); diff --git a/src/components/ProjectSettings/tests/ProjectImport.test.tsx b/src/components/ProjectSettings/tests/ProjectImport.test.tsx index 6288e1185a..aef6b9050c 100644 --- a/src/components/ProjectSettings/tests/ProjectImport.test.tsx +++ b/src/components/ProjectSettings/tests/ProjectImport.test.tsx @@ -27,7 +27,7 @@ const renderImport = async (): Promise => { describe("ProjectImport", () => { it("upload button disabled when no file selected", async () => { await renderImport(); - expect(uploadButton.props.disabled).toBeTruthy; + expect(uploadButton.props.disabled).toBeTruthy(); }); it("upload button enabled when file selected", async () => { @@ -35,6 +35,6 @@ describe("ProjectImport", () => { const selectButton = testRenderer.root.findByType(FileInputButton); const mockFile = { name: "name-of-a.file" } as File; await renderer.act(async () => selectButton.props.updateFile(mockFile)); - expect(uploadButton.props.disabled).toBeFalsy; + expect(uploadButton.props.disabled).toBeFalsy(); }); }); diff --git a/src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx b/src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx index 9a42c9368b..ac4cade32e 100644 --- a/src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx +++ b/src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx @@ -44,6 +44,6 @@ describe("ProjectSpeakersList", () => { expect(testRenderer.root.findAllByType(SpeakerListItem)).toHaveLength( mockSpeakers.length ); - expect(testRenderer.root.findByType(AddSpeakerListItem)).toBeTruthy; + expect(testRenderer.root.findByType(AddSpeakerListItem)).toBeTruthy(); }); }); diff --git a/src/components/ProjectUsers/tests/SortOptions.test.tsx b/src/components/ProjectUsers/tests/SortOptions.test.tsx index 2ca50cf237..38d41b7531 100644 --- a/src/components/ProjectUsers/tests/SortOptions.test.tsx +++ b/src/components/ProjectUsers/tests/SortOptions.test.tsx @@ -36,7 +36,6 @@ describe("SortOptions", () => { const mockReverse = jest.fn(); renderSortOptions({ onReverseClick: mockReverse }); const button = testRenderer.root.findByProps({ id: reverseButtonId }); - expect(button).not.toBeNull(); button.props.onClick(); expect(mockReverse).toHaveBeenCalledTimes(1); }); diff --git a/src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx b/src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx index 64ab376633..13ae4e0646 100644 --- a/src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx +++ b/src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx @@ -66,9 +66,9 @@ describe("SpeakerConsentListItemIcon", () => { ...mockSpeaker, consent: ConsentType.None, }); - expect(screen.queryByTestId(ListItemIconId.AddConsent)).not.toBeNull; - expect(screen.queryByTestId(ListItemIconId.PlayAudio)).toBeNull; - expect(screen.queryByTestId(ListItemIconId.ShowImage)).toBeNull; + expect(screen.queryByTestId(ListItemIconId.AddConsent)).not.toBeNull(); + expect(screen.queryByTestId(ListItemIconId.PlayAudio)).toBeNull(); + expect(screen.queryByTestId(ListItemIconId.ShowImage)).toBeNull(); }); it("opens menu when clicked", async () => { @@ -77,14 +77,14 @@ describe("SpeakerConsentListItemIcon", () => { ...mockSpeaker, consent: ConsentType.None, }); - expect(screen.queryByRole("menu")).toBeNull; - expect(screen.queryByTestId(ListItemIconId.RecordAudio)).toBeNull; - expect(screen.queryByTestId(ListItemIconId.UploadAudio)).toBeNull; + expect(screen.queryByRole("menu")).toBeNull(); + expect(screen.queryByTestId(ListItemIconId.RecordAudio)).toBeNull(); + expect(screen.queryByTestId(ListItemIconId.UploadAudio)).toBeNull(); await agent.click(screen.getByRole("button")); - expect(screen.queryByRole("menu")).not.toBeNull; - expect(screen.queryByTestId(ListItemIconId.RecordAudio)).not.toBeNull; - expect(screen.queryByTestId(ListItemIconId.UploadAudio)).not.toBeNull; + expect(screen.queryByRole("menu")).not.toBeNull(); + expect(screen.queryByTestId(ListItemIconId.RecordAudio)).not.toBeNull(); + expect(screen.queryByTestId(ListItemIconId.UploadAudio)).not.toBeNull(); }); }); @@ -94,9 +94,9 @@ describe("SpeakerConsentListItemIcon", () => { ...mockSpeaker, consent: ConsentType.Audio, }); - expect(screen.queryByTestId(ListItemIconId.AddConsent)).toBeNull; - expect(screen.queryByTestId(ListItemIconId.PlayAudio)).not.toBeNull; - expect(screen.queryByTestId(ListItemIconId.ShowImage)).toBeNull; + expect(screen.queryByTestId(ListItemIconId.AddConsent)).toBeNull(); + expect(screen.queryByTestId(ListItemIconId.PlayAudio)).not.toBeNull(); + expect(screen.queryByTestId(ListItemIconId.ShowImage)).toBeNull(); }); }); @@ -106,9 +106,9 @@ describe("SpeakerConsentListItemIcon", () => { ...mockSpeaker, consent: ConsentType.Image, }); - expect(screen.queryByTestId(ListItemIconId.AddConsent)).toBeNull; - expect(screen.queryByTestId(ListItemIconId.PlayAudio)).toBeNull; - expect(screen.queryByTestId(ListItemIconId.ShowImage)).not.toBeNull; + expect(screen.queryByTestId(ListItemIconId.AddConsent)).toBeNull(); + expect(screen.queryByTestId(ListItemIconId.PlayAudio)).toBeNull(); + expect(screen.queryByTestId(ListItemIconId.ShowImage)).not.toBeNull(); }); it("opens dialog when clicked", async () => { @@ -117,10 +117,10 @@ describe("SpeakerConsentListItemIcon", () => { ...mockSpeaker, consent: ConsentType.Image, }); - expect(screen.queryAllByRole("dialog")).toBeNull; + expect(screen.queryByRole("dialog")).toBeNull(); await agent.click(screen.getByRole("button")); - expect(screen.queryAllByRole("dialog")).not.toBeNull; + expect(screen.queryByRole("dialog")).not.toBeNull(); }); }); }); diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index 8222b8410f..74a2ff3a9f 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -10,7 +10,6 @@ import { Tooltip, } from "@mui/material"; import { - CSSProperties, MouseEvent, ReactElement, TouchEvent, @@ -32,11 +31,11 @@ import { import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; -import { themeColors } from "types/theme"; interface PlayerProps { audio: Pronunciation; deleteAudio?: (fileName: string) => void; + disabled?: boolean; onClick?: () => void; pronunciationUrl?: string; size?: "large" | "medium" | "small"; @@ -44,8 +43,6 @@ interface PlayerProps { warningTextId?: string; } -const iconStyle: CSSProperties = { color: themeColors.success }; - export default function AudioPlayer(props: PlayerProps): ReactElement { const isPlaying = useAppSelector( (state: StoreState) => @@ -178,6 +175,22 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { ); } + const icon = isPlaying ? ( + + props.disabled ? t.palette.grey[400] : t.palette.success.main, + }} + /> + ) : ( + + props.disabled ? t.palette.grey[400] : t.palette.success.main, + }} + /> + ); + return ( <> - {isPlaying ? : } + {icon} - {isPlaying ? : } + {icon} {canChangeSpeaker && ( void; @@ -50,6 +51,7 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { return ( {!props.playerOnly && !!props.uploadAudio && ( - + )} {audioButtons} @@ -60,6 +66,7 @@ function propsAreEqual( return false; } return ( + prev.disabled === next.disabled && prev.wordId === next.wordId && JSON.stringify(prev.audio) === JSON.stringify(next.audio) ); diff --git a/src/components/Pronunciations/RecorderIcon.tsx b/src/components/Pronunciations/RecorderIcon.tsx index 263c6dab7f..58526d3edb 100644 --- a/src/components/Pronunciations/RecorderIcon.tsx +++ b/src/components/Pronunciations/RecorderIcon.tsx @@ -16,6 +16,7 @@ export const recordButtonId = "recordingButton"; export const recordIconId = "recordingIcon"; interface RecorderIconProps { + disabled?: boolean; id: string; startRecording: () => void; stopRecording: () => void; @@ -61,6 +62,7 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement { + props.disabled + ? t.palette.grey[400] + : isRecording + ? themeColors.recordActive + : themeColors.recordIdle, }} /> diff --git a/src/components/Pronunciations/tests/AudioRecorder.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx index 546bca627f..82128d60a0 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -85,7 +85,7 @@ describe("Pronunciations", () => { ); }); const icon = testRenderer.root.findByProps({ id: recordIconId }); - expect(icon.props.sx.color).toEqual(themeColors.recordIdle); + expect(icon.props.sx.color({})).toEqual(themeColors.recordIdle); }); test("style depends on pronunciations state", () => { @@ -103,6 +103,6 @@ describe("Pronunciations", () => { ); }); const icon = testRenderer.root.findByProps({ id: recordIconId }); - expect(icon.props.sx.color).toEqual(themeColors.recordActive); + expect(icon.props.sx.color({})).toEqual(themeColors.recordActive); }); }); diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx index 0404a736a4..2ca7dc4820 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx @@ -1,10 +1,5 @@ import { Provider } from "react-redux"; -import { - ReactTestInstance, - ReactTestRenderer, - act, - create, -} from "react-test-renderer"; +import { type ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; @@ -17,7 +12,8 @@ import { } from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace"; import CharacterReplaceDialog from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog"; import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreState } from "types"; +import { type StoreState } from "types"; +import { testInstanceHasText } from "utilities/testRendererUtilities"; // Dialog uses portals, which are not supported in react-test-renderer. jest.mock("@mui/material", () => { @@ -66,13 +62,6 @@ async function renderCharacterDetail(): Promise { }); } -const hasText = (item: ReactTestInstance, text: string): boolean => { - const found = item.findAll( - (node) => node.children.length === 1 && node.children[0] === text - ); - return found.length !== 0; -}; - beforeEach(async () => { jest.resetAllMocks(); await renderCharacterDetail(); @@ -80,7 +69,7 @@ beforeEach(async () => { describe("CharacterDetail", () => { it("renders with example word", () => { - expect(hasText(charMaster.root, mockPrefix)).toBeTruthy(); + expect(testInstanceHasText(charMaster.root, mockPrefix)).toBeTruthy(); }); describe("FindAndReplace", () => { diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts index 527856e4ee..68fe773b77 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts @@ -10,8 +10,10 @@ import { } from "api/models"; import { type MergeData, + type MergeTreeReference, type MergeTreeSense, type MergeTreeWord, + type Sidebar, convertSenseToMergeTreeSense, convertWordToMergeTreeWord, defaultSidebar, @@ -31,12 +33,14 @@ const mergeDuplicatesSlice = createSlice({ clearMergeWordsAction: (state) => { state.mergeWords = []; }, + clearTreeAction: () => { return defaultState; }, + combineSenseAction: (state, action) => { - const srcRef = action.payload.src; - const destRef = action.payload.dest; + const srcRef: MergeTreeReference = action.payload.src; + const destRef: MergeTreeReference = action.payload.dest; // Ignore dropping a sense (or one of its sub-senses) into itself. if (srcRef.mergeSenseId !== destRef.mergeSenseId) { @@ -45,13 +49,19 @@ const mergeDuplicatesSlice = createSlice({ const srcGuids = words[srcWordId].sensesGuids[srcRef.mergeSenseId]; const destGuids: string[] = []; if (srcRef.order === undefined || srcGuids.length === 1) { + // A sense from a word dropped into another sense. destGuids.push(...srcGuids); delete words[srcWordId].sensesGuids[srcRef.mergeSenseId]; if (!Object.keys(words[srcWordId].sensesGuids).length) { delete words[srcWordId]; } } else { + // A sense from the sidebar dropped into another sense. destGuids.push(srcGuids.splice(srcRef.order, 1)[0]); + if (srcGuids.length < 2) { + // If not multiple senses in the sidebar, reset the sidebar. + state.tree.sidebar = defaultSidebar; + } } words[destRef.wordId].sensesGuids[destRef.mergeSenseId].push( @@ -60,39 +70,40 @@ const mergeDuplicatesSlice = createSlice({ state.tree.words = words; } }, + deleteSenseAction: (state, action) => { - const srcRef = action.payload; + const srcRef: MergeTreeReference = action.payload; const srcWordId = srcRef.wordId; const words = state.tree.words; const sensesGuids = words[srcWordId].sensesGuids; - if (srcRef.order !== undefined) { - sensesGuids[srcRef.mergeSenseId].splice(srcRef.order, 1); - if (!sensesGuids[srcRef.mergeSenseId].length) { - delete sensesGuids[srcRef.mergeSenseId]; - } - } else { + const srcGuids = sensesGuids[srcRef.mergeSenseId]; + if (srcRef.order === undefined || srcGuids.length === 1) { + // A sense deleted from a word. delete sensesGuids[srcRef.mergeSenseId]; - } - if (!Object.keys(words[srcWordId].sensesGuids).length) { - delete words[srcWordId]; - } + if (!Object.keys(sensesGuids).length) { + delete words[srcWordId]; + } - const sidebar = state.tree.sidebar; - // If the sense is being deleted from the words column - // and the sense is also shown in the sidebar, - // then reset the sidebar. - if ( - sidebar.wordId === srcRef.wordId && - sidebar.mergeSenseId === srcRef.mergeSenseId && - srcRef.order === undefined - ) { - state.tree.sidebar = defaultSidebar; + // If the deleted sense was open in the sidebar, reset the sidebar. + const { mergeSenseId, wordId } = state.tree.sidebar; + if (mergeSenseId === srcRef.mergeSenseId && wordId === srcRef.wordId) { + state.tree.sidebar = defaultSidebar; + } + } else { + // A sense deleted from the sidebar. + srcGuids.splice(srcRef.order, 1); + if (srcGuids.length < 2) { + // If not multiple senses in the sidebar, reset the sidebar. + state.tree.sidebar = defaultSidebar; + } } }, + flagWordAction: (state, action) => { state.tree.words[action.payload.wordId].flag = action.payload.flag; }, + getMergeWordsAction: (state) => { // Handle words with all senses deleted. const possibleWords = Object.values(state.data.words); @@ -125,18 +136,21 @@ const mergeDuplicatesSlice = createSlice({ } } }, + moveSenseAction: (state, action) => { - const srcWordId = action.payload.src.wordId; const destWordId = action.payload.destWordId; - const srcOrder = action.payload.src.order; - if (srcOrder === undefined && srcWordId !== destWordId) { - const mergeSenseId = action.payload.src.mergeSenseId; + const srcRef: MergeTreeReference = action.payload.src; + const srcWordId = srcRef.wordId; + // Verify that this is a valid movement of a word sense. + if (srcRef.order === undefined && srcWordId !== destWordId) { + const mergeSenseId = srcRef.mergeSenseId; const words = state.tree.words; // Check if dropping the sense into a new word. if (words[destWordId] === undefined) { if (Object.keys(words[srcWordId].sensesGuids).length === 1) { + // Don't do anything if the sense was alone in its word. return; } words[destWordId] = newMergeTreeWord(); @@ -146,9 +160,7 @@ const mergeDuplicatesSlice = createSlice({ const guids = words[srcWordId].sensesGuids[mergeSenseId]; const sensesPairs = Object.entries(words[destWordId].sensesGuids); sensesPairs.splice(action.payload.destOrder, 0, [mergeSenseId, guids]); - const newSensesGuids: Hash = {}; - sensesPairs.forEach(([key, value]) => (newSensesGuids[key] = value)); - words[destWordId].sensesGuids = newSensesGuids; + words[destWordId].sensesGuids = Object.fromEntries(sensesPairs); // Cleanup the srcWord. delete words[srcWordId].sensesGuids[mergeSenseId]; @@ -157,50 +169,36 @@ const mergeDuplicatesSlice = createSlice({ } } }, + moveDuplicateAction: (state, action) => { - const srcRef = action.payload.src; - // Verify that the ref.order field is defined - if (srcRef.order !== undefined) { + const srcRef: MergeTreeReference = action.payload.src; + const words = state.tree.words; + const srcGuids = words[srcRef.wordId].sensesGuids[srcRef.mergeSenseId]; + // Verify that this is a valid movement of a sidebar sense. + if (srcRef.order !== undefined && srcGuids.length > 1) { const destWordId = action.payload.destWordId; - const words = state.tree.words; - - const srcWordId = srcRef.wordId; - let mergeSenseId = srcRef.mergeSenseId; // Get guid of sense being restored from the sidebar. - const srcGuids = words[srcWordId].sensesGuids[mergeSenseId]; const guid = srcGuids.splice(srcRef.order, 1)[0]; + if (srcGuids.length < 2) { + // If not multiple senses in the sidebar, reset the sidebar. + state.tree.sidebar = defaultSidebar; + } // Check if dropping the sense into a new word. if (words[destWordId] === undefined) { words[destWordId] = newMergeTreeWord(); } - if (srcGuids.length === 0) { - // If there are no guids left, this is a full move. - if (srcWordId === destWordId) { - return; - } - delete words[srcWordId].sensesGuids[mergeSenseId]; - if (!Object.keys(words[srcWordId].sensesGuids).length) { - delete words[srcWordId]; - } - } else { - // Otherwise, create a new sense in the destWord. - mergeSenseId = v4(); - } - // Update the destWord. const sensesPairs = Object.entries(words[destWordId].sensesGuids); - sensesPairs.splice(action.payload.destOrder, 0, [mergeSenseId, [guid]]); - const newSensesGuids: Hash = {}; - sensesPairs.forEach(([key, value]) => (newSensesGuids[key] = value)); - words[destWordId].sensesGuids = newSensesGuids; + sensesPairs.splice(action.payload.destOrder, 0, [v4(), [guid]]); + words[destWordId].sensesGuids = Object.fromEntries(sensesPairs); } }, - orderDuplicateAction: (state, action) => { - const ref = action.payload.src; + orderDuplicateAction: (state, action) => { + const ref: MergeTreeReference = action.payload.src; const oldOrder = ref.order; const newOrder = action.payload.destOrder; @@ -218,13 +216,15 @@ const mergeDuplicatesSlice = createSlice({ state.tree.words[ref.wordId].sensesGuids = sensesGuids; } }, + orderSenseAction: (state, action) => { - const word = state.tree.words[action.payload.src.wordId]; + const ref: MergeTreeReference = action.payload.src; + const word = state.tree.words[ref.wordId]; // Convert the Hash to an array to expose the order. const sensePairs = Object.entries(word.sensesGuids); - const mergeSenseId = action.payload.src.mergeSenseId; + const mergeSenseId = ref.mergeSenseId; const oldOrder = sensePairs.findIndex((p) => p[0] === mergeSenseId); const newOrder = action.payload.destOrder; @@ -240,12 +240,17 @@ const mergeDuplicatesSlice = createSlice({ word.sensesGuids[key] = value; } - state.tree.words[action.payload.src.wordId] = word; + state.tree.words[ref.wordId] = word; } }, + setSidebarAction: (state, action) => { - state.tree.sidebar = action.payload; + const sidebar: Sidebar = action.payload; + // Only open sidebar with multiple senses. + state.tree.sidebar = + sidebar.mergeSenses.length > 1 ? sidebar : defaultSidebar; }, + setDataAction: (state, action) => { if (action.payload.length === 0) { state = defaultState; @@ -265,6 +270,7 @@ const mergeDuplicatesSlice = createSlice({ state.mergeWords = []; } }, + setVernacularAction: (state, action) => { state.tree.words[action.payload.wordId].vern = action.payload.vern; }, diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx index e283c7d6f9..69c6e6ebfa 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx @@ -4,6 +4,7 @@ import { type MergeTreeReference, type MergeTreeWord, convertSenseToMergeTreeSense, + defaultSidebar, defaultTree, newMergeTreeWord, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; @@ -37,7 +38,8 @@ jest.mock("uuid"); const mockUuid = require("uuid") as { v4: jest.Mock }; let uuidIndex = 0; -// getMockUuid(false) gives the next uuid to be assigned by our mocked v4. +/** When `increment` (default `true`) is set to `false`, + * returns the next uuid to be assigned by our mocked `v4`. */ function getMockUuid(increment = true): string { const uuid = `mockUuid${uuidIndex}`; if (increment) { @@ -85,209 +87,205 @@ describe("MergeDupsReducer", () => { word2: newMergeTreeWord("senses:A01", { word2_senseA: ["word2_senseA_0", "word2_senseA_1"], }), - word3: newMergeTreeWord("senses:A0B01", { + word3: newMergeTreeWord("senses:A0B012", { word3_senseA: ["word3_senseA_0"], - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], + word3_senseB: ["word3_senseB_0", "word3_senseB_1", "word3_senseB_2"], }), }; } + /** MergeTreeState with open sidebar */ const mockState: MergeTreeState = { ...defaultState, - tree: { ...defaultTree, words: testTreeWords() }, + tree: { + ...defaultTree, + sidebar: { + ...defaultSidebar, + mergeSenseId: "word2_senseA", + wordId: "word2", + }, + words: testTreeWords(), + }, }; - function checkTreeWords( + /** Check whether the action correctly updates the tree words. + * Also, whether the sidebar was closed by the action (default: `false`). */ + function checkTree( action: Action | PayloadAction, - expected: Hash + expectedWords: Hash, + sidebarClosed = false ): void { - const result = mergeDupStepReducer(mockState, action).tree.words; - // We have to stringify for this test, - // because the order of the .sensesGuids matters. - expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + const { sidebar, words } = mergeDupStepReducer(mockState, action).tree; + expect(!sidebar.wordId).toEqual(sidebarClosed); + // Stringify for this test, because order within `.sensesGuids` matters. + expect(JSON.stringify(words)).toEqual(JSON.stringify(expectedWords)); } describe("combineSense", () => { - it("combine sense from sidebar into other sense", () => { + it("combine sense from 2-sense sidebar into other sense; closes sidebar", () => { const srcWordId = "word2"; + const srcSenseId = `${srcWordId}_senseA`; const srcRef: MergeTreeReference = { wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, + mergeSenseId: srcSenseId, order: 0, }; const destWordId = "word1"; + const destSenseId = `${destWordId}_senseA`; const destRef: MergeTreeReference = { wordId: destWordId, - mergeSenseId: `${destWordId}_senseA`, + mergeSenseId: destSenseId, }; const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); - expectedWords[srcWordId].sensesGuids = { - word2_senseA: ["word2_senseA_1"], - }; - expectedWords[destWordId].sensesGuids = { - word1_senseA: ["word1_senseA_0", "word2_senseA_0"], - }; + // Sidebar sense _0 moved so _1 remains. + expectedWords[srcWordId].sensesGuids[srcSenseId] = [`${srcSenseId}_1`]; + expectedWords[destWordId].sensesGuids[destSenseId] = [ + `${destSenseId}_0`, + `${srcSenseId}_0`, + ]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords, true); }); - it("combine last sense from sidebar into other sense", () => { + it("combine sense from 3-sense sidebar into other sense", () => { const srcWordId = "word3"; + const srcSenseId = `${srcWordId}_senseB`; const srcRef: MergeTreeReference = { wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, - order: 0, + mergeSenseId: srcSenseId, + order: 1, }; const destWordId = "word1"; + const destSenseId = `${destWordId}_senseA`; const destRef: MergeTreeReference = { wordId: destWordId, - mergeSenseId: `${destWordId}_senseA`, + mergeSenseId: destSenseId, }; const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); - expectedWords[destWordId].sensesGuids = { - word1_senseA: ["word1_senseA_0", "word3_senseA_0"], - }; - expectedWords[srcWordId].sensesGuids = { - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], - }; - - checkTreeWords(testAction, expectedWords); - }); - - it("combine last sidebar sense from a word's last sense into other word's sense", () => { - const srcWordId = "word1"; - const srcRef: MergeTreeReference = { - wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, - order: 0, - }; - - const destWordId = "word3"; - const destRef: MergeTreeReference = { - wordId: destWordId, - mergeSenseId: `${destWordId}_senseA`, - }; - - const testAction = combineSense({ src: srcRef, dest: destRef }); - - const expectedWords = testTreeWords(); - delete expectedWords[srcWordId]; - expectedWords[destWordId].sensesGuids = { - word3_senseA: ["word3_senseA_0", "word1_senseA_0"], - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], - }; + // Sidebar sense _1 moved so _0, _2 remain. + expectedWords[srcWordId].sensesGuids[srcSenseId] = [ + `${srcSenseId}_0`, + `${srcSenseId}_2`, + ]; + expectedWords[destWordId].sensesGuids[destSenseId] = [ + `${destSenseId}_0`, + `${srcSenseId}_1`, + ]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); it("combine sense into other sense in same word", () => { const wordId = "word3"; - const srcRef: MergeTreeReference = { - wordId, - mergeSenseId: `${wordId}_senseB`, - }; - const destRef: MergeTreeReference = { - wordId, - mergeSenseId: `${wordId}_senseA`, - }; + const srcSenseId = `${wordId}_senseB`; + const srcRef: MergeTreeReference = { wordId, mergeSenseId: srcSenseId }; + const destSenseId = `${wordId}_senseA`; + const destRef: MergeTreeReference = { wordId, mergeSenseId: destSenseId }; const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); + // The word now has a single combined sense instead of two senses. expectedWords[wordId].sensesGuids = { - word3_senseA: ["word3_senseA_0", "word3_senseB_0", "word3_senseB_1"], + [destSenseId]: [ + `${destSenseId}_0`, + `${srcSenseId}_0`, + `${srcSenseId}_1`, + `${srcSenseId}_2`, + ], }; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); - it("combine sense into other sense in different word", () => { + it("combine sense into other sense in other word", () => { const srcWordId = "word3"; + const srcSenseId = `${srcWordId}_senseA`; const srcRef: MergeTreeReference = { wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, + mergeSenseId: srcSenseId, }; const destWordId = "word1"; + const destSenseId = `${destWordId}_senseA`; const destRef: MergeTreeReference = { wordId: destWordId, - mergeSenseId: `${destWordId}_senseA`, + mergeSenseId: destSenseId, }; const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); expectedWords[srcWordId].sensesGuids = { - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], + // _senseA moved so _senseB remains. + word3_senseB: ["word3_senseB_0", "word3_senseB_1", "word3_senseB_2"], }; expectedWords[destWordId].sensesGuids = { - word1_senseA: ["word1_senseA_0", "word3_senseA_0"], + [destSenseId]: [`${destSenseId}_0`, `${srcSenseId}_0`], }; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); - it("combine last sense into other sense in different word", () => { + it("combines last sense into other sense in other word", () => { const srcWordId = "word1"; + const srcSenseId = `${srcWordId}_senseA`; const srcRef: MergeTreeReference = { wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, + mergeSenseId: srcSenseId, }; const destWordId = "word3"; + const destSenseId = `${destWordId}_senseA`; const destRef: MergeTreeReference = { wordId: destWordId, - mergeSenseId: `${destWordId}_senseA`, + mergeSenseId: destSenseId, }; const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); delete expectedWords[srcWordId]; - expectedWords[destWordId].sensesGuids = { - word3_senseA: ["word3_senseA_0", "word1_senseA_0"], - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], - }; + expectedWords[destWordId].sensesGuids[destSenseId] = [ + `${destSenseId}_0`, + `${srcSenseId}_0`, + ]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); }); describe("deleteSense", () => { it("deletes one-sense sense from a word with multiple senses", () => { const wordId = "word3"; - const testRef: MergeTreeReference = { - wordId, - mergeSenseId: `${wordId}_senseA`, - }; + const mergeSenseId = `${wordId}_senseA`; + const testRef: MergeTreeReference = { wordId, mergeSenseId }; const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); - delete expectedWords[wordId].sensesGuids[testRef.mergeSenseId]; + delete expectedWords[wordId].sensesGuids[mergeSenseId]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); it("deletes multi-sense sense from a word with multiple senses", () => { const wordId = "word3"; - const testRef: MergeTreeReference = { - wordId, - mergeSenseId: `${wordId}_senseB`, - }; + const mergeSenseId = `${wordId}_senseB`; + const testRef: MergeTreeReference = { wordId, mergeSenseId }; const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); - delete expectedWords[wordId].sensesGuids[testRef.mergeSenseId]; + delete expectedWords[wordId].sensesGuids[mergeSenseId]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); it("deletes word when deleting final sense", () => { @@ -302,39 +300,39 @@ describe("MergeDupsReducer", () => { const expectedWords = testTreeWords(); delete expectedWords[wordId]; - checkTreeWords(testAction, expectedWords); + // Also closes sidebar, since deleted sense was open in the sidebar. + checkTree(testAction, expectedWords, true); }); - it("deletes a sense from the sidebar", () => { + it("deletes sense from 2-sense sidebar; closes sidebar", () => { const wordId = "word2"; - const testRef: MergeTreeReference = { - wordId, - mergeSenseId: `${wordId}_senseA`, - order: 0, - }; + const mergeSenseId = `${wordId}_senseA`; + const testRef: MergeTreeReference = { wordId, mergeSenseId, order: 0 }; const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); - expectedWords[wordId].sensesGuids = { word2_senseA: ["word2_senseA_1"] }; + // Sidebar sense _0 removed so _1 remains. + expectedWords[wordId].sensesGuids[mergeSenseId] = ["word2_senseA_1"]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords, true); }); - it("delete last sidebar sense from a word's last sense", () => { - const srcWordId = "word1"; - const srcRef: MergeTreeReference = { - wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, - order: 0, - }; + it("deletes sense from 3-sense sidebar", () => { + const wordId = "word3"; + const mergeSenseId = `${wordId}_senseB`; + const testRef: MergeTreeReference = { wordId, mergeSenseId, order: 2 }; - const testAction = deleteSense(srcRef); + const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); - delete expectedWords[srcWordId]; + // Sidebar sense _2 removed so _0, _1 remain. + expectedWords[wordId].sensesGuids[mergeSenseId] = [ + `${mergeSenseId}_0`, + `${mergeSenseId}_1`, + ]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); }); @@ -347,12 +345,12 @@ describe("MergeDupsReducer", () => { const expectedWords = testTreeWords(); expectedWords[wordId].flag = testFlag; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); }); describe("getMergeWords", () => { - it("sense moved from one word to another", () => { + it("moves sense from one word to another", () => { const store = setupStore(mergeTwoWordsScenario.initialState()); store.dispatch(getMergeWords()); const mergeArray = store.getState().mergeDuplicateGoal.mergeWords; @@ -369,7 +367,7 @@ describe("MergeDupsReducer", () => { expect(defs).toEqual(expectedResult[0].defs); }); - it("sense from one word combined with sense in another", () => { + it("combines sense from one word with sense in another", () => { const store = setupStore(mergeTwoSensesScenario.initialState()); store.dispatch(getMergeWords()); const mergeArray = store.getState().mergeDuplicateGoal.mergeWords; @@ -386,7 +384,7 @@ describe("MergeDupsReducer", () => { expect(defs).toEqual(expectedResult[0].defs); }); - it("combine senses with definitions", () => { + it("combines senses with definitions", () => { const store = setupStore(mergeTwoDefinitionsScenario.initialState()); store.dispatch(getMergeWords()); const mergeArray = store.getState().mergeDuplicateGoal.mergeWords; @@ -405,146 +403,133 @@ describe("MergeDupsReducer", () => { }); describe("moveSense", () => { - it("moves a sense out from sidebar to same word", () => { + it("moves sense from 2-sense sidebar to start of same word; closes sidebar", () => { const wordId = "word2"; - const testRef: MergeTreeReference = { - wordId, - mergeSenseId: `${wordId}_senseA`, - order: 0, - }; - const srcGuid = `${testRef.mergeSenseId}_${testRef.order}`; + const mergeSenseId = `${wordId}_senseA`; + const testRef: MergeTreeReference = { wordId, mergeSenseId, order: 0 }; + const srcGuid = `${mergeSenseId}_${testRef.order}`; // Intercept the uuid that will be assigned. const nextGuid = getMockUuid(false); const testAction = moveSense({ src: testRef, destWordId: wordId, - destOrder: 1, + destOrder: 0, }); const expectedWords = testTreeWords(); - expectedWords[wordId].sensesGuids = { word2_senseA: ["word2_senseA_1"] }; - // A new guid is used when a sense is added to a merge word, so use the intercepted - // nextGuid for the new sense expected from moving a sense out of a sidebar. - expectedWords[wordId].sensesGuids[nextGuid] = [srcGuid]; + expectedWords[wordId].sensesGuids = { + // A new guid is used when a sense is added to a merge word, so use the intercepted + // nextGuid for the new sense expected from moving a sense out of a sidebar. + [nextGuid]: [srcGuid], + // Sidebar sense _0 moved so _1 remains. + [mergeSenseId]: ["word2_senseA_1"], + }; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords, true); }); - it("moves a sense out from sidebar to different word", () => { + it("moves sense from 2-sense sidebar to end of other word; closes sidebar", () => { const srcWordId = "word2"; + const mergeSenseId = `${srcWordId}_senseA`; const testRef: MergeTreeReference = { wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, + mergeSenseId, order: 1, }; - const srcGuid = `${testRef.mergeSenseId}_${testRef.order}`; + const srcGuid = `${mergeSenseId}_${testRef.order}`; const destWordId = "word3"; // Intercept the uuid that will be assigned. const nextGuid = getMockUuid(false); - const testAction = moveSense({ - src: testRef, - destWordId: destWordId, - destOrder: 2, - }); + const testAction = moveSense({ src: testRef, destWordId, destOrder: 2 }); const expectedWords = testTreeWords(); - expectedWords[srcWordId].sensesGuids = { - word2_senseA: ["word2_senseA_0"], - }; + // Sidebar sense _1 moved so _0 remains. + expectedWords[srcWordId].sensesGuids[mergeSenseId] = ["word2_senseA_0"]; // A new guid is used when a sense is added to a merge word, so use the intercepted // nextGuid for the new sense expected from moving a sense out of a sidebar. expectedWords[destWordId].sensesGuids[nextGuid] = [srcGuid]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords, true); }); - it("moves last sense out from sidebar to different word", () => { + it("moves sense from 3-sense sidebar to new word", () => { const srcWordId = "word3"; + const mergeSenseId = `${srcWordId}_senseB`; const testRef: MergeTreeReference = { wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, + mergeSenseId, order: 0, }; + const srcGuid = `${mergeSenseId}_${testRef.order}`; - const destWordId = "word1"; + const destWordId = "new-word-id"; - const testAction = moveSense({ - src: testRef, - destWordId: destWordId, - destOrder: 1, - }); + // Intercept the uuid that will be assigned. + const nextGuid = getMockUuid(false); + const testAction = moveSense({ src: testRef, destWordId, destOrder: 0 }); const expectedWords = testTreeWords(); - expectedWords[srcWordId].sensesGuids = { - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], - }; - expectedWords[destWordId].sensesGuids = { - word1_senseA: ["word1_senseA_0"], - word3_senseA: ["word3_senseA_0"], - }; + // Sidebar sense _0 moved so _1, _2 remain. + expectedWords[srcWordId].sensesGuids[mergeSenseId] = [ + `${mergeSenseId}_1`, + `${mergeSenseId}_2`, + ]; + expectedWords[destWordId] = newMergeTreeWord(); + // A new guid is used when a sense is added to a merge word, so use the intercepted + // nextGuid for the new sense expected from moving a sense out of a sidebar. + expectedWords[destWordId].sensesGuids[nextGuid] = [srcGuid]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); - it("moves a sense to a different word", () => { + it("moves sense to end of other word", () => { const srcWordId = "word3"; - const testRef: MergeTreeReference = { - wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseB`, - }; + const mergeSenseId = `${srcWordId}_senseB`; + const testRef: MergeTreeReference = { wordId: srcWordId, mergeSenseId }; const destWordId = "word1"; - const testAction = moveSense({ - src: testRef, - destWordId: destWordId, - destOrder: 1, - }); + const testAction = moveSense({ src: testRef, destWordId, destOrder: 1 }); const expectedWords = testTreeWords(); expectedWords[srcWordId].sensesGuids = { + // _senseB moved so _senseA remains. word3_senseA: ["word3_senseA_0"], }; expectedWords[destWordId].sensesGuids = { word1_senseA: ["word1_senseA_0"], - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], + [mergeSenseId]: ["word3_senseB_0", "word3_senseB_1", "word3_senseB_2"], }; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); - it("moves last sense to a different word", () => { + it("moves last sense to start of other word", () => { const srcWordId = "word1"; - const testRef: MergeTreeReference = { - wordId: srcWordId, - mergeSenseId: `${srcWordId}_senseA`, - }; + const mergeSenseId = `${srcWordId}_senseA`; + const testRef: MergeTreeReference = { wordId: srcWordId, mergeSenseId }; const destWordId = "word2"; - const testAction = moveSense({ - src: testRef, - destWordId: destWordId, - destOrder: 1, - }); + const testAction = moveSense({ src: testRef, destWordId, destOrder: 0 }); expect(testAction.type).toEqual("mergeDupStepReducer/moveSenseAction"); const expectedWords = testTreeWords(); delete expectedWords[srcWordId]; expectedWords[destWordId].sensesGuids = { + [mergeSenseId]: ["word1_senseA_0"], word2_senseA: ["word2_senseA_0", "word2_senseA_1"], - word1_senseA: ["word1_senseA_0"], }; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); }); describe("orderSense", () => { - it("order sidebar sense", () => { + it("orders sidebar sense", () => { const wordId = "word2"; const mergeSenseId = `${wordId}_senseA`; const testRef: MergeTreeReference = { wordId, mergeSenseId, order: 0 }; @@ -557,10 +542,10 @@ describe("MergeDupsReducer", () => { "word2_senseA_0", ]; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); - it("order word sense", () => { + it("orders word sense", () => { const wordId = "word3"; const mergeSenseId = `${wordId}_senseA`; const testRef: MergeTreeReference = { wordId, mergeSenseId }; @@ -569,11 +554,11 @@ describe("MergeDupsReducer", () => { const expectedWords = testTreeWords(); expectedWords[wordId].sensesGuids = { - word3_senseB: ["word3_senseB_0", "word3_senseB_1"], + word3_senseB: ["word3_senseB_0", "word3_senseB_1", "word3_senseB_2"], word3_senseA: ["word3_senseA_0"], }; - checkTreeWords(testAction, expectedWords); + checkTree(testAction, expectedWords); }); }); diff --git a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx index 9380aae120..a078a11340 100644 --- a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx +++ b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx @@ -217,7 +217,7 @@ describe("ReviewEntriesActions", () => { // Verify the replacement word in state has the audio removed const words = store.getState().reviewEntriesState.words; - expect(words.find((w) => w.id === wordId)).toBeNull; + expect(words.find((w) => w.id === wordId)).toBeUndefined(); const wordInState = words.find((w) => w.id === newId); expect(wordInState?.audio).toHaveLength(0); }); @@ -247,7 +247,7 @@ describe("ReviewEntriesActions", () => { // Verify the replacement word in state has the updated speaker id const words = store.getState().reviewEntriesState.words; - expect(words.find((w) => w.id === wordId)).toBeNull; + expect(words.find((w) => w.id === wordId)).toBeUndefined(); const audioInState = words.find((w) => w.id === newId)?.audio; expect(audioInState).toHaveLength(1); expect(audioInState![0].speakerId).toEqual(speakerId); @@ -276,7 +276,7 @@ describe("ReviewEntriesActions", () => { // Verify the replacement word in state has the audio added const words = store.getState().reviewEntriesState.words; - expect(words.find((w) => w.id === wordId)).toBeNull; + expect(words.find((w) => w.id === wordId)).toBeUndefined(); const audioInState = words.find((w) => w.id === newId)?.audio; expect(audioInState).toHaveLength(1); expect(audioInState![0].fileName).toEqual(pro.fileName); diff --git a/src/utilities/testRendererUtilities.tsx b/src/utilities/testRendererUtilities.tsx new file mode 100644 index 0000000000..7bdf639ca7 --- /dev/null +++ b/src/utilities/testRendererUtilities.tsx @@ -0,0 +1,13 @@ +import { type ReactTestInstance } from "react-test-renderer"; + +/** Checks if any node in the given `react-test-renderer` instance has the given text. */ +export function testInstanceHasText( + instance: ReactTestInstance, + text: string +): boolean { + return ( + instance.findAll( + (node) => node.children.length === 1 && node.children[0] === text + ).length > 0 + ); +} diff --git a/src/utilities/testUtilities.tsx b/src/utilities/testingLibraryUtilities.tsx similarity index 77% rename from src/utilities/testUtilities.tsx rename to src/utilities/testingLibraryUtilities.tsx index 0f7020dcec..3eb1d7f7aa 100644 --- a/src/utilities/testUtilities.tsx +++ b/src/utilities/testingLibraryUtilities.tsx @@ -7,18 +7,16 @@ import { PersistGate } from "redux-persist/integration/react"; import { defaultState } from "components/App/DefaultState"; import { type AppStore, type RootState, persistor, setupStore } from "store"; -// These test utilities are leveraged from the Redux documentation for Writing Tests: -// https://redux.js.org/usage/writing-tests -// Specifically, see the section on "Integration Testing Connected Components -// and Redux Logic" - -// This type interface extends the default options for render from RTL, as well -// as allows the user to specify other things such as initialState, store. +/** This extends the default options for `render` from `@testing-library/react`, + * allowing the user to specify other things such as `initialState`, `store`. */ interface ExtendedRenderOptions extends Omit { preloadedState?: PreloadedState; store?: AppStore; } +/** This test utility is leveraged from the Redux documentation for Writing Tests: + * https://redux.js.org/usage/writing-tests. Specifically, see the section on + * "Integration Testing Connected Components and Redux Logic" */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function renderWithProviders( ui: ReactElement, diff --git a/src/utilities/tests/dictionaryLoader.test.ts b/src/utilities/tests/dictionaryLoader.test.ts index 2ba97d462f..9d84027bc3 100644 --- a/src/utilities/tests/dictionaryLoader.test.ts +++ b/src/utilities/tests/dictionaryLoader.test.ts @@ -57,8 +57,8 @@ describe("DictionaryLoader", () => { it("loads nothing for empty or non-existent key", async () => { const loader = new DictionaryLoader(bcp47); - expect(await loader.loadDictPart("")).toBeUndefined; - expect(await loader.loadDictPart("not-a-key")).toBeUndefined; + expect(await loader.loadDictPart("")).toBeUndefined(); + expect(await loader.loadDictPart("not-a-key")).toBeUndefined(); }); it("doesn't load the same part more than once", async () => {