Skip to content

Commit c20e16f

Browse files
committed
Add viewer ID tracking for embedded content progress aggregation
ContentViewer now generates unique viewer IDs and appends them to all interaction events, allowing parent components to track which embedded viewer emitted each event. SafeHtml5RendererIndex uses this to aggregate progress from multiple embedded viewers (e.g., videos in HTML5 content), combining scroll-based progress with embedded viewer progress using dynamic weighting.
1 parent 4597681 commit c20e16f

2 files changed

Lines changed: 323 additions & 5 deletions

File tree

kolibri/plugins/safe_html5_viewer/frontend/views/SafeHtml5RendererIndex.vue

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@
1414
role="region"
1515
:aria-label="$tr('articleContent')"
1616
>
17-
<SafeHTML :html="html" />
17+
<SafeHTML
18+
:html="html"
19+
@startTracking="handleViewerStartTracking"
20+
@stopTracking="handleViewerStopTracking"
21+
@updateProgress="handleViewerUpdateProgress"
22+
@addProgress="handleViewerAddProgress"
23+
@finished="handleViewerFinished"
24+
/>
1825
</div>
1926
</div>
2027

@@ -52,12 +59,41 @@
5259
html: null,
5360
scrollBasedProgress: 0,
5461
debouncedHandleScroll: null,
62+
// Track embedded viewers for progress aggregation
63+
// Using object instead of Map for Vue 2.7 reactivity
64+
// Structure: { viewerId: { progress: number } }
65+
embeddedViewers: {},
66+
// Guard to prevent emitting 'finished' multiple times
67+
hasEmittedFinished: false,
5568
};
5669
},
5770
computed: {
5871
entry() {
5972
return (this.options && this.options.entry) || 'index.html';
6073
},
74+
// Count of registered embedded viewers
75+
viewerCount() {
76+
return Object.keys(this.embeddedViewers).length;
77+
},
78+
// Aggregated progress using dynamic weighting
79+
// If no embedded viewers: progress = scrollBasedProgress
80+
// If viewers exist: progress = (scrollBasedProgress + avgViewerProgress) / 2
81+
aggregatedProgress() {
82+
if (this.viewerCount === 0) {
83+
return this.scrollBasedProgress;
84+
}
85+
86+
let totalViewerProgress = 0;
87+
for (const viewer of Object.values(this.embeddedViewers)) {
88+
totalViewerProgress += viewer.progress;
89+
}
90+
const avgViewerProgress = totalViewerProgress / this.viewerCount;
91+
92+
// Dynamic weighting: 50% scroll, 50% viewers
93+
// A live average, so a viewer registering mid-session lowers it; the backend
94+
// keeps the maximum, so learner-facing progress never regresses.
95+
return (this.scrollBasedProgress + avgViewerProgress) / 2;
96+
},
6197
},
6298
async created() {
6399
const storageUrl = this.defaultFile.storage_url;
@@ -100,18 +136,66 @@
100136
}
101137
});
102138
},
139+
140+
// Handle startTracking from embedded viewers
141+
handleViewerStartTracking(viewerId) {
142+
if (viewerId && !this.embeddedViewers[viewerId]) {
143+
this.$set(this.embeddedViewers, viewerId, { progress: 0 });
144+
}
145+
},
146+
147+
// Handle stopTracking from embedded viewers
148+
handleViewerStopTracking(viewerId) {
149+
if (viewerId) {
150+
this.$delete(this.embeddedViewers, viewerId);
151+
}
152+
},
153+
154+
// Handle updateProgress from embedded viewers
155+
handleViewerUpdateProgress(progress, viewerId) {
156+
if (viewerId && this.embeddedViewers[viewerId]) {
157+
this.$set(this.embeddedViewers, viewerId, {
158+
...this.embeddedViewers[viewerId],
159+
progress: Math.min(1, Math.max(0, progress)),
160+
});
161+
}
162+
},
163+
164+
// Handle addProgress from embedded viewers
165+
handleViewerAddProgress(delta, viewerId) {
166+
if (viewerId) {
167+
const viewer = this.embeddedViewers[viewerId];
168+
if (viewer) {
169+
const newProgress = Math.min(1, Math.max(0, viewer.progress + delta));
170+
this.$set(this.embeddedViewers, viewerId, {
171+
...viewer,
172+
progress: newProgress,
173+
});
174+
}
175+
}
176+
},
177+
178+
// Handle finished from embedded viewers
179+
handleViewerFinished(viewerId) {
180+
if (viewerId && this.embeddedViewers[viewerId]) {
181+
this.$set(this.embeddedViewers, viewerId, { progress: 1 });
182+
}
183+
},
184+
103185
recordProgress() {
104186
let progress;
105187
if (this.forceDurationBasedProgress) {
106188
progress = this.durationBasedProgress;
107189
} else {
108-
// Use scroll events to track progress
109-
progress = this.scrollBasedProgress;
190+
// Use aggregated progress from scroll + embedded viewers
191+
progress = this.aggregatedProgress;
110192
}
111193
this.$emit('updateProgress', progress);
112-
113-
if (progress >= 1) {
194+
// Scroll-to-bottom completes the article; embedded viewers
195+
// registered at that point must also be at 1.
196+
if (progress >= 1 && !this.hasEmittedFinished) {
114197
this.$emit('finished');
198+
this.hasEmittedFinished = true;
115199
}
116200
this.pollProgress();
117201
},
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { render, screen } from '@testing-library/vue';
2+
import { createTranslator } from 'kolibri/utils/i18n';
3+
// eslint-disable-next-line import-x/named
4+
import useContentViewer, { useContentViewerMock } from 'kolibri/composables/useContentViewer';
5+
import SafeHtml5RendererIndex from '../SafeHtml5RendererIndex.vue';
6+
7+
const { articleContent$ } = createTranslator(
8+
SafeHtml5RendererIndex.name,
9+
SafeHtml5RendererIndex.$trs,
10+
);
11+
12+
let mockEmitFromSafeHTML;
13+
14+
jest.mock('kolibri', () => ({
15+
canHandleElement: jest.fn(() => false),
16+
}));
17+
18+
jest.mock('kolibri/composables/useContentViewer');
19+
20+
// Mock SafeHTML to render html content and provide controllable event emission
21+
// for testing how the parent aggregates progress from embedded viewers.
22+
jest.mock('kolibri-common/components/SafeHTML', () => ({
23+
createSafeHTML: () => ({
24+
name: 'SafeHTML',
25+
props: { html: String },
26+
created() {
27+
mockEmitFromSafeHTML = (event, ...args) => this.$emit(event, ...args);
28+
},
29+
render(h) {
30+
return h('div', { domProps: { innerHTML: this.html || '' } });
31+
},
32+
}),
33+
}));
34+
35+
jest.mock('kolibri-common/components/SafeHTML/style.scss', () => ({}));
36+
jest.mock('kolibri-zip', () => {
37+
return jest.fn().mockImplementation(() => ({
38+
file: jest.fn().mockResolvedValue({
39+
toString: () => '<h1>Mocked HTML content</h1>',
40+
}),
41+
}));
42+
});
43+
44+
const renderComponent = () => {
45+
return render(SafeHtml5RendererIndex);
46+
};
47+
48+
describe('SafeHtml5RendererIndex progress aggregation', () => {
49+
beforeEach(() => {
50+
useContentViewer.mockImplementation(() =>
51+
useContentViewerMock({ defaultFile: { storage_url: 'mock://test.html' } }),
52+
);
53+
jest.useFakeTimers();
54+
mockEmitFromSafeHTML = null;
55+
});
56+
57+
afterEach(() => {
58+
jest.useRealTimers();
59+
});
60+
61+
async function renderAndLoad() {
62+
const result = renderComponent();
63+
await screen.findByRole('region', { name: articleContent$() });
64+
return result;
65+
}
66+
67+
function getLastEmittedProgress(emitted) {
68+
jest.advanceTimersByTime(5000);
69+
const events = emitted().updateProgress;
70+
return events[events.length - 1][0];
71+
}
72+
73+
// In jsdom, scrollHeight and clientHeight are both 0, so maxScroll = 0
74+
// and handleScroll sets scrollBasedProgress = 1 (content considered fully read).
75+
function simulateFullScroll() {
76+
const wrapper = document.querySelector('[data-testid="safe-html-wrapper"]');
77+
wrapper.dispatchEvent(new Event('scroll'));
78+
jest.advanceTimersByTime(150); // flush debounce
79+
}
80+
81+
it('reports scroll-based progress when no embedded viewers exist', async () => {
82+
const { emitted } = await renderAndLoad();
83+
expect(getLastEmittedProgress(emitted)).toBe(0);
84+
});
85+
86+
it('averages scroll and viewer progress with one viewer', async () => {
87+
const { emitted } = await renderAndLoad();
88+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
89+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
90+
// scroll=0, viewer=0.5, aggregated=(0+0.5)/2=0.25
91+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
92+
});
93+
94+
it('averages scroll and average viewer progress with multiple viewers', async () => {
95+
const { emitted } = await renderAndLoad();
96+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
97+
mockEmitFromSafeHTML('startTracking', 'viewer-2');
98+
mockEmitFromSafeHTML('updateProgress', 0.25, 'viewer-1');
99+
mockEmitFromSafeHTML('updateProgress', 0.75, 'viewer-2');
100+
// scroll=0, viewer avg=(0.25+0.75)/2=0.5, aggregated=(0+0.5)/2=0.25
101+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
102+
});
103+
104+
it('accumulates delta progress via addProgress', async () => {
105+
const { emitted } = await renderAndLoad();
106+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
107+
mockEmitFromSafeHTML('addProgress', 0.25, 'viewer-1');
108+
mockEmitFromSafeHTML('addProgress', 0.25, 'viewer-1');
109+
// viewer=0.5, scroll=0, aggregated=(0+0.5)/2=0.25
110+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
111+
});
112+
113+
it('clamps negative progress to 0', async () => {
114+
const { emitted } = await renderAndLoad();
115+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
116+
mockEmitFromSafeHTML('updateProgress', -0.5, 'viewer-1');
117+
// clamped to 0, aggregated=(0+0)/2=0
118+
expect(getLastEmittedProgress(emitted)).toBe(0);
119+
});
120+
121+
it('clamps progress above 1 to 1', async () => {
122+
const { emitted } = await renderAndLoad();
123+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
124+
mockEmitFromSafeHTML('updateProgress', 1.5, 'viewer-1');
125+
// clamped to 1, aggregated=(0+1)/2=0.5
126+
expect(getLastEmittedProgress(emitted)).toBe(0.5);
127+
});
128+
129+
it('reverts to scroll-only progress after viewer unregisters', async () => {
130+
const { emitted } = await renderAndLoad();
131+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
132+
mockEmitFromSafeHTML('updateProgress', 0.8, 'viewer-1');
133+
mockEmitFromSafeHTML('stopTracking', 'viewer-1');
134+
// no viewers left, progress=scrollBasedProgress=0
135+
expect(getLastEmittedProgress(emitted)).toBe(0);
136+
});
137+
138+
it('does not re-register an already registered viewer', async () => {
139+
const { emitted } = await renderAndLoad();
140+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
141+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
142+
// Re-registering should not reset progress
143+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
144+
// Still one viewer at 0.5, aggregated=(0+0.5)/2=0.25
145+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
146+
});
147+
148+
it('ignores events without a viewerId', async () => {
149+
const { emitted } = await renderAndLoad();
150+
mockEmitFromSafeHTML('startTracking', null);
151+
mockEmitFromSafeHTML('updateProgress', 0.5, null);
152+
mockEmitFromSafeHTML('addProgress', 0.5, null);
153+
mockEmitFromSafeHTML('finished', null);
154+
mockEmitFromSafeHTML('stopTracking', null);
155+
// No viewers registered, progress=scroll=0
156+
expect(getLastEmittedProgress(emitted)).toBe(0);
157+
});
158+
159+
it('ignores updates to unregistered viewers', async () => {
160+
const { emitted } = await renderAndLoad();
161+
mockEmitFromSafeHTML('updateProgress', 0.5, 'unknown');
162+
mockEmitFromSafeHTML('addProgress', 0.5, 'unknown');
163+
mockEmitFromSafeHTML('finished', 'unknown');
164+
// No viewers, progress=scroll=0
165+
expect(getLastEmittedProgress(emitted)).toBe(0);
166+
});
167+
168+
it('does not emit finished when progress is below 1', async () => {
169+
const { emitted } = await renderAndLoad();
170+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
171+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
172+
jest.advanceTimersByTime(5000);
173+
expect(emitted().finished).toBeUndefined();
174+
});
175+
176+
it('does not emit finished when viewers are incomplete', async () => {
177+
const { emitted } = await renderAndLoad();
178+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
179+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
180+
simulateFullScroll();
181+
// scroll=1, viewer=0.5, aggregated=(1+0.5)/2=0.75 and viewer not complete
182+
expect(emitted().finished).toBeUndefined();
183+
});
184+
185+
it('emits finished when scroll is complete and no embedded viewers exist', async () => {
186+
const { emitted } = await renderAndLoad();
187+
simulateFullScroll();
188+
// scroll=1, no viewers, aggregated=1
189+
expect(emitted().finished).toHaveLength(1);
190+
});
191+
192+
it('emits finished when scroll is complete and all viewers are finished', async () => {
193+
const { emitted } = await renderAndLoad();
194+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
195+
mockEmitFromSafeHTML('startTracking', 'viewer-2');
196+
mockEmitFromSafeHTML('finished', 'viewer-1');
197+
mockEmitFromSafeHTML('finished', 'viewer-2');
198+
simulateFullScroll();
199+
// scroll=1, both viewers complete at progress=1
200+
// aggregated=(1+1)/2=1
201+
expect(emitted().finished).toHaveLength(1);
202+
});
203+
204+
it('emits finished when a viewer reaches progress 1 without emitting finished', async () => {
205+
const { emitted } = await renderAndLoad();
206+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
207+
mockEmitFromSafeHTML('updateProgress', 1, 'viewer-1');
208+
simulateFullScroll();
209+
expect(emitted().finished).toHaveLength(1);
210+
});
211+
212+
it('emits finished on duration-based progress regardless of embedded viewers', async () => {
213+
useContentViewer.mockImplementation(() =>
214+
useContentViewerMock({
215+
defaultFile: { storage_url: 'mock://test.html' },
216+
forceDurationBasedProgress: true,
217+
durationBasedProgress: 1,
218+
}),
219+
);
220+
const { emitted } = await renderAndLoad();
221+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
222+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
223+
jest.advanceTimersByTime(5000);
224+
expect(emitted().finished).toHaveLength(1);
225+
});
226+
227+
it('emits finished only once across multiple recordProgress calls', async () => {
228+
const { emitted } = await renderAndLoad();
229+
simulateFullScroll(); // triggers recordProgress via handleScroll
230+
jest.advanceTimersByTime(5000); // triggers another recordProgress via poll
231+
jest.advanceTimersByTime(5000); // and another
232+
expect(emitted().finished).toHaveLength(1);
233+
});
234+
});

0 commit comments

Comments
 (0)