Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.
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
2 changes: 1 addition & 1 deletion projects/sunbird-quml-player-react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@project-sunbird/sunbird-quml-player-web-component-react",
"private": true,
"version": "0.1.10",
"version": "0.1.11",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
5 changes: 4 additions & 1 deletion projects/sunbird-quml-player-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ function resolveConfig(): PlayerConfig {
context: { uid: 'dev-user', sid: 'dev-session', channel: 'dev', host: '' },
// Only set language when ?lang is present; otherwise let the language
// precedence (localStorage['app-language'] → 'en') apply.
config: { language: params.get('lang') ?? undefined, maxAttempts: 3 },
config: { language: params.get('lang') ?? undefined },
// API mode: only an identifier, no embedded sections. Online asset hosts
// come from each media[].baseUrl in the backend response (Angular parity).
data: { identifier },
// maxAttempts is host/backend data (Angular parity: playerConfig.metadata),
// not a player UI setting.
metadata: { maxAttempts: 3 },
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
flex-direction: column;
gap: v.$space-4;
margin-top: v.$space-8;

@include m.short {
margin-top: v.$space-3;
}
}

.actions {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { QumlProvider } from '../../context/QumlContext';
import { MainPlayer } from './MainPlayer';
import type { PlayerConfig } from '../../types';

// Angular parity (main-player.component.ts:249,253,483-485) — maxAttempts is
// host/backend data under `playerConfig.metadata`, not `config`.
const baseData = {
showTimer: false,
sections: [
{
identifier: 's1',
name: 'Section 1',
timeLimits: { questionSet: { max: 0, min: 0 } },
children: [
{
identifier: 'q1',
body: '<p>Q1</p>',
primaryCategory: 'Multiple Choice Question',
interactions: { response1: { options: [{ value: 0, label: 'Apple' }, { value: 1, label: 'Banana' }] } },
responseDeclaration: {
response1: { cardinality: 'single', type: 'integer', correctResponse: { value: 0 } },
},
},
],
},
],
};

const enterAssessment = () => {
fireEvent.click(screen.getByRole('button', { name: /start assessment/i }));
fireEvent.click(screen.getByRole('button', { name: /start section/i }));
};

const submitAssessment = () => {
fireEvent.click(screen.getAllByRole('radio')[0]); // answer correctly
fireEvent.click(screen.getAllByRole('button', { name: /^submit$/i })[0]);
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: /^submit$/i }));
};

