From f64dc636c503abac12af67e495bd92a8e9751ae1 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 3 Sep 2026 12:10:28 -0500 Subject: [PATCH 1/6] Add unit tests for pending upload retry gaps functionality - Introduced a new test file for validating the behavior of pending upload retries, focusing on the restoration of section resources and linking to media files and workflow steps. - Implemented tests to ensure correct linking of section resources to sections, media files, and organization workflow steps during the restoration process. - Enhanced test coverage for linking passages when restore metadata includes passage IDs, ensuring accurate functionality in various scenarios. - Established a memory source setup for testing, including necessary records and relationships to simulate real-world conditions for pending uploads. --- .../upload/pendingUploadRetryGaps.test.ts | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts diff --git a/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts new file mode 100644 index 00000000..57bdb4a0 --- /dev/null +++ b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import { RecordSchema } from '@orbit/records'; +import MemorySource from '@orbit/memory'; +import { related } from '../../crud/related'; +import { restoreAfterPendingUpload } from './restoreAfterPendingUpload'; +import type { PendingUploadRestore } from './pendingMediaUploads'; +import { + getRecordingForClause, + getCompletedClauseIndices, +} from '../../components/PassageDetail/carefulSpeech/carefulSpeechCompletion'; +import { IRegion } from '../../crud/useWavesurferRegions'; +import { IRow } from '../../context/PassageDetailContext'; + +const clauseRegion: IRegion = { start: 0, end: 10, label: '' }; + +const schema = new RecordSchema({ + models: { + user: { + attributes: {}, + relationships: { + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + section: { + attributes: { + name: { type: 'string' }, + dateUpdated: { type: 'string' }, + }, + relationships: { + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + passage: { + attributes: {}, + relationships: { + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + orgworkflowstep: { + attributes: {}, + relationships: { + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + artifacttype: { + attributes: { typename: { type: 'string' } }, + relationships: { + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + artifactcategory: { + attributes: { categoryname: { type: 'string' } }, + relationships: { + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + mediafile: { + attributes: { + sourceSegments: { type: 'string' }, + originalFile: { type: 'string' }, + versionNumber: { type: 'number' }, + dateCreated: { type: 'string' }, + dateUpdated: { type: 'string' }, + }, + relationships: { + artifactType: { kind: 'hasOne', type: 'artifacttype' }, + sourceMedia: { kind: 'hasOne', type: 'mediafile' }, + passage: { kind: 'hasOne', type: 'passage' }, + artifactCategory: { kind: 'hasOne', type: 'artifactcategory' }, + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + sectionresource: { + attributes: { + sequenceNum: { type: 'number' }, + description: { type: 'string' }, + dateCreated: { type: 'string' }, + dateUpdated: { type: 'string' }, + }, + relationships: { + section: { kind: 'hasOne', type: 'section' }, + mediafile: { kind: 'hasOne', type: 'mediafile' }, + passage: { kind: 'hasOne', type: 'passage' }, + orgWorkflowStep: { kind: 'hasOne', type: 'orgworkflowstep' }, + lastModifiedByUser: { kind: 'hasOne', type: 'user' }, + }, + }, + }, +}); + +function makeLwcRow( + overrides: Partial & { + id: string; + sourceMediaId?: string; + sourceSegments?: string; + } +): IRow { + const { + id, + sourceMediaId, + sourceSegments = JSON.stringify({ start: 0, end: 10 }), + ...rest + } = overrides; + return { + id, + artifactType: 'LWC translation', + sourceVersion: 1, + mediafile: { + id: `${id}-mf`, + type: 'mediafile', + attributes: { sourceSegments, versionNumber: 1 }, + relationships: { + artifactType: { data: { id: 'lwc-art', type: 'artifacttype' } }, + ...(sourceMediaId + ? { sourceMedia: { data: { id: sourceMediaId, type: 'mediafile' } } } + : {}), + }, + } as IRow['mediafile'], + ...rest, + } as IRow; +} + +describe('pending upload retry gaps (TT-7363 reopen)', () => { + let memory: MemorySource; + const user = 'user-1'; + + beforeEach(async () => { + memory = new MemorySource({ schema }); + await memory.update((t) => [ + t.addRecord({ type: 'user', id: user, attributes: {} }), + t.addRecord({ + type: 'section', + id: 'sec-1', + attributes: { name: 'Section 1' }, + }), + t.addRecord({ type: 'passage', id: 'pas-1', attributes: {} }), + t.addRecord({ type: 'orgworkflowstep', id: 'ows-1', attributes: {} }), + t.addRecord({ + type: 'artifacttype', + id: 'res-art', + attributes: { typename: 'resource' }, + }), + t.addRecord({ + type: 'artifacttype', + id: 'lwc-art', + attributes: { typename: 'backtranslation' }, + }), + t.addRecord({ + type: 'mediafile', + id: 'vern-1', + attributes: { versionNumber: 1 }, + relationships: { + passage: { data: { type: 'passage', id: 'pas-1' } }, + }, + }), + t.addRecord({ + type: 'mediafile', + id: 'resource-media-1', + attributes: { + originalFile: 'resource.mp3', + versionNumber: 1, + }, + relationships: { + artifactType: { data: { type: 'artifacttype', id: 'res-art' } }, + passage: { data: { type: 'passage', id: 'pas-1' } }, + }, + }), + t.addRecord({ + type: 'mediafile', + id: 'lwc-media-1', + attributes: { + sourceSegments: JSON.stringify({ start: 0, end: 10 }), + versionNumber: 1, + }, + relationships: { + artifactType: { data: { type: 'artifacttype', id: 'lwc-art' } }, + passage: { data: { type: 'passage', id: 'pas-1' } }, + }, + }), + ]); + }); + + describe('Resource — sectionresource secondary link', () => { + it('creates sectionresource linked to section, mediafile, and org workflow step', async () => { + const restore = { + kind: 'sectionresource', + sectionId: 'sec-1', + description: 'My resource recording', + sequenceNum: 1, + orgWorkflowStepId: 'ows-1', + } as unknown as PendingUploadRestore; + + await restoreAfterPendingUpload({ + mediaId: 'resource-media-1', + restore, + memory, + user, + }); + + const sectionResources = memory.cache.query((q) => + q.findRecords('sectionresource') + ) as unknown as Array<{ + attributes?: { description?: string }; + }>; + expect(sectionResources).toHaveLength(1); + expect(related(sectionResources[0], 'section')).toBe('sec-1'); + expect(related(sectionResources[0], 'mediafile')).toBe('resource-media-1'); + expect(related(sectionResources[0], 'orgWorkflowStep')).toBe('ows-1'); + expect(sectionResources[0].attributes?.description).toBe( + 'My resource recording' + ); + }); + + it('links passage when restore meta includes passageId', async () => { + const restore = { + kind: 'sectionresource', + sectionId: 'sec-1', + description: 'Passage resource', + sequenceNum: 2, + orgWorkflowStepId: 'ows-1', + passageId: 'pas-1', + } as unknown as PendingUploadRestore; + + await restoreAfterPendingUpload({ + mediaId: 'resource-media-1', + restore, + memory, + user, + }); + + const sectionResources = memory.cache.query((q) => + q.findRecords('sectionresource') + ) as unknown as Array>; + expect(sectionResources).toHaveLength(1); + expect(related(sectionResources[0], 'passage')).toBe('pas-1'); + }); + }); + + describe('LWC Audio Translation — sourceMedia secondary link', () => { + it('relinks sourceMedia on the pulled mediafile from pending restore meta', async () => { + const restore = { + kind: 'sourceMedia', + sourceMediaId: 'vern-1', + } as unknown as PendingUploadRestore; + + await restoreAfterPendingUpload({ + mediaId: 'lwc-media-1', + restore, + memory, + user, + }); + + const media = memory.cache.getRecordSync({ + type: 'mediafile', + id: 'lwc-media-1', + }); + expect(related(media, 'sourceMedia')).toBe('vern-1'); + }); + + it('shows the LWC clause as recorded after sourceMedia is restored', async () => { + const restore = { + kind: 'sourceMedia', + sourceMediaId: 'vern-1', + } as unknown as PendingUploadRestore; + + await restoreAfterPendingUpload({ + mediaId: 'lwc-media-1', + restore, + memory, + user, + }); + + const media = memory.cache.getRecordSync({ + type: 'mediafile', + id: 'lwc-media-1', + }); + const row = makeLwcRow({ + id: 'lwc-row', + sourceMediaId: related(media, 'sourceMedia') ?? undefined, + sourceSegments: media?.attributes?.sourceSegments as string, + }); + + const completed = getCompletedClauseIndices( + [clauseRegion], + [row], + 'lwc-art', + 1, + 'vern-1' + ); + expect(completed.has(0)).toBe(true); + expect( + getRecordingForClause( + [row], + 'lwc-art', + 1, + clauseRegion, + 'vern-1' + )?.id + ).toBe('lwc-row'); + }); + }); +}); From 18899a2ec3ce7cbf95d7cb30b6a19f1a6ce0d494 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 3 Sep 2026 12:12:10 -0500 Subject: [PATCH 2/6] TT-7363b Enhance Passage components with pending restore functionality - Added `pendingRestore` prop to `PassageRecordDlg`, `Uploader`, `LwcTranslationControls`, and `PassageDetail` components to support restoration of media uploads. - Implemented `resourcePendingRestore` logic in `PassageDetailArtifacts` and `PassageDetailArtifactsMobile` to manage section resource restoration. - Updated `pendingMediaUploads` type definitions to include new `sectionresource` kind for better handling of pending uploads. - Enhanced `restoreAfterPendingUpload` function to process `sectionresource` restoration, ensuring correct linking to sections and media files. - Improved unit tests for pending upload scenarios to validate new restoration logic and ensure reliability. --- .../PassageDetailArtifacts.tsx | 24 ++- .../PassageDetailsArtifactsMobile.tsx | 24 ++- .../PassageDetailLwcTranslation.tsx | 9 + .../lwcTranslation/LwcTranslationControls.tsx | 3 + .../src/components/PassageRecordDlg.tsx | 3 + src/renderer/src/components/Uploader.tsx | 1 + .../src/store/upload/pendingMediaUploads.ts | 14 ++ .../upload/pendingUploadRetryGaps.test.ts | 16 +- .../store/upload/restoreAfterPendingUpload.ts | 155 ++++++++++++++++++ 9 files changed, 237 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx index cec26f95..26c19528 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx @@ -1,4 +1,4 @@ -import { useState, useContext, useMemo, useRef, useEffect } from 'react'; +import { useState, useContext, useMemo, useRef, useEffect, useCallback } from 'react'; import { useGetGlobal, useGlobal } from '../../../context/useGlobal'; import { IPassageDetailArtifactsStrings, @@ -147,7 +147,7 @@ export function PassageDetailArtifacts() { sharedResource, } = usePassageDetailContext(); const { getOrganizedBy } = useOrganizedBy(); - const { AddSectionResource } = useSecResCreate(section); + const { AddSectionResource, InternalizationStep } = useSecResCreate(section); const AddSectionResourceUser = useSecResUserCreate(); const ReadSectionResourceUser = useSecResUserRead(); const RemoveSectionResourceUser = useSecResUserDelete(); @@ -193,6 +193,7 @@ export function PassageDetailArtifacts() { const editCatCommitRef = useRef<(() => Promise) | null>(null); const addCatCommitRef = useRef<(() => Promise) | null>(null); const descriptionRef = useRef(''); + const pendingResourceSeqRef = useRef(0); const resourceTypeRef = useRef( ResourceTypeEnum.sectionResource @@ -272,6 +273,23 @@ export function PassageDetailArtifacts() { return resourceType?.id; }, [artifactTypes, offlineOnly]); + const resourcePendingRestore = useCallback(() => { + if (isProjectResource()) return undefined; + const step = InternalizationStep(); + if (!step?.id) return undefined; + pendingResourceSeqRef.current += 1; + return { + kind: 'sectionresource' as const, + sectionId: section.id, + description: descriptionRef.current || null, + sequenceNum: rowData.length + pendingResourceSeqRef.current, + orgWorkflowStepId: step.id, + ...(isPassageResource() ? { passageId: passage.id } : {}), + ...(catIdRef.current ? { artifactCategoryId: catIdRef.current } : {}), + ...(descriptionRef.current ? { topic: descriptionRef.current } : {}), + }; + }, [InternalizationStep, section.id, passage.id, rowData.length]); + const handlePlay = (id: string) => { if (id === playItem) { setItemPlaying(!itemPlaying); @@ -977,6 +995,7 @@ export function PassageDetailArtifacts() { multiple={true} finish={afterUpload} beforeUpload={async () => { + pendingResourceSeqRef.current = 0; if (addCatCommitRef.current) catIdRef.current = await addCatCommitRef.current(); }} @@ -991,6 +1010,7 @@ export function PassageDetailArtifacts() { inValue={markdownValue} eafUrl={aiGenerated ? AIGenerated : ''} defaultFilename={filename} + pendingRestore={resourcePendingRestore} metaData={ Promise) | null>(null); const addCatCommitRef = useRef<(() => Promise) | null>(null); const descriptionRef = useRef(''); + const pendingResourceSeqRef = useRef(0); const resourceTypeRef = useRef( ResourceTypeEnum.sectionResource @@ -256,6 +257,23 @@ export function PassageDetailArtifactsMobile() { return resourceType?.id; }, [artifactTypes, offlineOnly]); + const resourcePendingRestore = useCallback(() => { + if (isProjectResource()) return undefined; + const step = InternalizationStep(); + if (!step?.id) return undefined; + pendingResourceSeqRef.current += 1; + return { + kind: 'sectionresource' as const, + sectionId: section.id, + description: descriptionRef.current || null, + sequenceNum: rowData.length + pendingResourceSeqRef.current, + orgWorkflowStepId: step.id, + ...(isPassageResource() ? { passageId: passage.id } : {}), + ...(catIdRef.current ? { artifactCategoryId: catIdRef.current } : {}), + ...(descriptionRef.current ? { topic: descriptionRef.current } : {}), + }; + }, [InternalizationStep, section.id, passage.id, rowData.length]); + const handlePlay = (id: string) => { if (id === playItem) { setItemPlaying(!itemPlaying); @@ -956,6 +974,7 @@ export function PassageDetailArtifactsMobile() { multiple={true} finish={afterUpload} beforeUpload={async () => { + pendingResourceSeqRef.current = 0; if (addCatCommitRef.current) catIdRef.current = await addCatCommitRef.current(); }} @@ -970,6 +989,7 @@ export function PassageDetailArtifactsMobile() { inValue={markdownValue} eafUrl={aiGenerated ? AIGenerated : ''} defaultFilename={filename} + pendingRestore={resourcePendingRestore} metaData={ + mediafileId + ? ({ kind: 'sourceMedia' as const, sourceMediaId: mediafileId }) + : undefined, + [mediafileId] + ); + const handleClearRecording = useCallback(async () => { // Deleting the take retires the failed save with it, so the message and the // latch must both go (TT-7583). @@ -652,6 +660,7 @@ export function PassageDetailLwcTranslation({ width }: IProps) { return next; }); }} + pendingRestore={lwcPendingRestore} setStatusText={() => {}} showRecorder={showRecorder} /> diff --git a/src/renderer/src/components/PassageDetail/lwcTranslation/LwcTranslationControls.tsx b/src/renderer/src/components/PassageDetail/lwcTranslation/LwcTranslationControls.tsx index 1c8b3960..b7e870a9 100644 --- a/src/renderer/src/components/PassageDetail/lwcTranslation/LwcTranslationControls.tsx +++ b/src/renderer/src/components/PassageDetail/lwcTranslation/LwcTranslationControls.tsx @@ -52,6 +52,7 @@ interface Props { setCanSave: (v: boolean) => void; /** Passed through to MediaRecord; see its prop docs (TT-7583). */ onSaveRejected?: () => void; + pendingRestore?: import('../../../store/upload/pendingMediaUploads').PendingRestoreInput; setStatusText: (t: string) => void; showRecorder: boolean; } @@ -82,6 +83,7 @@ export default function LwcTranslationControls({ setResetMedia, setCanSave, onSaveRejected, + pendingRestore, setStatusText, showRecorder, }: Props) { @@ -147,6 +149,7 @@ export default function LwcTranslationControls({ onReady={onSaveSettled} setCanSave={setCanSave} onSaveRejected={onSaveRejected} + pendingRestore={pendingRestore} setStatusText={setStatusText} doReset={resetMedia} setDoReset={setResetMedia} diff --git a/src/renderer/src/components/PassageRecordDlg.tsx b/src/renderer/src/components/PassageRecordDlg.tsx index 26f8f492..e3eefc3c 100644 --- a/src/renderer/src/components/PassageRecordDlg.tsx +++ b/src/renderer/src/components/PassageRecordDlg.tsx @@ -93,6 +93,7 @@ interface IProps { inValue?: string | undefined; onNonAudio?: ((nonAudio: boolean) => void) | undefined; audioOnly?: boolean | undefined; + pendingRestore?: import('../store/upload/pendingMediaUploads').PendingRestoreInput; } function PassageRecordDlg(props: IProps) { @@ -118,6 +119,7 @@ function PassageRecordDlg(props: IProps) { inValue, onNonAudio, audioOnly, + pendingRestore, } = props; const resourceStrings: IPassageDetailArtifactsStrings = useSelector( resourceSelector, @@ -286,6 +288,7 @@ function PassageRecordDlg(props: IProps) { allowNoNoise={true} allowDeltaVoice={true} onRecording={setRecording} + pendingRestore={pendingRestore} /> {metaData} diff --git a/src/renderer/src/components/Uploader.tsx b/src/renderer/src/components/Uploader.tsx index 45b588e1..58ae7ead 100644 --- a/src/renderer/src/components/Uploader.tsx +++ b/src/renderer/src/components/Uploader.tsx @@ -510,6 +510,7 @@ export const Uploader = (props: IProps) => { inValue={inValue} onNonAudio={onNonAudio} audioOnly={audioOnly} + pendingRestore={pendingRestore} /> )} {!audioUploadOrRecord && !hasImport && ( diff --git a/src/renderer/src/store/upload/pendingMediaUploads.ts b/src/renderer/src/store/upload/pendingMediaUploads.ts index 541462cf..19b0011f 100644 --- a/src/renderer/src/store/upload/pendingMediaUploads.ts +++ b/src/renderer/src/store/upload/pendingMediaUploads.ts @@ -40,6 +40,20 @@ export type PendingUploadRestore = | { kind: 'title'; sectionId: string; + } + | { + kind: 'sectionresource'; + sectionId: string; + description: string | null; + sequenceNum: number; + orgWorkflowStepId: string; + passageId?: string; + artifactCategoryId?: string; + topic?: string; + } + | { + kind: 'sourceMedia'; + sourceMediaId: string; }; export type PendingRestoreInput = diff --git a/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts index 57bdb4a0..f5306b65 100644 --- a/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts +++ b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts @@ -182,13 +182,13 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { describe('Resource — sectionresource secondary link', () => { it('creates sectionresource linked to section, mediafile, and org workflow step', async () => { - const restore = { + const restore: PendingUploadRestore = { kind: 'sectionresource', sectionId: 'sec-1', description: 'My resource recording', sequenceNum: 1, orgWorkflowStepId: 'ows-1', - } as unknown as PendingUploadRestore; + }; await restoreAfterPendingUpload({ mediaId: 'resource-media-1', @@ -212,14 +212,14 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { }); it('links passage when restore meta includes passageId', async () => { - const restore = { + const restore: PendingUploadRestore = { kind: 'sectionresource', sectionId: 'sec-1', description: 'Passage resource', sequenceNum: 2, orgWorkflowStepId: 'ows-1', passageId: 'pas-1', - } as unknown as PendingUploadRestore; + }; await restoreAfterPendingUpload({ mediaId: 'resource-media-1', @@ -238,10 +238,10 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { describe('LWC Audio Translation — sourceMedia secondary link', () => { it('relinks sourceMedia on the pulled mediafile from pending restore meta', async () => { - const restore = { + const restore: PendingUploadRestore = { kind: 'sourceMedia', sourceMediaId: 'vern-1', - } as unknown as PendingUploadRestore; + }; await restoreAfterPendingUpload({ mediaId: 'lwc-media-1', @@ -258,10 +258,10 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { }); it('shows the LWC clause as recorded after sourceMedia is restored', async () => { - const restore = { + const restore: PendingUploadRestore = { kind: 'sourceMedia', sourceMediaId: 'vern-1', - } as unknown as PendingUploadRestore; + }; await restoreAfterPendingUpload({ mediaId: 'lwc-media-1', diff --git a/src/renderer/src/store/upload/restoreAfterPendingUpload.ts b/src/renderer/src/store/upload/restoreAfterPendingUpload.ts index d0f4611e..e640519a 100644 --- a/src/renderer/src/store/upload/restoreAfterPendingUpload.ts +++ b/src/renderer/src/store/upload/restoreAfterPendingUpload.ts @@ -21,6 +21,7 @@ import { IntellectualProperty, MediaFileD, SectionD, + SectionResource, } from '../../model'; import type { PendingUploadRestore } from './pendingMediaUploads'; @@ -71,6 +72,22 @@ export async function restoreAfterPendingUpload({ user, }); return; + case 'sectionresource': + await restoreSectionResource({ + mediaId: localMediaId, + restore, + memory, + user, + }); + return; + case 'sourceMedia': + await restoreSourceMedia({ + mediaId: localMediaId, + restore, + memory, + user, + }); + return; default: return; } @@ -228,3 +245,141 @@ async function restoreTitle({ UpdateRelatedRecord(t, secRec, 'titleMediafile', 'mediafile', mediaId, user) ); } + +async function restoreSectionResource({ + mediaId, + restore, + memory, + user, +}: { + mediaId: string; + restore: Extract; + memory: Memory; + user: string; +}): Promise { + const mediaRecId = { type: 'mediafile', id: mediaId }; + const mediaRec = findRecord(memory, 'mediafile', mediaId) as + | MediaFileD + | undefined; + + if (restore.topic && mediaRec) { + await memory.update((t) => + UpdateRecord( + t, + { + ...mediaRec, + attributes: { ...mediaRec.attributes, topic: restore.topic }, + } as MediaFileD, + user + ) + ); + } + if (restore.artifactCategoryId) { + const t = new RecordTransformBuilder(); + await memory.update([ + ...ReplaceRelatedRecord( + t, + mediaRecId, + 'artifactCategory', + 'artifactcategory', + restore.artifactCategoryId + ), + ]); + } + if (restore.passageId) { + const t = new RecordTransformBuilder(); + await memory.update([ + ...ReplaceRelatedRecord( + t, + mediaRecId, + 'passage', + 'passage', + restore.passageId + ), + ]); + } + + const secRes = { + type: 'sectionresource', + attributes: { + sequenceNum: restore.sequenceNum, + description: restore.description ?? '', + }, + } as SectionResource & UninitializedRecord; + + const t = new RecordTransformBuilder(); + const ops = [ + ...AddRecord(t, secRes, user, memory), + ...ReplaceRelatedRecord( + t, + secRes as RecordIdentity, + 'section', + 'section', + restore.sectionId + ), + ...ReplaceRelatedRecord( + t, + secRes as RecordIdentity, + 'mediafile', + 'mediafile', + mediaId + ), + ...ReplaceRelatedRecord( + t, + secRes as RecordIdentity, + 'orgWorkflowStep', + 'orgworkflowstep', + restore.orgWorkflowStepId + ), + ]; + if (restore.passageId) { + ops.push( + ...ReplaceRelatedRecord( + t, + secRes as RecordIdentity, + 'passage', + 'passage', + restore.passageId + ) + ); + } + await memory.update(ops); +} + +async function restoreSourceMedia({ + mediaId, + restore, + memory, + user, +}: { + mediaId: string; + restore: Extract; + memory: Memory; + user: string; +}): Promise { + const mediaRec = findRecord(memory, 'mediafile', mediaId) as + | MediaFileD + | undefined; + if (!mediaRec) return; + + const localSourceId = + (memory?.keyMap + ? remoteIdGuid( + 'mediafile', + restore.sourceMediaId, + memory.keyMap as RecordKeyMap + ) + : undefined) ?? restore.sourceMediaId; + + const t = new RecordTransformBuilder(); + await memory.update( + UpdateRelatedRecord( + t, + mediaRec, + 'sourceMedia', + 'mediafile', + localSourceId, + user + ) + ); +} From 5a8def804343d39583279796bc7f856ac056e63c Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 3 Sep 2026 12:12:21 -0500 Subject: [PATCH 3/6] Enhance pending upload restoration logic to avoid duplicate sequence numbers - Added a new test case to validate that the restoration process avoids duplicate sequence numbers when a section already has that sequence. - Updated the `restoreAfterPendingUpload` function to ensure that if a sequence number is already in use, the next available sequence number is assigned. - Improved unit tests for pending upload scenarios to ensure the reliability of the restoration logic and correct linking of section resources. --- .../upload/pendingUploadRetryGaps.test.ts | 72 +++++++++++++++++++ .../store/upload/restoreAfterPendingUpload.ts | 15 +++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts index f5306b65..c4243391 100644 --- a/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts +++ b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts @@ -234,6 +234,78 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { expect(sectionResources).toHaveLength(1); expect(related(sectionResources[0], 'passage')).toBe('pas-1'); }); + + /** + * Stale sequenceNum: pending meta freezes rowData.length+n at stage time, + * while successful siblings (batch compact or later adds) already occupy it. + * Restore must pick a free sequence for the section. + */ + it('avoids duplicate sequenceNum when section already has that sequence', async () => { + await memory.update((t) => [ + t.addRecord({ + type: 'mediafile', + id: 'resource-media-existing', + attributes: { + originalFile: 'existing.mp3', + versionNumber: 1, + }, + relationships: { + artifactType: { data: { type: 'artifacttype', id: 'res-art' } }, + }, + }), + t.addRecord({ + type: 'sectionresource', + id: 'sr-existing', + attributes: { + sequenceNum: 1, + description: 'Already linked after compact upload', + }, + relationships: { + section: { data: { type: 'section', id: 'sec-1' } }, + mediafile: { + data: { type: 'mediafile', id: 'resource-media-existing' }, + }, + orgWorkflowStep: { + data: { type: 'orgworkflowstep', id: 'ows-1' }, + }, + }, + }), + ]); + + const restore: PendingUploadRestore = { + kind: 'sectionresource', + sectionId: 'sec-1', + description: 'Retried pending resource', + // Stale: captured when rowData was empty / first in failed batch + sequenceNum: 1, + orgWorkflowStepId: 'ows-1', + }; + + await restoreAfterPendingUpload({ + mediaId: 'resource-media-1', + restore, + memory, + user, + }); + + const sectionResources = ( + memory.cache.query((q) => q.findRecords('sectionresource')) as Array<{ + id: string; + attributes?: { sequenceNum?: number; description?: string }; + }> + ).filter((r) => related(r, 'section') === 'sec-1'); + + expect(sectionResources).toHaveLength(2); + const seqs = sectionResources.map((r) => r.attributes?.sequenceNum); + expect(new Set(seqs).size).toBe(2); + const restored = sectionResources.find( + (r) => related(r, 'mediafile') === 'resource-media-1' + ); + expect(restored?.attributes?.sequenceNum).toBe(2); + expect(restored?.attributes?.description).toBe( + 'Retried pending resource' + ); + }); }); describe('LWC Audio Translation — sourceMedia secondary link', () => { diff --git a/src/renderer/src/store/upload/restoreAfterPendingUpload.ts b/src/renderer/src/store/upload/restoreAfterPendingUpload.ts index e640519a..8bdb7f07 100644 --- a/src/renderer/src/store/upload/restoreAfterPendingUpload.ts +++ b/src/renderer/src/store/upload/restoreAfterPendingUpload.ts @@ -299,10 +299,23 @@ async function restoreSectionResource({ ]); } + // Pending meta freezes sequence at stage time; successful siblings or later + // adds may already occupy it (batch compact / delayed retry). Prefer the + // captured value when free; otherwise take max+1 for this section. + const existingForSection = ( + memory.cache.query((q) => q.findRecords('sectionresource')) as SectionResource[] + ).filter((r) => related(r, 'section') === restore.sectionId); + const usedSeqs = existingForSection.map( + (r) => r.attributes?.sequenceNum ?? 0 + ); + const sequenceNum = usedSeqs.includes(restore.sequenceNum) + ? Math.max(...usedSeqs, 0) + 1 + : restore.sequenceNum; + const secRes = { type: 'sectionresource', attributes: { - sequenceNum: restore.sequenceNum, + sequenceNum, description: restore.description ?? '', }, } as SectionResource & UninitializedRecord; From 8ab3434ebb46e76e0b7169cb7a7c6d1a73871542 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 3 Sep 2026 12:12:22 -0500 Subject: [PATCH 4/6] Enhance media upload process with beforeUpload functionality - Introduced a new `beforeUpload` prop across `MediaRecord`, `PassageRecordDlg`, and `Uploader` components to allow committing deferred metadata before staging uploads. - Updated `useMediaUpload` hook to handle the `beforeUpload` logic, ensuring that newly created category IDs are included in the `pendingRestore` metadata. - Added unit tests to validate the behavior of `beforeUpload`, ensuring it is called prior to capturing `pendingRestore` during upload processes. - Improved overall handling of pending uploads to prevent issues with missing metadata during restoration. --- src/renderer/src/components/MediaRecord.tsx | 7 + .../src/components/PassageRecordDlg.tsx | 3 + src/renderer/src/components/Uploader.tsx | 1 + src/renderer/src/crud/useMediaUpload.test.ts | 72 ++++++- src/renderer/src/crud/useMediaUpload.ts | 187 ++++++++++-------- 5 files changed, 183 insertions(+), 87 deletions(-) diff --git a/src/renderer/src/components/MediaRecord.tsx b/src/renderer/src/components/MediaRecord.tsx index 43aa19af..30862034 100644 --- a/src/renderer/src/components/MediaRecord.tsx +++ b/src/renderer/src/components/MediaRecord.tsx @@ -53,6 +53,11 @@ interface IProps { * Domain restore metadata for pending-upload Retry (TT-7363). */ pendingRestore?: import('../store/upload/pendingMediaUploads').PendingRestoreInput; + /** + * Commit deferred metadata before the recorded upload is staged so + * `pendingRestore` can include newly created category ids. + */ + beforeUpload?: () => Promise; onReady?: (() => void) | undefined; onSaving?: (() => void) | undefined; onRecording?: ((r: boolean) => void) | undefined; @@ -141,6 +146,7 @@ function MediaRecord(props: IProps) { languagebcp47, afterUploadCb, pendingRestore, + beforeUpload, setCanSave, setCanCancel, setStatusText, @@ -360,6 +366,7 @@ function MediaRecord(props: IProps) { languagebcp47, afterUploadCb: myAfterUploadCb, pendingRestore, + beforeUpload, }); useEffect(() => { diff --git a/src/renderer/src/components/PassageRecordDlg.tsx b/src/renderer/src/components/PassageRecordDlg.tsx index e3eefc3c..7c114e82 100644 --- a/src/renderer/src/components/PassageRecordDlg.tsx +++ b/src/renderer/src/components/PassageRecordDlg.tsx @@ -94,6 +94,7 @@ interface IProps { onNonAudio?: ((nonAudio: boolean) => void) | undefined; audioOnly?: boolean | undefined; pendingRestore?: import('../store/upload/pendingMediaUploads').PendingRestoreInput; + beforeUpload?: (() => Promise) | undefined; } function PassageRecordDlg(props: IProps) { @@ -120,6 +121,7 @@ function PassageRecordDlg(props: IProps) { onNonAudio, audioOnly, pendingRestore, + beforeUpload, } = props; const resourceStrings: IPassageDetailArtifactsStrings = useSelector( resourceSelector, @@ -289,6 +291,7 @@ function PassageRecordDlg(props: IProps) { allowDeltaVoice={true} onRecording={setRecording} pendingRestore={pendingRestore} + beforeUpload={beforeUpload} /> {metaData} diff --git a/src/renderer/src/components/Uploader.tsx b/src/renderer/src/components/Uploader.tsx index 58ae7ead..0935bcec 100644 --- a/src/renderer/src/components/Uploader.tsx +++ b/src/renderer/src/components/Uploader.tsx @@ -511,6 +511,7 @@ export const Uploader = (props: IProps) => { onNonAudio={onNonAudio} audioOnly={audioOnly} pendingRestore={pendingRestore} + beforeUpload={beforeUpload} /> )} {!audioUploadOrRecord && !hasImport && ( diff --git a/src/renderer/src/crud/useMediaUpload.test.ts b/src/renderer/src/crud/useMediaUpload.test.ts index feefe236..e5370032 100644 --- a/src/renderer/src/crud/useMediaUpload.test.ts +++ b/src/renderer/src/crud/useMediaUpload.test.ts @@ -162,6 +162,8 @@ describe('useMediaUpload', () => { passageId: string | undefined; planId?: string; afterUploadCb: jest.Mock; + beforeUpload?: () => Promise; + pendingRestore?: () => unknown; }) { const { renderHook } = require('@testing-library/react'); const { useMediaUpload } = require('./useMediaUpload'); @@ -171,18 +173,29 @@ describe('useMediaUpload', () => { passageId: props.passageId, planId: props.planId, afterUploadCb: props.afterUploadCb, + beforeUpload: props.beforeUpload, + pendingRestore: props.pendingRestore, }) ); } + async function waitForNextUpload() { + const { nextUpload } = require('../store'); + for (let i = 0; i < 50; i++) { + if ((nextUpload as jest.Mock).mock.calls.length > 0) return nextUpload; + await Promise.resolve(); + } + expect(nextUpload).toHaveBeenCalled(); + return nextUpload; + } + async function completeUpload( upload: (files: File[]) => Promise, files: File[], ...cbArgs: [number, boolean, unknown?] ) { const uploadPromise = upload(files); - const { nextUpload } = require('../store'); - expect(nextUpload).toHaveBeenCalled(); + const nextUpload = await waitForNextUpload(); const uploadProps = (nextUpload as jest.Mock).mock.calls.at(-1)![0]; const cb = uploadProps.cb as ( n: number, @@ -445,8 +458,7 @@ describe('useMediaUpload', () => { }); const uploadPromise = staleUpload([makeFile()]); - const { nextUpload } = require('../store'); - expect(nextUpload).toHaveBeenCalled(); + const nextUpload = await waitForNextUpload(); const uploadProps = (nextUpload as jest.Mock).mock.calls.at(-1)![0]; expect(uploadProps.record.performedBy).toBe('Dharma'); expect(uploadProps.record.topic).toBe('Community Q1'); @@ -459,4 +471,56 @@ describe('useMediaUpload', () => { await cb(0, true, { stringId: 'media-1' }); await expect(uploadPromise).resolves.toBe(true); }); + + /** + * Recorded resource path (TT-7363 / Copilot r3918081583): SelectArtifactCategory + * defers creating a newly typed category until commit()/beforeUpload. If + * pendingRestore is snapshotted first, artifactCategoryId is omitted and Retry + * restores the resource without its category — even though afterUploadCb would + * later commit on success. Failure never reaches afterUploadCb's commit. + */ + it('commits beforeUpload before capturing pendingRestore, even when upload fails', async () => { + let catId: string | undefined; + const beforeUpload = jest.fn(async () => { + catId = 'new-cat-1'; + }); + const pendingRestore = jest.fn(() => ({ + kind: 'sectionresource' as const, + sectionId: 'sec-1', + description: 'Resource take', + sequenceNum: 1, + orgWorkflowStepId: 'ows-1', + ...(catId ? { artifactCategoryId: catId } : {}), + })); + const afterUploadCb = jest.fn().mockResolvedValue(undefined); + const { result } = renderUploadHook({ + artifactId: 'res-art', + passageId: 'psg-1', + afterUploadCb, + beforeUpload, + pendingRestore, + }); + const upload = result.current as (files: File[]) => Promise; + + const uploadPromise = upload([makeFile()]); + const nextUpload = await waitForNextUpload(); + const uploadProps = (nextUpload as jest.Mock).mock.calls.at(-1)![0]; + expect(beforeUpload).toHaveBeenCalled(); + expect(pendingRestore).toHaveBeenCalled(); + expect(beforeUpload.mock.invocationCallOrder[0]).toBeLessThan( + pendingRestore.mock.invocationCallOrder[0] + ); + expect(uploadProps.pendingRestore).toEqual( + expect.objectContaining({ artifactCategoryId: 'new-cat-1' }) + ); + + const cb = uploadProps.cb as ( + n: number, + success: boolean, + data?: unknown + ) => void | Promise; + await cb(0, false, undefined); + await expect(uploadPromise).rejects.toThrow('Upload Failed!'); + expect(afterUploadCb).toHaveBeenCalledWith(''); + }); }); diff --git a/src/renderer/src/crud/useMediaUpload.ts b/src/renderer/src/crud/useMediaUpload.ts index fddbd1da..15ba5a68 100644 --- a/src/renderer/src/crud/useMediaUpload.ts +++ b/src/renderer/src/crud/useMediaUpload.ts @@ -40,6 +40,13 @@ interface IProps { * getter so callers can capture live comment/speaker state at upload time. */ pendingRestore?: PendingRestoreInput; + /** + * Commit deferred metadata (e.g. a newly typed artifact category) before + * staging the upload and evaluating `pendingRestore`. Required on the + * recorded-audio path: `Uploader.afterUploadCb` only runs `beforeUpload` + * after a successful recording (TT-7363 Copilot r3918081583). + */ + beforeUpload?: () => Promise; } export const useMediaUpload = ({ artifactId, @@ -53,6 +60,7 @@ export const useMediaUpload = ({ afterUploadCb, pendingUploadIdToClearOnSuccess, pendingRestore, + beforeUpload, }: IProps) => { const dispatch = useDispatch(); const uploadFiles = (files: File[]) => @@ -167,93 +175,106 @@ export const useMediaUpload = ({ return (files: File[]): Promise => { if (!files.length) return Promise.resolve(false); return new Promise((resolve, reject) => { - const getPlanId = () => - planId - ? remoteIdNum('plan', planId, memory?.keyMap as RecordKeyMap) || + void (async () => { + try { + if (beforeUpload) await beforeUpload(); + const getPlanId = () => planId - : remoteIdNum( - 'plan', - getGlobal('plan'), - memory?.keyMap as RecordKeyMap - ) || getGlobal('plan'); - const getArtifactId = () => - artifactId === null - ? null - : remoteIdNum( - 'artifacttype', - artifactId, - memory?.keyMap as RecordKeyMap - ) || artifactId; - const getPassageId = () => - passageId - ? remoteIdNum('passage', passageId, memory?.keyMap as RecordKeyMap) || + ? remoteIdNum('plan', planId, memory?.keyMap as RecordKeyMap) || + planId + : remoteIdNum( + 'plan', + getGlobal('plan'), + memory?.keyMap as RecordKeyMap + ) || getGlobal('plan'); + const getArtifactId = () => + artifactId === null + ? null + : remoteIdNum( + 'artifacttype', + artifactId, + memory?.keyMap as RecordKeyMap + ) || artifactId; + const getPassageId = () => passageId - : ''; - const getUserId = () => - remoteIdNum('user', user, memory?.keyMap as RecordKeyMap) || user; - const getSourceMediaId = () => - remoteIdNum( - 'mediafile', - sourceMediaId || '', - memory?.keyMap as RecordKeyMap - ) || sourceMediaId; + ? remoteIdNum( + 'passage', + passageId, + memory?.keyMap as RecordKeyMap + ) || passageId + : ''; + const getUserId = () => + remoteIdNum('user', user, memory?.keyMap as RecordKeyMap) || user; + const getSourceMediaId = () => + remoteIdNum( + 'mediafile', + sourceMediaId || '', + memory?.keyMap as RecordKeyMap + ) || sourceMediaId; - uploadFiles(files); - fileList.current = files; + uploadFiles(files); + fileList.current = files; - const mediafile = { - planId: getPlanId(), - versionNumber: 1, - originalFile: (files[0] as File).name, - contentType: getContentType(files[0]?.type, (files[0] as File).name), - artifactTypeId: getArtifactId(), - passageId: getPassageId(), - recordedbyUserId: getUserId(), - userId: getUserId(), - sourceMediaId: getSourceMediaId(), - sourceSegments: sourceSegments ?? '{}', - performedBy: performedByRef.current ?? null, - topic: topicRef.current ?? '', - languagebcp47: languagebcp47 ?? '', - eafUrl: !artifactId - ? ts.mediaAttached - : localizedArtifactTypeFromId(artifactId), //put psc message here - } as MediaFileAttributes & { - planId: string; - artifactTypeId: string; - passageId: string; - recordedbyUserId: string; - userId: string; - sourceMediaId: string; - }; - nextUpload({ - record: mediafile, - files, - n: 0, - token: accessToken || '', - offline: getGlobal('offline'), - errorReporter: reporter, - uploadType: UploadType.Media, - cb: (n, success, data) => { - void itemComplete(n, success, data) - .then(() => { - if (success) resolve(true); - else reject(new Error(t.uploadFailed)); - }) - .catch(reject); - }, - pendingUploadIdToClearOnSuccess, - pendingRestore: - typeof pendingRestore === 'function' - ? pendingRestore() - : pendingRestore, - onTerminalFailure: (info) => { - showMessage( - formatUploadTerminalFailureMessage(t, info), - AlertSeverity.Warning - ); - }, - }); + const mediafile = { + planId: getPlanId(), + versionNumber: 1, + originalFile: (files[0] as File).name, + contentType: getContentType( + files[0]?.type, + (files[0] as File).name + ), + artifactTypeId: getArtifactId(), + passageId: getPassageId(), + recordedbyUserId: getUserId(), + userId: getUserId(), + sourceMediaId: getSourceMediaId(), + sourceSegments: sourceSegments ?? '{}', + performedBy: performedByRef.current ?? null, + topic: topicRef.current ?? '', + languagebcp47: languagebcp47 ?? '', + eafUrl: !artifactId + ? ts.mediaAttached + : localizedArtifactTypeFromId(artifactId), //put psc message here + } as MediaFileAttributes & { + planId: string; + artifactTypeId: string; + passageId: string; + recordedbyUserId: string; + userId: string; + sourceMediaId: string; + }; + nextUpload({ + record: mediafile, + files, + n: 0, + token: accessToken || '', + offline: getGlobal('offline'), + errorReporter: reporter, + uploadType: UploadType.Media, + cb: (n, success, data) => { + void itemComplete(n, success, data) + .then(() => { + if (success) resolve(true); + else reject(new Error(t.uploadFailed)); + }) + .catch(reject); + }, + pendingUploadIdToClearOnSuccess, + pendingRestore: + typeof pendingRestore === 'function' + ? pendingRestore() + : pendingRestore, + onTerminalFailure: (info) => { + showMessage( + formatUploadTerminalFailureMessage(t, info), + AlertSeverity.Warning + ); + }, + }); + } catch (err) { + reject(err); + } + })(); }); }; }; From 6cc0331adf6482296d2786e5546e829553ec591f Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 3 Sep 2026 12:56:37 -0500 Subject: [PATCH 5/6] Add buildResourcePendingRestore functionality and related tests - Introduced the `buildResourcePendingRestore` function to generate restore metadata for project and section resources, enhancing the media upload restoration process. - Created unit tests for `buildResourcePendingRestore` to validate its behavior for different resource types, ensuring correct metadata generation. - Updated `PassageDetailArtifacts` and `PassageDetailArtifactsMobile` components to utilize the new function for handling resource restoration. - Implemented `useResumePendingProjectResourceConfig` hook to manage the configuration flow after restoring general resources, queuing them for further processing. - Enhanced `pendingMediaUploads` type definitions to include the new `projectresource` kind, improving the handling of pending uploads. - Added tests for the new functionality to ensure reliability and correctness in various scenarios. --- .../PassageDetailArtifacts.tsx | 47 +++++++---- .../PassageDetailsArtifactsMobile.tsx | 47 +++++++---- .../buildResourcePendingRestore.test.ts | 55 +++++++++++++ .../buildResourcePendingRestore.ts | 53 +++++++++++++ .../useResumePendingProjectResourceConfig.ts | 43 ++++++++++ .../src/store/upload/pendingMediaUploads.ts | 10 +++ .../pendingProjectResourceConfig.test.ts | 29 +++++++ .../upload/pendingProjectResourceConfig.ts | 48 ++++++++++++ .../upload/pendingUploadRetryGaps.test.ts | 78 +++++++++++++++++-- .../store/upload/restoreAfterPendingUpload.ts | 66 ++++++++++++++-- 10 files changed, 437 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.test.ts create mode 100644 src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.ts create mode 100644 src/renderer/src/components/PassageDetail/Internalization/useResumePendingProjectResourceConfig.ts create mode 100644 src/renderer/src/store/upload/pendingProjectResourceConfig.test.ts create mode 100644 src/renderer/src/store/upload/pendingProjectResourceConfig.ts diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx index 26c19528..3e0678fe 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailArtifacts.tsx @@ -1,4 +1,11 @@ -import { useState, useContext, useMemo, useRef, useEffect, useCallback } from 'react'; +import { + useState, + useContext, + useMemo, + useRef, + useEffect, + useCallback, +} from 'react'; import { useGetGlobal, useGlobal } from '../../../context/useGlobal'; import { IPassageDetailArtifactsStrings, @@ -105,6 +112,8 @@ import { usePassageRef } from './usePassageRef'; import { MarkDownView } from '../../../control/MarkDownView'; import { UploadType } from '../../UploadType'; import { ResourceTypeEnum } from './ResourceTypeEnum'; +import { buildResourcePendingRestore } from './buildResourcePendingRestore'; +import { useResumePendingProjectResourceConfig } from './useResumePendingProjectResourceConfig'; const MediaContainer = styled(Box)(({ theme }) => ({ marginRight: theme.spacing(2), @@ -274,22 +283,37 @@ export function PassageDetailArtifacts() { }, [artifactTypes, offlineOnly]); const resourcePendingRestore = useCallback(() => { - if (isProjectResource()) return undefined; + if (resourceTypeRef.current === ResourceTypeEnum.projectResource) { + return buildResourcePendingRestore({ + resourceType: ResourceTypeEnum.projectResource, + sectionId: section.id, + passageId: passage.id, + description: descriptionRef.current || null, + sequenceNum: 0, + ...(catIdRef.current ? { artifactCategoryId: catIdRef.current } : {}), + }); + } const step = InternalizationStep(); if (!step?.id) return undefined; pendingResourceSeqRef.current += 1; - return { - kind: 'sectionresource' as const, + return buildResourcePendingRestore({ + resourceType: resourceTypeRef.current, sectionId: section.id, + passageId: passage.id, description: descriptionRef.current || null, sequenceNum: rowData.length + pendingResourceSeqRef.current, orgWorkflowStepId: step.id, - ...(isPassageResource() ? { passageId: passage.id } : {}), ...(catIdRef.current ? { artifactCategoryId: catIdRef.current } : {}), - ...(descriptionRef.current ? { topic: descriptionRef.current } : {}), - }; + }); }, [InternalizationStep, section.id, passage.id, rowData.length]); + useResumePendingProjectResourceConfig({ + memory, + mediafiles, + setProjResSetup, + isAddingAudioResourceRef, + }); + const handlePlay = (id: string) => { if (id === playItem) { setItemPlaying(!itemPlaying); @@ -411,8 +435,7 @@ export function PassageDetailArtifacts() { (r) => related(r, 'mediafile') === id ) as SectionResourceD; const mf = mediafiles.find((m) => m.id === related(secRes, 'mediafile')) as - | MediaFileD - | undefined; + MediaFileD | undefined; // General (project) resources are reconfigured through the wizard, not the // simple edit dialog (mockup: "use Edit to also configure the General Resource"). if (mf && related(mf, 'artifactType') === projResourceType) { @@ -726,8 +749,7 @@ export function PassageDetailArtifacts() { const results: number[] = []; sectionResources.forEach((sr) => { const rec = findRecord(memory, 'mediafile', related(sr, 'mediafile')) as - | MediaFileD - | undefined; + MediaFileD | undefined; if (rowData.find((r) => r.id === rec?.id)) { const passageId = rec?.attributes.resourcePassageId; if (passageId) results.push(passageId); @@ -775,8 +797,7 @@ export function PassageDetailArtifacts() { const total = items.length; for (const i of items) { const rec = memory.cache.query((q) => q.findRecord(i)) as - | Passage - | Section; + Passage | Section; const secRec = rec?.type === 'section' ? (rec as Section) diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx index b0d5800b..59d41c92 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx @@ -1,4 +1,11 @@ -import { useState, useContext, useMemo, useRef, useEffect, useCallback } from 'react'; +import { + useState, + useContext, + useMemo, + useRef, + useEffect, + useCallback, +} from 'react'; import { useGetGlobal, useGlobal } from '../../../context/useGlobal'; import { IPassageDetailArtifactsStrings, @@ -95,6 +102,8 @@ import { usePassageRef } from './usePassageRef'; import { CompactMarkDownView } from '../../../control/MarkDownView'; import { UploadType } from '../../UploadType'; import { ResourceTypeEnum } from './ResourceTypeEnum'; +import { buildResourcePendingRestore } from './buildResourcePendingRestore'; +import { useResumePendingProjectResourceConfig } from './useResumePendingProjectResourceConfig'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import IconMenu from '../../../control/IconMenu'; @@ -258,22 +267,37 @@ export function PassageDetailArtifactsMobile() { }, [artifactTypes, offlineOnly]); const resourcePendingRestore = useCallback(() => { - if (isProjectResource()) return undefined; + if (resourceTypeRef.current === ResourceTypeEnum.projectResource) { + return buildResourcePendingRestore({ + resourceType: ResourceTypeEnum.projectResource, + sectionId: section.id, + passageId: passage.id, + description: descriptionRef.current || null, + sequenceNum: 0, + ...(catIdRef.current ? { artifactCategoryId: catIdRef.current } : {}), + }); + } const step = InternalizationStep(); if (!step?.id) return undefined; pendingResourceSeqRef.current += 1; - return { - kind: 'sectionresource' as const, + return buildResourcePendingRestore({ + resourceType: resourceTypeRef.current, sectionId: section.id, + passageId: passage.id, description: descriptionRef.current || null, sequenceNum: rowData.length + pendingResourceSeqRef.current, orgWorkflowStepId: step.id, - ...(isPassageResource() ? { passageId: passage.id } : {}), ...(catIdRef.current ? { artifactCategoryId: catIdRef.current } : {}), - ...(descriptionRef.current ? { topic: descriptionRef.current } : {}), - }; + }); }, [InternalizationStep, section.id, passage.id, rowData.length]); + useResumePendingProjectResourceConfig({ + memory, + mediafiles, + setProjResSetup, + isAddingAudioResourceRef, + }); + const handlePlay = (id: string) => { if (id === playItem) { setItemPlaying(!itemPlaying); @@ -398,8 +422,7 @@ export function PassageDetailArtifactsMobile() { (r) => related(r, 'mediafile') === id ) as SectionResourceD; const mf = mediafiles.find((m) => m.id === related(secRes, 'mediafile')) as - | MediaFileD - | undefined; + MediaFileD | undefined; // General (project) resources are reconfigured through the wizard, not the // simple edit dialog (mockup: "use Edit to also configure the General Resource"). if (mf && related(mf, 'artifactType') === projResourceType) { @@ -703,8 +726,7 @@ export function PassageDetailArtifactsMobile() { const results: number[] = []; sectionResources.forEach((sr) => { const rec = findRecord(memory, 'mediafile', related(sr, 'mediafile')) as - | MediaFileD - | undefined; + MediaFileD | undefined; if (rowData.find((r) => r.id === rec?.id)) { const passageId = rec?.attributes.resourcePassageId; if (passageId) results.push(passageId); @@ -752,8 +774,7 @@ export function PassageDetailArtifactsMobile() { const total = items.length; for (const i of items) { const rec = memory.cache.query((q) => q.findRecord(i)) as - | Passage - | Section; + Passage | Section; const secRec = rec?.type === 'section' ? (rec as Section) diff --git a/src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.test.ts b/src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.test.ts new file mode 100644 index 00000000..d5b396dd --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from '@jest/globals'; +import { buildResourcePendingRestore } from './buildResourcePendingRestore'; +import { ResourceTypeEnum } from './ResourceTypeEnum'; + +describe('buildResourcePendingRestore (TT-7363 general resource)', () => { + it('returns projectresource restore meta instead of undefined for general resources', () => { + expect( + buildResourcePendingRestore({ + resourceType: ResourceTypeEnum.projectResource, + sectionId: 'sec-1', + passageId: 'pas-1', + description: 'General take', + sequenceNum: 1, + orgWorkflowStepId: 'ows-1', + artifactCategoryId: 'cat-1', + }) + ).toEqual({ + kind: 'projectresource', + topic: 'General take', + artifactCategoryId: 'cat-1', + }); + }); + + it('omits empty topic and category on projectresource restore', () => { + expect( + buildResourcePendingRestore({ + resourceType: ResourceTypeEnum.projectResource, + sectionId: 'sec-1', + passageId: 'pas-1', + description: null, + sequenceNum: 1, + }) + ).toEqual({ kind: 'projectresource' }); + }); + + it('still builds sectionresource restore for section resources', () => { + expect( + buildResourcePendingRestore({ + resourceType: ResourceTypeEnum.sectionResource, + sectionId: 'sec-1', + passageId: 'pas-1', + description: 'Section take', + sequenceNum: 2, + orgWorkflowStepId: 'ows-1', + }) + ).toEqual({ + kind: 'sectionresource', + sectionId: 'sec-1', + description: 'Section take', + sequenceNum: 2, + orgWorkflowStepId: 'ows-1', + topic: 'Section take', + }); + }); +}); diff --git a/src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.ts b/src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.ts new file mode 100644 index 00000000..8d13481a --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/buildResourcePendingRestore.ts @@ -0,0 +1,53 @@ +import type { PendingUploadRestore } from '../../../store/upload/pendingMediaUploads'; +import { ResourceTypeEnum } from './ResourceTypeEnum'; + +export interface BuildResourcePendingRestoreArgs { + resourceType: ResourceTypeEnum; + sectionId: string; + passageId: string; + description: string | null; + sequenceNum: number; + orgWorkflowStepId?: string; + artifactCategoryId?: string; +} + +/** + * Serializable restore metadata for resource Uploader / MediaRecord pending + * uploads (TT-7363). Section and passage resources recreate a sectionresource; + * general (project) resources only carry topic/category so Home Retry can + * apply them and resume the configure wizard. + */ +export function buildResourcePendingRestore( + args: BuildResourcePendingRestoreArgs +): PendingUploadRestore | undefined { + const { + resourceType, + sectionId, + passageId, + description, + sequenceNum, + orgWorkflowStepId, + artifactCategoryId, + } = args; + + if (resourceType === ResourceTypeEnum.projectResource) { + return { + kind: 'projectresource' as const, + ...(description ? { topic: description } : {}), + ...(artifactCategoryId ? { artifactCategoryId } : {}), + }; + } + + if (!orgWorkflowStepId) return undefined; + + return { + kind: 'sectionresource' as const, + sectionId, + description: description || null, + sequenceNum, + orgWorkflowStepId, + ...(resourceType === ResourceTypeEnum.passageResource ? { passageId } : {}), + ...(artifactCategoryId ? { artifactCategoryId } : {}), + ...(description ? { topic: description } : {}), + }; +} diff --git a/src/renderer/src/components/PassageDetail/Internalization/useResumePendingProjectResourceConfig.ts b/src/renderer/src/components/PassageDetail/Internalization/useResumePendingProjectResourceConfig.ts new file mode 100644 index 00000000..681e3c1e --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/useResumePendingProjectResourceConfig.ts @@ -0,0 +1,43 @@ +import { useEffect, MutableRefObject } from 'react'; +import Memory from '@orbit/memory'; +import { findRecord } from '../../../crud/tryFindRecord'; +import { MediaFileD } from '../../../model'; +import { + loadPendingProjectResourceConfigs, + removePendingProjectResourceConfigs, +} from '../../../store/upload/pendingProjectResourceConfig'; + +/** + * After Home Retry restores a general resource, reopen the configure flow the + * same way afterUpload does via setProjResSetup (desktop + mobile). + */ +export function useResumePendingProjectResourceConfig({ + memory, + mediafiles, + setProjResSetup, + isAddingAudioResourceRef, +}: { + memory: Memory; + mediafiles: MediaFileD[]; + setProjResSetup: (medias: MediaFileD[]) => void; + isAddingAudioResourceRef: MutableRefObject; +}): void { + useEffect(() => { + const pendingIds = loadPendingProjectResourceConfigs(); + if (!pendingIds.length) return; + + const ready = pendingIds + .map( + (id) => findRecord(memory, 'mediafile', id) as MediaFileD | undefined + ) + .filter((m): m is MediaFileD => Boolean(m)); + + if (!ready.length) return; + + removePendingProjectResourceConfigs(ready.map((m) => m.id)); + isAddingAudioResourceRef.current = true; + setProjResSetup(ready); + // mediafiles: re-run after Orbit pull from pending retry + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mediafiles]); +} diff --git a/src/renderer/src/store/upload/pendingMediaUploads.ts b/src/renderer/src/store/upload/pendingMediaUploads.ts index 19b0011f..3194777f 100644 --- a/src/renderer/src/store/upload/pendingMediaUploads.ts +++ b/src/renderer/src/store/upload/pendingMediaUploads.ts @@ -54,6 +54,16 @@ export type PendingUploadRestore = | { kind: 'sourceMedia'; sourceMediaId: string; + } + | { + /** + * General (project) resource: afterUpload opens the configure wizard + * instead of creating a sectionresource. Home Retry applies topic/category + * and queues the media id so Internalization can resume configuration. + */ + kind: 'projectresource'; + topic?: string; + artifactCategoryId?: string; }; export type PendingRestoreInput = diff --git a/src/renderer/src/store/upload/pendingProjectResourceConfig.test.ts b/src/renderer/src/store/upload/pendingProjectResourceConfig.test.ts new file mode 100644 index 00000000..31c43227 --- /dev/null +++ b/src/renderer/src/store/upload/pendingProjectResourceConfig.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import { + appendPendingProjectResourceConfig, + loadPendingProjectResourceConfigs, + removePendingProjectResourceConfigs, + takePendingProjectResourceConfigs, +} from './pendingProjectResourceConfig'; + +describe('pendingProjectResourceConfig', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('queues and drains media ids for configure resume', () => { + appendPendingProjectResourceConfig('m1'); + appendPendingProjectResourceConfig('m2'); + expect(loadPendingProjectResourceConfigs()).toEqual(['m1', 'm2']); + expect(takePendingProjectResourceConfigs()).toEqual(['m1', 'm2']); + expect(loadPendingProjectResourceConfigs()).toEqual([]); + }); + + it('dedupes append and can remove a subset', () => { + appendPendingProjectResourceConfig('m1'); + appendPendingProjectResourceConfig('m1'); + appendPendingProjectResourceConfig('m2'); + removePendingProjectResourceConfigs(['m1']); + expect(loadPendingProjectResourceConfigs()).toEqual(['m2']); + }); +}); diff --git a/src/renderer/src/store/upload/pendingProjectResourceConfig.ts b/src/renderer/src/store/upload/pendingProjectResourceConfig.ts new file mode 100644 index 00000000..cec3e9ce --- /dev/null +++ b/src/renderer/src/store/upload/pendingProjectResourceConfig.ts @@ -0,0 +1,48 @@ +const STORAGE_KEY = 'pendingProjectResourceConfigV1'; + +function loadIds(): string[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter((id): id is string => typeof id === 'string'); + } catch { + return []; + } +} + +function saveIds(ids: string[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); + } catch { + // ignore quota / private mode + } +} + +/** After Home Retry restores a general resource, queue it for configure resume. */ +export function appendPendingProjectResourceConfig(mediaId: string): void { + if (!mediaId) return; + const next = loadIds().filter((id) => id !== mediaId); + next.push(mediaId); + saveIds(next); +} + +/** Drain the configure-resume queue (Internalization consumes on mount). */ +export function takePendingProjectResourceConfigs(): string[] { + const ids = loadIds(); + if (ids.length) saveIds([]); + return ids; +} + +/** Remove specific media ids after they have been handed to the configure UI. */ +export function removePendingProjectResourceConfigs(mediaIds: string[]): void { + if (!mediaIds.length) return; + const remove = new Set(mediaIds); + saveIds(loadIds().filter((id) => !remove.has(id))); +} + +/** Peek without draining — for tests / debugging. */ +export function loadPendingProjectResourceConfigs(): string[] { + return loadIds(); +} diff --git a/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts index c4243391..27c4f17d 100644 --- a/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts +++ b/src/renderer/src/store/upload/pendingUploadRetryGaps.test.ts @@ -4,6 +4,7 @@ import MemorySource from '@orbit/memory'; import { related } from '../../crud/related'; import { restoreAfterPendingUpload } from './restoreAfterPendingUpload'; import type { PendingUploadRestore } from './pendingMediaUploads'; +import { takePendingProjectResourceConfigs } from './pendingProjectResourceConfig'; import { getRecordingForClause, getCompletedClauseIndices, @@ -59,6 +60,7 @@ const schema = new RecordSchema({ sourceSegments: { type: 'string' }, originalFile: { type: 'string' }, versionNumber: { type: 'number' }, + topic: { type: 'string' }, dateCreated: { type: 'string' }, dateUpdated: { type: 'string' }, }, @@ -125,6 +127,7 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { const user = 'user-1'; beforeEach(async () => { + localStorage.clear(); memory = new MemorySource({ schema }); await memory.update((t) => [ t.addRecord({ type: 'user', id: user, attributes: {} }), @@ -204,7 +207,9 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { }>; expect(sectionResources).toHaveLength(1); expect(related(sectionResources[0], 'section')).toBe('sec-1'); - expect(related(sectionResources[0], 'mediafile')).toBe('resource-media-1'); + expect(related(sectionResources[0], 'mediafile')).toBe( + 'resource-media-1' + ); expect(related(sectionResources[0], 'orgWorkflowStep')).toBe('ows-1'); expect(sectionResources[0].attributes?.description).toBe( 'My resource recording' @@ -308,6 +313,69 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { }); }); + describe('General Resource — projectresource configure resume', () => { + /** + * Bug: PassageDetailArtifacts / PassageDetailsArtifactsMobile return + * undefined from resourcePendingRestore when resourceType is projectResource. + * Home Retry then uploads an unlinked mediafile with no way to reopen the + * configure wizard that afterUpload normally starts. + */ + it('applies topic and artifactCategory, and queues media for configure resume', async () => { + await memory.update((t) => [ + t.addRecord({ + type: 'artifactcategory', + id: 'cat-1', + attributes: { categoryname: 'Scripture' }, + }), + t.addRecord({ + type: 'artifacttype', + id: 'proj-art', + attributes: { typename: 'projectresource' }, + }), + t.addRecord({ + type: 'mediafile', + id: 'proj-media-1', + attributes: { + originalFile: 'general.mp3', + versionNumber: 1, + topic: '', + }, + relationships: { + artifactType: { data: { type: 'artifacttype', id: 'proj-art' } }, + }, + }), + ]); + + const restore: PendingUploadRestore = { + kind: 'projectresource', + topic: 'General resource take', + artifactCategoryId: 'cat-1', + }; + + await restoreAfterPendingUpload({ + mediaId: 'proj-media-1', + restore, + memory, + user, + }); + + const media = memory.cache.getRecordSync({ + type: 'mediafile', + id: 'proj-media-1', + }) as { attributes?: { topic?: string } }; + expect(media.attributes?.topic).toBe('General resource take'); + expect(related(media, 'artifactCategory')).toBe('cat-1'); + + // Must not create a sectionresource — configuration still owns linking. + const sectionResources = memory.cache.query((q) => + q.findRecords('sectionresource') + ); + expect(sectionResources).toHaveLength(0); + + expect(takePendingProjectResourceConfigs()).toEqual(['proj-media-1']); + }); + }); + describe('LWC Audio Translation — sourceMedia secondary link', () => { it('relinks sourceMedia on the pulled mediafile from pending restore meta', async () => { const restore: PendingUploadRestore = { @@ -361,13 +429,7 @@ describe('pending upload retry gaps (TT-7363 reopen)', () => { ); expect(completed.has(0)).toBe(true); expect( - getRecordingForClause( - [row], - 'lwc-art', - 1, - clauseRegion, - 'vern-1' - )?.id + getRecordingForClause([row], 'lwc-art', 1, clauseRegion, 'vern-1')?.id ).toBe('lwc-row'); }); }); diff --git a/src/renderer/src/store/upload/restoreAfterPendingUpload.ts b/src/renderer/src/store/upload/restoreAfterPendingUpload.ts index 8bdb7f07..9552a12e 100644 --- a/src/renderer/src/store/upload/restoreAfterPendingUpload.ts +++ b/src/renderer/src/store/upload/restoreAfterPendingUpload.ts @@ -24,6 +24,7 @@ import { SectionResource, } from '../../model'; import type { PendingUploadRestore } from './pendingMediaUploads'; +import { appendPendingProjectResourceConfig } from './pendingProjectResourceConfig'; export interface RestoreAfterPendingUploadArgs { mediaId: string; @@ -88,6 +89,14 @@ export async function restoreAfterPendingUpload({ user, }); return; + case 'projectresource': + await restoreProjectResource({ + mediaId: localMediaId, + restore, + memory, + user, + }); + return; default: return; } @@ -259,8 +268,7 @@ async function restoreSectionResource({ }): Promise { const mediaRecId = { type: 'mediafile', id: mediaId }; const mediaRec = findRecord(memory, 'mediafile', mediaId) as - | MediaFileD - | undefined; + MediaFileD | undefined; if (restore.topic && mediaRec) { await memory.update((t) => @@ -303,7 +311,9 @@ async function restoreSectionResource({ // adds may already occupy it (batch compact / delayed retry). Prefer the // captured value when free; otherwise take max+1 for this section. const existingForSection = ( - memory.cache.query((q) => q.findRecords('sectionresource')) as SectionResource[] + memory.cache.query((q) => + q.findRecords('sectionresource') + ) as SectionResource[] ).filter((r) => related(r, 'section') === restore.sectionId); const usedSeqs = existingForSection.map( (r) => r.attributes?.sequenceNum ?? 0 @@ -371,8 +381,7 @@ async function restoreSourceMedia({ user: string; }): Promise { const mediaRec = findRecord(memory, 'mediafile', mediaId) as - | MediaFileD - | undefined; + MediaFileD | undefined; if (!mediaRec) return; const localSourceId = @@ -396,3 +405,50 @@ async function restoreSourceMedia({ ) ); } + +/** + * Mirrors PassageDetailArtifacts.afterUpload for project resources: apply + * topic/category only, then queue configure-wizard resume (no sectionresource). + */ +async function restoreProjectResource({ + mediaId, + restore, + memory, + user, +}: { + mediaId: string; + restore: Extract; + memory: Memory; + user: string; +}): Promise { + const mediaRecId = { type: 'mediafile', id: mediaId }; + const mediaRec = findRecord(memory, 'mediafile', mediaId) as + MediaFileD | undefined; + + if (restore.topic && mediaRec) { + await memory.update((t) => + UpdateRecord( + t, + { + ...mediaRec, + attributes: { ...mediaRec.attributes, topic: restore.topic }, + } as MediaFileD, + user + ) + ); + } + if (restore.artifactCategoryId) { + const t = new RecordTransformBuilder(); + await memory.update([ + ...ReplaceRelatedRecord( + t, + mediaRecId, + 'artifactCategory', + 'artifactcategory', + restore.artifactCategoryId + ), + ]); + } + + appendPendingProjectResourceConfig(mediaId); +} From 1a7e3a005b1a2d25ada0b4381955e043d9b21908 Mon Sep 17 00:00:00 2001 From: Greg Trihus Date: Thu, 3 Sep 2026 14:36:07 -0500 Subject: [PATCH 6/6] Add unit tests for Uploader component and modify beforeUpload logic - Introduced a new test file for the Uploader component, validating the behavior of the beforeUpload prop and its interaction with the afterUploadCb function. - Updated the Uploader component to remove the awaiting of beforeUpload within afterUploadCb, ensuring that metadata is committed correctly without redundant calls. - Enhanced the useMediaUpload hook documentation to clarify the handling of beforeUpload in the recorded path, improving overall clarity and maintainability. - Ensured comprehensive test coverage for the new functionality to validate correct behavior during media uploads. --- src/renderer/src/components/Uploader.test.tsx | 177 ++++++++++++++++++ src/renderer/src/components/Uploader.tsx | 1 - src/renderer/src/crud/useMediaUpload.ts | 6 +- 3 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/Uploader.test.tsx diff --git a/src/renderer/src/components/Uploader.test.tsx b/src/renderer/src/components/Uploader.test.tsx new file mode 100644 index 00000000..28d9c36f --- /dev/null +++ b/src/renderer/src/components/Uploader.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { act, cleanup, render } from '@testing-library/react'; +import { UploadType } from './UploadType'; + +type PassageRecordDlgProps = { + afterUploadCb: (mediaId: string | undefined) => Promise; + beforeUpload?: () => Promise; +}; + +let capturedDlg: PassageRecordDlgProps | undefined; + +jest.mock('./PassageRecordDlg', () => ({ + __esModule: true, + default: (props: PassageRecordDlgProps) => { + capturedDlg = props; + return
; + }, +})); + +jest.mock('./MediaUpload', () => ({ + __esModule: true, + default: () => null, + FaithbridgeType: 'audio/mpeg/s3link', +})); + +jest.mock('../store', () => ({ + uploadFiles: jest.fn(() => ({ type: 'UPLOAD_LIST' })), + nextUpload: jest.fn(() => ({ type: 'NEXT_UPLOAD' })), + uploadComplete: jest.fn(() => ({ type: 'UPLOAD_COMPLETE' })), +})); + +jest.mock('../selector', () => ({ + mediaTabSelector: () => ({ + uploadComplete: '{0} of {1} files uploaded successfully.', + unsupported: '{0}', + toobig: '{0} {1} {2}', + selectFiles: 'Select files', + }), + sharedSelector: () => ({ + mediaAttached: 'Media attached', + }), +})); + +jest.mock('react-redux', () => ({ + shallowEqual: jest.fn(), + useDispatch: () => jest.fn((action: unknown) => action), + useSelector: (sel: (state: { upload: { errmsg: string } }) => unknown) => + sel({ upload: { errmsg: '' } }), +})); + +jest.mock('../context/TokenProvider', () => { + const ReactActual = jest.requireActual('react'); + return { + TokenContext: ReactActual.createContext({ + state: { accessToken: 'tok' }, + }), + }; +}); + +const mockMemory = { cache: { query: jest.fn(() => []) }, keyMap: {} }; +const mockCoordinator = { + getSource: jest.fn((name: string) => { + if (name === 'memory') return mockMemory; + return {}; + }), +}; + +jest.mock('../context/useGlobal', () => ({ + useGlobal: jest.fn((key: string) => { + const mockValues: Record = { + developer: false, + coordinator: mockCoordinator, + errorReporter: {}, + orbitRetries: 0, + importexportBusy: false, + plan: '', + user: 'user-1', + progress: 0, + }; + return [mockValues[key], jest.fn()]; + }), + useGetGlobal: jest.fn(() => (key: string) => { + if (key === 'offline') return false; + if (key === 'importexportBusy') return false; + return undefined; + }), +})); + +jest.mock('../crud', () => ({ + findRecord: jest.fn(), + pullTableList: jest.fn(), + related: jest.fn(), + remoteIdNum: jest.fn((_table: string, id: string) => id), + useArtifactType: () => ({ + localizedArtifactTypeFromId: jest.fn(() => 'artifact-label'), + }), + useOfflnMediafileCreate: () => ({ + createMedia: jest.fn(), + }), + VernacularTag: null, +})); + +jest.mock('../utils', () => ({ + restoreScroll: jest.fn(), +})); + +jest.mock('../utils/passageDefaultFilename', () => ({ + passageDefaultSuffix: jest.fn(() => ''), +})); + +jest.mock('../utils/typeLimit', () => ({ + typeLimit: jest.fn(() => 100), +})); + +jest.mock('../utils/contentType', () => ({ + getContentType: jest.fn(() => 'audio/webm'), +})); + +jest.mock('../store/upload/uploadTerminalMessages', () => ({ + formatUploadTerminalFailureMessage: jest.fn(() => 'upload failed'), +})); + +import Uploader from './Uploader'; + +function renderUploader(beforeUpload: () => Promise) { + const cancelled = { current: false }; + return render( + + ); +} + +describe('Uploader recorded path beforeUpload', () => { + beforeEach(() => { + capturedDlg = undefined; + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + cleanup(); + }); + + /** + * Recorded save goes MediaRecord → useMediaUpload (await beforeUpload, then + * stage) → Uploader.afterUploadCb on success. Copilot r3919030521: the + * after-upload hook must not commit again — SelectArtifactCategory.commit() + * is not idempotent if React has not re-rendered the new category yet. + */ + it('commits deferred metadata once when a recording upload succeeds', async () => { + const created: string[] = []; + const beforeUpload = jest.fn(async () => { + created.push(`cat-${created.length + 1}`); + }); + renderUploader(beforeUpload); + expect(capturedDlg?.beforeUpload).toBe(beforeUpload); + + await act(async () => { + await capturedDlg!.beforeUpload!(); + await capturedDlg!.afterUploadCb('media-1'); + }); + + expect(created).toEqual(['cat-1']); + }); +}); diff --git a/src/renderer/src/components/Uploader.tsx b/src/renderer/src/components/Uploader.tsx index 0935bcec..25ebd2a3 100644 --- a/src/renderer/src/components/Uploader.tsx +++ b/src/renderer/src/components/Uploader.tsx @@ -164,7 +164,6 @@ export const Uploader = (props: IProps) => { const afterUploadCb = async (mediaId: string | undefined) => { if (mediaId) { - if (beforeUpload) await beforeUpload(); successCount.current = 1; mediaIdRef.current = [mediaId]; } else successCount.current = 0; diff --git a/src/renderer/src/crud/useMediaUpload.ts b/src/renderer/src/crud/useMediaUpload.ts index 15ba5a68..b2652a0c 100644 --- a/src/renderer/src/crud/useMediaUpload.ts +++ b/src/renderer/src/crud/useMediaUpload.ts @@ -42,9 +42,9 @@ interface IProps { pendingRestore?: PendingRestoreInput; /** * Commit deferred metadata (e.g. a newly typed artifact category) before - * staging the upload and evaluating `pendingRestore`. Required on the - * recorded-audio path: `Uploader.afterUploadCb` only runs `beforeUpload` - * after a successful recording (TT-7363 Copilot r3918081583). + * staging the upload and evaluating `pendingRestore`. Uploader's recorded + * path forwards this here and does not await it again in afterUploadCb + * (TT-7363 Copilot r3919030521). */ beforeUpload?: () => Promise; }