Skip to content
Closed
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 @@ -279,6 +279,41 @@ describe('PassageDetailGuidedPhraseRecord - step scope (TT-7643)', () => {
expect(controlsProps?.defaultFilename).toContain('he');
});

/**
* TT-7432 - deleting a recording and recording the segment again produced a
* second take whose name was identical to the first: segment index, source
* version and step language are all unchanged. `dataPath` resolves a
* mediafile's audioUrl to `<offlineData>/media/<basename>`, so the new take
* resolved to the file already cached for the deleted one and playback kept
* playing the deleted recording.
*/
it('names each take of a segment separately (TT-7432)', async () => {
mockCompleted = new Set<number>();
mockRecordingRow = { mediafile: { id: 'take-1' } };
await mountAndSettle();
const onRecording = controlsProps?.onRecording as (a: boolean) => void;

// First take of segment 1.
await act(async () => {
onRecording(true);
});
const firstTake = controlsProps?.defaultFilename as string;
await act(async () => {
onRecording(false);
});
// It still says which segment and which source version it belongs to.
expect(firstTake).toContain('backtranslation1_v1');

// Delete it and record the same segment again.
await act(async () => {
await (controlsProps?.onClearRecording as () => Promise<void>)();
});
await act(async () => {
(controlsProps?.onRecording as (a: boolean) => void)(true);
});
expect(controlsProps?.defaultFilename).not.toEqual(firstTake);
});

