Skip to content
93 changes: 71 additions & 22 deletions src/renderer/cypress/support/pbtHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import type { MediaFileD } from '../../src/model';
import { boldDefaultSegParams } from '../../src/components/PassageDetail/carefulSpeech/boldCarefulSpeechSegParams';
import { regionsJsonFromList } from '../../src/components/PassageDetail/carefulSpeech/carefulSpeechBoundary';
import { prettySegment } from '../../src/utils/prettySegment';
import { parseMediaLanguageBcp47 } from '../../src/utils/mediaLanguage';
import { phraseBtBoundaryRegionName } from '../../src/components/PassageDetail/carefulSpeech/matchesGuidedOutputRow';
import PassageDetailPhraseBackTranslate from '../../src/components/PassageDetail/PassageDetailPhraseBackTranslate';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -97,8 +99,8 @@ export const USER_ID = 'user-1';
export const ORG_ID = 'org-1';
export const STEP_LANGUAGE = 'English|en';
export const STEP_BCP47 = 'en';
/** Named-region bucket PBT stores its boundaries in (phraseBtBoundaryRegionName). */
export const BT_REGION_NAME = `BT:${STEP_BCP47}`;
/** Named-region bucket PBT stores its boundaries in. */
export const BT_REGION_NAME = phraseBtBoundaryRegionName(STEP_BCP47);

/** Fake S3 host the intercepted POST hands back for the audio PUT. */
const FAKE_AUDIO_HOST = 'https://pbt-test.invalid';
Expand Down Expand Up @@ -144,6 +146,8 @@ interface ServerState {
generation: number;
/** Outstanding lagged writes, so a reset can cancel them. */
pendingTimers: ReturnType<typeof setTimeout>[];
/** ids requested through GET /mediafiles/:id/fileurl (in request order). */
fileurlRequestedIds: string[];
}