describe('MainPlayer — max attempts (Angular parity)', () => {
it('hides Retake on Results once this attempt is the last one allowed', () => {
const cfg: PlayerConfig = {
context: {},
config: { language: 'en' },
metadata: { maxAttempts: 1 },
data: baseData,
};
render(
<QumlProvider playerConfig={cfg}>
<MainPlayer playerConfig={cfg} />
</QumlProvider>,
);
enterAssessment();
submitAssessment();
expect(screen.getByRole('heading', { name: /your results/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /retake/i })).not.toBeInTheDocument();
});

it('keeps Retake when attempts remain', () => {
const cfg: PlayerConfig = {
context: {},
config: { language: 'en' },
metadata: { maxAttempts: 3 },
data: baseData,
};
render(
<QumlProvider playerConfig={cfg}>
<MainPlayer playerConfig={cfg} />
</QumlProvider>,
);
enterAssessment();
submitAssessment();
expect(screen.getByRole('button', { name: /retake/i })).toBeInTheDocument();
});

it('emits an exdata isLastAttempt event when the final attempt starts', () => {
const onPlayerEvent = vi.fn();
const cfg: PlayerConfig = {
context: {},
config: { language: 'en' },
metadata: { maxAttempts: 1 },
data: baseData,
};
render(
<QumlProvider playerConfig={cfg}>
<MainPlayer playerConfig={cfg} onPlayerEvent={onPlayerEvent} />
</QumlProvider>,
);
expect(onPlayerEvent).toHaveBeenCalledWith(
expect.objectContaining({
eid: 'exdata',
edata: expect.objectContaining({
currentattempt: 1,
isLastAttempt: true,
maxLimitExceeded: false,
}),
}),
);
});

it('emits an exdata maxLimitExceeded event when the last attempt is submitted', () => {
const onPlayerEvent = vi.fn();
const cfg: PlayerConfig = {
context: {},
config: { language: 'en' },
metadata: { maxAttempts: 1 },
data: baseData,
};
render(
<QumlProvider playerConfig={cfg}>
<MainPlayer playerConfig={cfg} onPlayerEvent={onPlayerEvent} />
</QumlProvider>,
);
onPlayerEvent.mockClear();
enterAssessment();
submitAssessment();
expect(onPlayerEvent).toHaveBeenCalledWith(
expect.objectContaining({
eid: 'exdata',
edata: expect.objectContaining({
currentattempt: 1,
isLastAttempt: false,
maxLimitExceeded: true,
}),
}),
);
});

it('does not restrict Retake when maxAttempts is not sent (unlimited)', () => {
const cfg: PlayerConfig = {
context: {},
config: { language: 'en' },
data: baseData,
};
render(
<QumlProvider playerConfig={cfg}>
<MainPlayer playerConfig={cfg} />
</QumlProvider>,
);
enterAssessment();
submitAssessment();
expect(screen.getByRole('button', { name: /retake/i })).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, act, within } from '@testing-library/react';
import { QumlProvider } from '../../context/QumlContext';
import { MainPlayer } from './MainPlayer';
import type { PlayerConfig } from '../../types';

// Angular parity (section-player.component.ts:232-235, main-player.component.ts
// :172,257,488-493) — `showTimer` only gates the LIVE widget's visibility; it
// has no bearing on whether the clock is tracked, whether a time limit is
// enforced, or whether the results screen can report duration. These configs
// all set `showTimer: false` (or omit it) while still exercising the clock.

const mcq = (id: string) => ({
identifier: id,
body: `<p>${id}</p>`,
primaryCategory: 'Multiple Choice Question',
interactions: { response1: { options: [{ value: 0, label: 'Apple' }, { value: 1, label: 'Banana' }] } },
responseDeclaration: {
response1: { cardinality: 'single', type: 'integer', correctResponse: { value: 0 } },
},
});

const enterAssessment = () => {
fireEvent.click(screen.getByRole('button', { name: /start assessment/i }));
fireEvent.click(screen.getByRole('button', { name: /start section/i }));
};

describe('MainPlayer — showTimer/summaryType parity (Angular)', () => {
it('auto-submits at time-limit expiry even when showTimer is false (hidden limit is still enforced)', () => {
const cfg: PlayerConfig = {
context: {},
config: { language: 'en' },
data: {
showTimer: false,
timeLimits: { questionSet: { max: 2, min: 0 } },
sections: [{ identifier: 's1', name: 'Section 1', children: [mcq('q1')] }],
},
};
vi.useFakeTimers();
try {
render(
<QumlProvider playerConfig={cfg}>
<MainPlayer playerConfig={cfg} />
</QumlProvider>,
);
enterAssessment();
expect(screen.getByText('Apple')).toBeInTheDocument();
// No visible countdown — showTimer is false.
expect(screen.queryByRole('timer')).not.toBeInTheDocument();
// Past the 2s limit → auto-submit, no confirmation needed.
act(() => vi.advanceTimersByTime(2100));
expect(screen.getByRole('heading', { name: /your results/i })).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});

it('reports duration on Results when showTimer is false and summaryType allows it', () => {
const cfg: PlayerConfig = {
context: {},
config: { language: 'en' },
data: {
showTimer: false,
summaryType: 'Score and Duration',
sections: [{ identifier: 's1', name: 'Section 1', children: [mcq('q1')] }],
},
};
vi.useFakeTimers();
try {
render(
<QumlProvider playerConfig={cfg}>
<MainPlayer playerConfig={cfg} />
</QumlProvider>,
);
enterAssessment();
act(() => vi.advanceTimersByTime(3000)); // 3s elapsed, count-up mode (no time limit)
fireEvent.click(screen.getAllByRole('radio')[0]);
fireEvent.click(screen.getAllByRole('button', { name: /^submit$/i })[0]);
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: /^submit$/i }));
expect(screen.getByRole('heading', { name: /your results/i })).toBeInTheDocument();
expect(screen.getByText(/time taken/i)).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});
Loading
Loading