Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ jest.mock('./PassageDetailPlayer', () => {
// }),
// }));
jest.mock('../../utils/logErrorService', () => jest.fn());

const mockMediaFile = {
id: 'm1',
type: 'mediafile',
attributes: { segments: '{}' },
} as MediaFileD;

jest.mock('../../crud/tryFindRecord', () => ({
findRecord: jest.fn((_memory: unknown, table: string, id: string) =>
table === 'mediafile' && id === 'm1' ? mockMediaFile : undefined
),
}));
jest.mock('../../hoc/SnackBar', () => {
const actual = jest.requireActual('../../hoc/SnackBar');
return {
Expand Down Expand Up @@ -493,7 +505,32 @@ test('should not add rows including refs already in table', async () => {
expect(tbody.children.length).toBe(4); // 3 limits (with 3 verse refs) + 1 header
});

test('shows warning snackbar and blocks autosave when checkRefs has issues', async () => {
test('autosaves segment markup after debounce when only soft validation warnings', async () => {
runTest({ width: 1000 });
await waitFor(() =>
expect(screen.getByTestId('verse-sheet')).toBeInTheDocument()
);

act(() => {
if (mockPlayerAction) {
mockPlayerAction(
'{"regions":"[{\\"start\\":0,\\"end\\":5},{\\"start\\":5,\\"end\\":9}]"}',
false
);
}
});

await waitFor(
() => expect(mockProjectSegmentSave).toHaveBeenCalled(),
{ timeout: 3000 }
);
expect(mockShowMessage).not.toHaveBeenCalledWith(
expect.anything(),
AlertSeverity.Warning
);
});

test('blocks autosave when markup has hard reference errors', async () => {
jest.useFakeTimers();
runTest({ width: 1000 });
const tbody = screen.getByTestId('verse-sheet')?.firstChild?.firstChild
Expand All @@ -503,7 +540,7 @@ test('shows warning snackbar and blocks autosave when checkRefs has issues', asy
act(() => {
if (mockPlayerAction) {
mockPlayerAction(
'{"regions":"[{\\"start\\":0,\\"end\\":5},{\\"start\\":5,\\"end\\":9}]"}',
'{"regions":"[{\\"start\\":0,\\"end\\":5,\\"label\\":\\"9:9\\"}]"}',
false
);
}
Expand All @@ -516,12 +553,6 @@ test('shows warning snackbar and blocks autosave when checkRefs has issues', asy
);
});

const messageArg = mockShowMessage.mock.calls[0][0] as {
props: { children: unknown[] };
};
const messageText = JSON.stringify(messageArg);
expect(messageText).toMatch(/Autosave skipped: \d+ issue\(s\) found\./);

act(() => {
jest.advanceTimersByTime(2000);
});
Expand Down
183 changes: 99 additions & 84 deletions src/renderer/src/components/PassageDetail/PassageDetailMarkVerses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ import {
isMarkVersesTableRowCompleted,
isMarkVersesTableTailIncomplete,
} from '../../utils/markVersesSegmentColors';
import {
getMarkVersesAutosaveBlockers,
getMarkVersesValidationIssues,
} from '../../utils/markVersesValidation';
import { verseToolId } from './markVersesTool';

const NotTable = 490;
const verseToolId = 'VerseTool';
/** Nudge past a join when seeking so the playhead lands in the right-hand segment. */
const SEGMENT_BOUNDARY_TOLERANCE_SEC = 0.1;
/** Table limits use one decimal; waveform uses float seconds — allow rounding drift. */
Expand Down Expand Up @@ -334,49 +338,77 @@ export function PassageDetailMarkVerses({ width }: MarkVersesProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passage, engVrs]);

const setStepCompleteRef = useRef(setStepComplete);
setStepCompleteRef.current = setStepComplete;
const gotoNextStepRef = useRef(gotoNextStep);
gotoNextStepRef.current = gotoNextStep;
const currentstepRef = useRef(currentstep);
currentstepRef.current = currentstep;

const handleComplete = (complete: boolean) => {
waitForSave(undefined, 200).finally(async () => {
await setStepComplete(currentstep, complete);
if (complete) gotoNextStep();
await setStepCompleteRef.current(currentstepRef.current, complete);
if (complete) gotoNextStepRef.current();
});
};

const syncSegmentsRefFromTable = () => {
const regions: IRegion[] = [];
dataRef.current.forEach((r, i) => {
if (i > 0) {
const limits = `${r[ColName.Limits]?.value ?? ''}`.split('-');
if (limits.length === 2) {
regions.push({
start: parseFloat(limits[0]),
end: parseFloat(limits[1]),
label: `${r[ColName.Ref]?.value ?? ''}`,
});
}
}
});
segmentsRef.current = JSON.stringify({ regions });
};

const writeResources = async () => {
if (!savingRef.current) {
savingRef.current = true;
if (media) {
// update all three segment types: verse, transcription, backtranslation
let segments = updateSegments(
NamedRegions.Transcription,
updateSegments(
NamedRegions.Verse,
media.attributes?.segments,
segmentsRef.current
),
syncSegmentsRefFromTable();
if (!media) {
savingRef.current = false;
saveCompleted(verseToolId);
return;
}
// update all three segment types: verse, transcription, backtranslation
let segments = updateSegments(
NamedRegions.Transcription,
updateSegments(
NamedRegions.Verse,
media.attributes?.segments,
segmentsRef.current
),
segmentsRef.current
);
if (!hasBtRecordings) {
segments = updateSegments(
NamedRegions.BackTranslation,
segments,
segmentsRef.current
);
if (!hasBtRecordings) {
segments = updateSegments(
NamedRegions.BackTranslation,
segments,
segmentsRef.current
);
}
// remove TRTask segments that handle AI transcription
segments = updateSegments(NamedRegions.TRTask, segments, '');
projectSegmentSave({ media, segments })
.then(() => {
saveCompleted(verseToolId);
})
.catch((err) => {
saveCompleted(verseToolId, err.message);
})
.finally(() => {
savingRef.current = false;
canceling.current = false;
setComplete(0);
});
}
// remove TRTask segments that handle AI transcription
segments = updateSegments(NamedRegions.TRTask, segments, '');
projectSegmentSave({ media, segments })
.then(() => {
saveCompleted(verseToolId);
})
.catch((err) => {
saveCompleted(verseToolId, err.message);
})
.finally(() => {
savingRef.current = false;
canceling.current = false;
setComplete(0);
});
}
};

Expand Down Expand Up @@ -756,51 +788,23 @@ export function PassageDetailMarkVerses({ width }: MarkVersesProps) {
else showMessage(tt.noData.replace('{0}', t.markVerses));
};

useEffect(() => {
if (saveRequested(verseToolId) && !savingRef.current) writeResources();
else if (clearRequested(verseToolId)) clearCompleted(verseToolId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolsChanged]);
const validationInput = () => ({
rows: dataRef.current
.filter((_, i) => i > 0)
.map((row) => ({
limits: `${(row[ColName.Limits] as ICell).value ?? ''}`,
ref: `${(row[ColName.Ref] as ICell).value ?? ''}`,
})),
expandedRefs: collectRefs(dataRef.current),
passageRefs: passageRefs.current,
hasBtRecordings,
strings: t,
});

const checkRefs = () => {
const refs: string[] = collectRefs(dataRef.current);
const noSegRefs = dataRef.current
.filter((v, i) => i > 0)
.filter(
(v) =>
(v[ColName.Ref] as ICell).value && !(v[ColName.Limits] as ICell).value
)
.map((v) => (v[ColName.Ref] as ICell).value);
const noRefSegs = dataRef.current
.filter((v, i) => i > 0)
.some(
(v) =>
!(v[ColName.Ref] as ICell).value && (v[ColName.Limits] as ICell).value
);
const matchAll = refs.every((r) => refMatch(r));
const refSet = new Set(passageRefs.current);
const outsideRefs = new Set<string>();
refs.forEach((r) => {
if (refSet.has(r)) refSet.delete(r);
else if (refMatch(r)) outsideRefs.add(r);
});
const issues: string[] = [];
if (!matchAll) issues.push(t.badReferences);
if (noSegRefs.length > 0)
issues.push(t.noSegments.replace('{0}', noSegRefs.join(', ')));
if (refSet.size > 0)
issues.push(
t.missingReferences.replace('{0}', Array.from(refSet).sort().join(', '))
);
if (outsideRefs.size > 0) {
issues.push(
t.outsideReferences.replace('{0}', Array.from(outsideRefs).join(', '))
);
}
if (noRefSegs) issues.push(t.noReferences);
if (hasBtRecordings) issues.push(t.btNotUpdated);
return issues;
};
const checkRefs = () => getMarkVersesValidationIssues(validationInput());

const checkAutosaveBlockers = () =>
getMarkVersesAutosaveBlockers(validationInput());

const handleCancel = () => {
if (savingRef.current) {
Expand Down Expand Up @@ -832,13 +836,23 @@ export function PassageDetailMarkVerses({ width }: MarkVersesProps) {
[scheduleAutosave]
);

useEffect(() => {
if (saveRequested(verseToolId) && !savingRef.current) {
scheduleAutosave.clear();
void writeResourcesRef.current();
} else if (clearRequested(verseToolId)) clearCompleted(verseToolId);
// saveRequested/clearRequested read live UnsavedContext refs via toolsChanged
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolsChanged, scheduleAutosave]);

useEffect(() => {
if (!isChanged(verseToolId) || !hasPermission || savingRef.current) return;
const issues = checkRefs();
if (issues.length > 0) {
const allIssues = checkRefs();
const blockers = checkAutosaveBlockers();
setSaveIssues(allIssues);
if (blockers.length > 0) {
scheduleAutosave.clear();
setSaveIssues(issues);
const fingerprint = issues.join('\0');
const fingerprint = blockers.join('\0');
if (fingerprint !== lastIssuesNotifyRef.current) {
lastIssuesNotifyRef.current = fingerprint;
showMessage(
Expand All @@ -851,7 +865,7 @@ export function PassageDetailMarkVerses({ width }: MarkVersesProps) {
}}
>
<span>
{t.autosaveSkipped.replace('{0}', String(issues.length))}
{t.autosaveSkipped.replace('{0}', String(blockers.length))}
</span>
<Button
size="small"
Expand All @@ -867,8 +881,9 @@ export function PassageDetailMarkVerses({ width }: MarkVersesProps) {
return;
}
lastIssuesNotifyRef.current = '';
setSaveIssues([]);
setIssuesDialogOpen(false);
if (allIssues.length === 0) {
setIssuesDialogOpen(false);
}
scheduleAutosave();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolsChanged, hasPermission, scheduleAutosave]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useGlobal } from '../../context/useGlobal';
import { IconButton, Box, Typography } from '@mui/material';
import CompleteIcon from '@mui/icons-material/CheckBoxOutlined';
Expand All @@ -13,6 +13,9 @@ import { useLocation } from 'react-router-dom';
import { useStepPermissions } from '../../utils/useStepPermission';
import { ToolSlug, useStepTool } from '../../crud';
import { useMobile } from '../../utils';
import { UnsavedContext } from '../../context/UnsavedContext';
import { verseToolId } from './markVersesTool';
import { useSnackBar } from '../../hoc/SnackBar';

export const PassageDetailStepComplete = () => {
const {
Expand All @@ -24,7 +27,6 @@ export const PassageDetailStepComplete = () => {
gotoNextStep,
psgCompleted,
section,
passage,
recording,
isBoldWorkflow,
} = usePassageDetailContext();
Expand All @@ -42,6 +44,9 @@ export const PassageDetailStepComplete = () => {
const passageNavigate = usePassageNavigate(() => {
setView('');
}, setCurrentStep);
const { isChanged, startSave, waitForSave } =
useContext(UnsavedContext).state;
const { showMessage } = useSnackBar();

const hasPermission = canDoSectionStep(currentstep, section);

Expand All @@ -53,11 +58,34 @@ export const PassageDetailStepComplete = () => {

const handleToggleComplete = useCallback(async () => {
const curStatus = complete;
await setStepComplete(currentstep, !complete);
//if we're now complete, go to the next step or passage
if (!curStatus) gotoNextStep();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [complete, currentstep, passage, section]);
const finish = async () => {
await setStepComplete(currentstep, !complete);
if (!curStatus) gotoNextStep();
};

if (!curStatus && tool === ToolSlug.Verses && isChanged(verseToolId)) {
startSave(verseToolId);
try {
await waitForSave(undefined, 25);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message) showMessage(message);
return;
}
}

await finish();
}, [
complete,
currentstep,
tool,
isChanged,
startSave,
waitForSave,
setStepComplete,
gotoNextStep,
showMessage,
]);

const handleSetCompleteTo = async () => {
setStepCompleteTo(currentstep);
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/components/PassageDetail/markVersesTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** UnsavedContext tool id for Mark Verses (desktop and mobile). */
export const verseToolId = 'VerseTool';
Loading
Loading