const serverState: ServerState = {
Expand All @@ -154,6 +158,7 @@ const serverState: ServerState = {
nextRemoteId: 1000,
generation: 0,
pendingTimers: [],
fileurlRequestedIds: [],
};

/** Reset the fake server. Call from beforeEach before installing intercepts. */
Expand All @@ -168,6 +173,7 @@ export function resetPbtServer(options?: {
serverState.pendingTimers = [];
serverState.generation += 1;
serverState.takes = [];
serverState.fileurlRequestedIds = [];
serverState.nextRemoteId = 1000;
serverState.putDelayMs = options?.putDelayMs ?? 0;
serverState.fileurlDelayMs = options?.fileurlDelayMs ?? 0;
Expand All @@ -182,6 +188,11 @@ export function postedTakes(): PostedTake[] {
return serverState.takes;
}

/** mediafile ids requested by MediaRecord load calls (GET /mediafiles/:id/fileurl). */
export function fileurlRequestedIds(): string[] {
return serverState.fileurlRequestedIds;
}

/** Make later uploads fail (used mid-spec for save-failure paths). */
export function failNextUploads(putStatus = 500) {
serverState.failPutWithStatus = putStatus;
Expand Down Expand Up @@ -269,6 +280,10 @@ export function installPbtServer() {

// Existing-take load (useFetchUrlNow → GET mediafiles/<id>/fileurl).
cy.intercept('GET', '**/mediafiles/*/fileurl', (req) => {
const match = /\/mediafiles\/([^/]+)\/fileurl(?:\?|$)/.exec(req.url);
if (match?.[1]) {
serverState.fileurlRequestedIds.push(decodeURIComponent(match[1]));
}
req.reply({
statusCode: 200,
delay: serverState.fileurlDelayMs,
Expand Down Expand Up @@ -378,6 +393,15 @@ export interface MountPbtOptions {
segments?: SegmentSpec[];
/** Indices that already have a saved take when the step opens. */
existingTakes?: number[];
/** Exact seeded takes for multi-language and duplicate-segment cases. */
existingTakeRows?: Array<{
segmentIndex: number;
languagebcp47: string;
remoteId: string;
performedBy?: string;
}>;
/** Step language stamped in Step Settings (`Name|bcp47`). */
stepLanguage?: string;
/** Source audio length (s). Must cover the last segment end. */
durationSec?: number;
/** ms before an uploaded take shows up in rowData (0 = immediate). */
Expand Down Expand Up @@ -419,15 +443,15 @@ function interiorBoundaries(
.filter((t) => t > 0.01 && t < durationSec - 0.01);
}

function segmentsAttribute(segments: SegmentSpec[]): string {
function segmentsAttribute(segments: SegmentSpec[], bcp47: string): string {
const regions: IRegion[] = segments.map((s) => ({
start: s.start,
end: s.end,
label: '',
}));
return JSON.stringify([
{
name: BT_REGION_NAME,
name: phraseBtBoundaryRegionName(bcp47),
regionInfo: regionsJsonFromList(regions, boldDefaultSegParams),
},
]);
Expand All @@ -437,7 +461,8 @@ function takeRecord(
id: string,
remoteId: string,
sourceSegments: string,
performedBy: string | null
performedBy: string | null,
languagebcp47 = STEP_LANGUAGE
): MediaFileD {
return {
type: 'mediafile',
Expand All @@ -450,7 +475,7 @@ function takeRecord(
originalFile: `${id}.ogg`,
audioUrl: `${FAKE_AUDIO_HOST}/audio/${id}.wav`,
sourceSegments,
languagebcp47: STEP_LANGUAGE,
languagebcp47,
performedBy,
dateCreated: new Date(2026, 0, 1).toISOString(),
segments: '[]',
Expand All @@ -467,10 +492,12 @@ function takeRecord(

function seedRecords(memory: Memory, options: MountPbtOptions) {
const segments = options.segments ?? [];
const stepLanguage = options.stepLanguage ?? STEP_LANGUAGE;
const stepBcp47 = parseMediaLanguageBcp47(stepLanguage);
const stepTool = JSON.stringify({
tool: 'phraseBackTranslate',
settings: JSON.stringify({
language: STEP_LANGUAGE,
language: stepLanguage,
artifactTypeId: ARTIFACT_TYPE_ID,
}),
});
Expand Down Expand Up @@ -536,7 +563,7 @@ function seedRecords(memory: Memory, options: MountPbtOptions) {
contentType: 'audio/wav',
originalFile: 'vern.wav',
audioUrl: `${FAKE_AUDIO_HOST}/audio/vern.wav`,
segments: segmentsAttribute(segments),
segments: segmentsAttribute(segments, stepBcp47),
transcription: '',
},
relationships: {
Expand All @@ -546,19 +573,37 @@ function seedRecords(memory: Memory, options: MountPbtOptions) {
},
];

(options.existingTakes ?? []).forEach((idx) => {
const seg = segments[idx];
if (!seg) return;
records.push(
takeRecord(
`mf-take-${idx}`,
String(500 + idx),
JSON.stringify({ start: seg.start, end: seg.end, label: '' }),
'Existing Speaker'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any
);
});
if ((options.existingTakeRows?.length ?? 0) > 0) {
options.existingTakeRows?.forEach((row) => {
const seg = segments[row.segmentIndex];
if (!seg) return;
records.push(
takeRecord(
`mf-take-${row.remoteId}`,
row.remoteId,
JSON.stringify({ start: seg.start, end: seg.end, label: '' }),
row.performedBy ?? 'Existing Speaker',
row.languagebcp47
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any
);
});
} else {
(options.existingTakes ?? []).forEach((idx) => {
const seg = segments[idx];
if (!seg) return;
records.push(
takeRecord(
`mf-take-${idx}`,
String(500 + idx),
JSON.stringify({ start: seg.start, end: seg.end, label: '' }),
'Existing Speaker',
stepLanguage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any
);
});
}

memory.cache.update((t) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -693,7 +738,11 @@ function PbtHarnessInner({ options, memory, blob }: HarnessProps) {
id,
take.remoteId,
take.sourceSegments,
take.performedBy
take.performedBy,
// The real pull-after-upload returns what was posted; stamping
// every uploaded take English hid whether the step language ever
// reached the upload at all.
take.languagebcp47
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ jest.mock('./carefulSpeech/useGuidedPhraseSegments', () => ({
setPhraseSegString: jest.fn(),
bootstrapped: true,
ensureSegments: jest.fn().mockResolvedValue(true),
resetForMediafile: jest.fn(),
resetForScope: jest.fn(),
resegmentWithParams: jest.fn().mockResolvedValue(false),
resetToDefaultSegments: jest.fn().mockResolvedValue(false),
persistPhraseSegments: jest.fn().mockResolvedValue(undefined),
Expand Down
Loading
Loading