it('records against the language of the step now showing', async () => {
const { rerender } = await mountAndSettle();
expect(controlsProps?.languagebcp47).toBe('Sena|seh');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
splitClauseAt,
} from './carefulSpeech/carefulSpeechClauseSplit';
import {
newTakeToken,
type GuidedPhraseRecordConfig,
type IGuidedPhraseRecordControlStrings,
} from '../../components/PassageDetail/guidedPhraseRecord/types';
Expand Down Expand Up @@ -220,6 +221,11 @@ export function PassageDetailGuidedPhraseRecord({
localStorage.getItem(config.speakerLocalKey) ?? ''
);
const [showRecorder, setShowRecorder] = useState(false);
// Names this take apart from any other take of the same segment in the same
// step - see buildFilenamePostfix for why a repeated name plays the wrong
// audio (TT-7432). A new one for every take, so it is minted where recording
// begins rather than derived from anything the takes have in common.
const [takeToken, setTakeToken] = useState(newTakeToken);
const [resetMedia, setResetMedia] = useState(false);
const [statusText, setStatusText] = useState('');
const [canSave, setCanSave] = useState(false);
Expand Down Expand Up @@ -523,7 +529,8 @@ export function PassageDetailGuidedPhraseRecord({
const postfix = config.buildFilenamePostfix(
currentIndex,
currentVersion,
stepLanguageBcp47
stepLanguageBcp47,
takeToken
);
return passageDefaultFilename(
passage,
Expand All @@ -542,6 +549,7 @@ export function PassageDetailGuidedPhraseRecord({
currentIndex,
currentVersion,
stepLanguageBcp47,
takeToken,
config,
]);

Expand Down Expand Up @@ -1863,6 +1871,11 @@ export function PassageDetailGuidedPhraseRecord({
onRecording={(active) => {
if (active) {
recordingActiveRef.current = true;
// This take is its own file, even where an earlier take of this
// segment was deleted first (TT-7432). MediaRecord reads
// defaultFilename when the save runs, so a token minted here is
// the one the take uploads under.
setTakeToken(newTakeToken());
// A new take supersedes any earlier rejected save (TT-7583).
saveRejectedRef.current = false;
setSaveRejected(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,25 @@ jest.mock('./lwcTranslation/LwcTranslationControls', () => ({
},
}));

// Echo the postfix the step built, so a spec can see what the take would be
// named. Real signature: (passage, plan, memory, artifactType, offline, postfix).
jest.mock('../../utils/passageDefaultFilename', () => ({
passageDefaultFilename: () => 'file.ogg',
passageDefaultFilename: (...args: unknown[]) =>
`GEN001_014-019${args[5]}_plan`,
}));

import { PassageDetailLwcTranslation } from './PassageDetailLwcTranslation';

/** Play the reference clause through, which is what reveals the recorder. */
const openRecorder = async () => {
render(<PassageDetailLwcTranslation width={400} />);
await waitFor(() => expect(referenceProps).toBeDefined());
await act(async () => {
(referenceProps?.onPlaybackComplete as () => void)();
});
await waitFor(() => expect(controlsProps?.showRecorder).toBe(true));
};

describe('PassageDetailLwcTranslation', () => {
beforeEach(() => {
mockCarefulSpeechComplete = new Set<number>();
Expand Down Expand Up @@ -207,16 +220,6 @@ describe('PassageDetailLwcTranslation — rejected save (TT-7583)', () => {
mockStartSave.mockClear();
});

// Play the reference clause through, which is what reveals the recorder.
const openRecorder = async () => {
render(<PassageDetailLwcTranslation width={400} />);
await waitFor(() => expect(referenceProps).toBeDefined());
await act(async () => {
(referenceProps?.onPlaybackComplete as () => void)();
});
await waitFor(() => expect(controlsProps?.showRecorder).toBe(true));
};

// Record a take and request its auto-save, then have MediaRecord reject it.
const recordAndRejectSave = async () => {
await openRecorder();
Expand Down Expand Up @@ -374,3 +377,47 @@ describe('PassageDetailLwcTranslation — rejected save (TT-7583)', () => {
expect(controlsProps?.phase).toBe('recorded');
});
});

/**
* TT-7432 - clause index and source version were the whole of a take's name,
* and they do not change when the clause is recorded again. `dataPath` resolves
* a mediafile's audioUrl to `<offlineData>/media/<basename>`, so clearing a
* recording and recording it again uploaded the replacement under the name the
* deleted take is already cached on, and the deleted audio is what played.
* Same defect the Careful Speech / Phrase BT steps had.
*/
describe('PassageDetailLwcTranslation - take names (TT-7432)', () => {
beforeEach(() => {
mockCarefulSpeechComplete = new Set([0]);
mockLwcComplete = new Set<number>();
mockClauseRegions = [{ start: 0, end: 5, label: '' }];
controlsProps = undefined;
referenceProps = undefined;
mockStartSave.mockClear();
});

it('names each take of a clause separately', async () => {
await openRecorder();
const record = (active: boolean) =>
(controlsProps?.onRecording as (a: boolean) => void)(active);

await act(async () => {
record(true);
});
const firstTake = controlsProps?.defaultFilename as string;
await act(async () => {
record(false);
});
// It still says which clause and which source version it belongs to.
expect(firstTake).toContain('lwctranslation1_v1');

// Clear it and record the same clause again.
await act(async () => {
await (controlsProps?.onClearRecording as () => Promise<void>)();
});
await act(async () => {
record(true);
});
expect(controlsProps?.defaultFilename).not.toEqual(firstTake);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import LwcTranslationControls, {
LwcTranslationPhase,
} from './lwcTranslation/LwcTranslationControls';
import { LocalKey } from '../../utils/localUserKey';
import { newTakeToken } from './guidedPhraseRecord/types';
import { Button } from '../../control/Button';

const toolId = 'LwcTranslationTool';
Expand Down Expand Up @@ -106,6 +107,9 @@ export function PassageDetailLwcTranslation({ width }: IProps) {
return stored ?? '';
});
const [showRecorder, setShowRecorder] = useState(false);
// Names this take apart from any other take of the same clause - see
// defaultFilename for why a repeated name plays the wrong audio (TT-7432).
const [takeToken, setTakeToken] = useState(newTakeToken);
const [resetMedia, setResetMedia] = useState(false);
const [canSave, setCanSave] = useState(false);
const [savingRecording, setSavingRecording] = useState(false);
Expand Down Expand Up @@ -228,7 +232,12 @@ export function PassageDetailLwcTranslation({ width }: IProps) {
);

const defaultFilename = useMemo(() => {
const postfix = `lwctranslation${currentIndex + 1}_v${currentVersion}`;
// The token is what tells two takes of this clause apart: the uploaded name
// becomes the media object's name, and `dataPath` resolves a mediafile's
// audioUrl to `<offlineData>/media/<basename>`, so without it a re-recorded
// clause resolves to the cached file of the take it replaced (TT-7432).
const clause = `lwctranslation${currentIndex + 1}_v${currentVersion}`;
const postfix = `${clause}_${takeToken}`;
return passageDefaultFilename(
passage,
plan,
Expand All @@ -245,6 +254,7 @@ export function PassageDetailLwcTranslation({ width }: IProps) {
offline,
currentIndex,
currentVersion,
takeToken,
]);

// TT-7583: this step auto-saves on every rising edge of canSave. A failed
Expand Down Expand Up @@ -520,6 +530,10 @@ export function PassageDetailLwcTranslation({ width }: IProps) {
(active: boolean) => {
if (active) {
recordingActiveRef.current = true;
// This take is its own file, even where the clause's earlier take was
// cleared first (TT-7432). MediaRecord reads defaultFilename when the
// save runs, so a token minted here is the one the take uploads under.
setTakeToken(newTakeToken());
// A new take supersedes any earlier rejected save (TT-7583).
saveRejectedRef.current = false;
setSaveRejected(false);
Expand Down
Loading