diff --git a/srv/developer-service.js b/srv/developer-service.js index 50ebf1df7..11ebc036c 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -789,25 +789,33 @@ export default class DeveloperService extends cds.ApplicationService { try { // A completion is event-agnostic — it carries no event_ID. Match it to // any event whose [startDate,endDate] window contains the completion - // instant and broadcast to that event's WS context. (The old code bailed - // on `!result.event_ID`, so normal completions — which never set it — - // never reached the display; see getEventBuckets for the same fix.) + // instant, and label it with the TRACK(s) it belongs to in that event's + // mission — the SAME grouping the display buckets + app-space page use + // (lib/event-path-buckets.js). A tutorial outside every track emits + // nothing (no bar). (The old code bailed on `!result.event_ID` — NULL on + // normal completions — and keyed bucketName on primaryTag, which did not + // match the path-grouped buckets; see getEventBuckets for the fetch fix.) const at = result.completionDate || new Date().toISOString(); const events = await SELECT.from(dbEvents) - .columns('legacyId') + .columns('ID', 'legacyId', 'startDate', 'endDate', 'mission_ID') .where(`startDate <=`, at).and(`endDate >=`, at); if (!events.length) return; - const tutorial = await SELECT.one.from(dbTutorials).where({ legacyId: result.taskLegacyId }); - const user = await SELECT.one.from(dbUsers).where({ ID: result.user_ID }); + const { trackNamesForTutorial } = await import('./lib/event-path-buckets.js'); + const db = await cds.connect.to('db'); - const payload = { - bucketName: tutorial?.primaryTag || 'unknown', - completeDate: new Date().toISOString().slice(0, 10), - tutorialTitle: tutorial?.title || 'Unknown Tutorial', - }; + // Build [{ context, bucketName }] — one per (event, track) the completion lands in. + const targets = []; + for (const ev of events) { + const names = await trackNamesForTutorial(ev, result.taskLegacyId, db); + for (const name of names) targets.push({ context: String(ev.legacyId), bucketName: name }); + } + if (!targets.length) return; - const contexts = events.map(e => String(e.legacyId)); + const tutorial = await SELECT.one.from(dbTutorials).where({ legacyId: result.taskLegacyId }); + const user = await SELECT.one.from(dbUsers).where({ ID: result.user_ID }); + const completeDate = new Date().toISOString().slice(0, 10); + const tutorialTitle = tutorial?.title || 'Unknown Tutorial'; // Broadcast to kiosks (unauthenticated, no user info). // Broadcast to authenticated clients (with user name). @@ -827,16 +835,19 @@ export default class DeveloperService extends cds.ApplicationService { const privileged = cds.User.privileged; const broadcast = async () => { const eventStream = await cds.connect.to('EventStreamService'); - await eventStream.tx({ user: privileged }, tx => - tx.emit('tutorialCompleted', payload, { contexts }) - ); const display = await cds.connect.to('DisplayService'); - await display.tx({ user: privileged }, tx => - tx.emit('tutorialCompleted', - { ...payload, userName: user?.displayName || 'Someone' }, - { contexts } - ) - ); + for (const t of targets) { + const payload = { bucketName: t.bucketName, completeDate, tutorialTitle }; + await eventStream.tx({ user: privileged }, tx => + tx.emit('tutorialCompleted', payload, { contexts: [t.context] }) + ); + await display.tx({ user: privileged }, tx => + tx.emit('tutorialCompleted', + { ...payload, userName: user?.displayName || 'Someone' }, + { contexts: [t.context] } + ) + ); + } }; broadcast().catch(e => cds.log('ws').warn('Failed to emit tutorialCompleted:', e.message)); } catch (e) { diff --git a/srv/display-service.js b/srv/display-service.js index 13cee921c..7fcf5cda3 100644 --- a/srv/display-service.js +++ b/srv/display-service.js @@ -1,5 +1,6 @@ import cds from '@sap/cds'; -import { computeBuckets, computeBurnup, computeTrackStats, computeCompletionSpeed, computeLeaderboard } from './lib/event-statistics.js'; +import { computeBurnup, computeTrackStats, computeCompletionSpeed, computeLeaderboard } from './lib/event-statistics.js'; +import { computePathBuckets } from './lib/event-path-buckets.js'; import { cached } from './lib/ttl-cache.js'; const CACHE_TTL = 600_000; // 600 seconds, matches IMS leaderboard cache @@ -25,13 +26,13 @@ export default class DisplayService extends cds.ApplicationService { const event = await resolveEvent(eventLegacyId, req); if (!event) return; + // Per-track ("CompletionPath") completion counts, grouped the SAME way as + // the app-space page (event → mission → paths), window-scoped. Shared with + // the anonymous EventStreamService.getEventBuckets via lib/event-path-buckets.js. + // Replaces the old event_ID join (NULL on all normal completions → always []). return cached(`buckets:${eventLegacyId}`, CACHE_TTL, async () => { - const records = await SELECT.from(TaskRecords).where({ - event_ID: event.ID, - taskType: 'TUTORIAL', - status: { in: COMPLETION_STATUSES } - }); - return computeBuckets(records); + const db = await cds.connect.to('db'); + return computePathBuckets(event, db); }); }); diff --git a/srv/event-stream-service.js b/srv/event-stream-service.js index c847b3699..e36dd1949 100644 --- a/srv/event-stream-service.js +++ b/srv/event-stream-service.js @@ -1,16 +1,13 @@ import cds from '@sap/cds'; -import { computeBuckets } from './lib/event-statistics.js'; +import { computePathBuckets } from './lib/event-path-buckets.js'; import { cached } from './lib/ttl-cache.js'; const CACHE_TTL = 600_000; -// "Has-ever-completed" semantic (issue #600) — see DisplayService for rationale. -const COMPLETION_STATUSES = ['COMPLETED', 'SUPERSEDED']; - export default class EventStreamService extends cds.ApplicationService { async init() { - const { Events, TaskRecords } = cds.entities('com.sap.developers.ims'); + const { Events } = cds.entities('com.sap.developers.ims'); this.on('getEventBuckets', async (req) => { const { eventLegacyId } = req.data; @@ -18,20 +15,14 @@ export default class EventStreamService extends cds.ApplicationService { const event = await SELECT.one.from(Events).where({ legacyId: eventLegacyId }); if (!event) return req.reject(404, `Event with legacy ID ${eventLegacyId} not found`); - // Scope completions by the event's date window, NOT by TaskRecords.event_ID. - // event_ID is only stamped by the TechEd scan-in / app-space flow; normal - // tutorial completions leave it NULL, so an event_ID join reported 0 for - // every date-windowed event (TechEd 2026 showed "0 completed" despite live - // activity). The display is already scoped to one event via its URL - // eventId, so [startDate,endDate] unambiguously selects the right rows. - // No window configured → nothing to count. + // Buckets = per-track ("CompletionPath") completion counts, grouped the + // SAME way the app-space page groups them: event → mission → paths, with + // completions matched to a path via CompletionPathItems (+ GroupPathItems + // for GROUP items). Window-scoped to [startDate,endDate]. See + // lib/event-path-buckets.js. Returns [] when there's no window/mission/path. const buckets = await cached(`es-buckets:${eventLegacyId}`, CACHE_TTL, async () => { - if (!event.startDate || !event.endDate) return []; - const records = await SELECT.from(TaskRecords) - .where({ taskType: 'TUTORIAL', status: { in: COMPLETION_STATUSES } }) - .and(`completionDate >=`, event.startDate) - .and(`completionDate <=`, event.endDate); - return computeBuckets(records); + const db = await cds.connect.to('db'); + return computePathBuckets(event, db); }); return { diff --git a/srv/lib/event-path-buckets.js b/srv/lib/event-path-buckets.js new file mode 100644 index 000000000..6d2e3b5e6 --- /dev/null +++ b/srv/lib/event-path-buckets.js @@ -0,0 +1,161 @@ +import cds from '@sap/cds'; + +const NS = 'com.sap.developers.ims'; + +/** + * Resolve the event's mission ID using the SAME precedence as app-space + * (getAppSpaceProgress): forward Events.mission_ID, else the Missions.event_ID + * backlink (the only link the admin UI exposes), tie-broken by published then + * legacyId. Returns null when no mission is configured. + */ +export async function resolveEventMissionId(event, db) { + const { Missions } = cds.entities(NS); + if (event?.mission_ID) return event.mission_ID; + const linked = await db.run( + SELECT.one.from(Missions).columns('ID') + .where({ event_ID: event.ID }) + .orderBy('published desc', 'legacyId') + ); + return linked?.ID ?? null; +} + +/** + * Build the map (taskType:taskLegacyId) → Set(path display-name) for an event's + * mission tracks — direct CompletionPathItems plus GROUP items expanded through + * GroupPathItems to their child tutorials. This is the single source of truth + * for "which track(s) does a completed tutorial belong to", shared by the + * display bucket aggregation (computePathBuckets) and the realtime socket emit + * so both label completions with the SAME track names the app-space page uses. + * + * Returns { pathNameByToken: Map>, pathNameById: Map }. + */ +export async function buildTrackMap(missionId, db) { + const { CompletionPaths, CompletionPathItems, GroupPathItems } = cds.entities(NS); + const pathNameByToken = new Map(); + + const paths = await db.run( + SELECT.from(CompletionPaths).columns('ID', 'name').where({ mission_ID: missionId }).orderBy('legacyId') + ); + const pathNameById = new Map(paths.map(p => [p.ID, p.name || ''])); + if (!paths.length) return { pathNameByToken, pathNameById }; + + const pathIds = paths.map(p => p.ID); + const items = await db.run( + SELECT.from(CompletionPathItems).columns('path_ID', 'taskType', 'taskLegacyId', 'group_ID') + .where({ path_ID: { in: pathIds } }) + ); + + const addToken = (tok, pid) => { + const name = pathNameById.get(pid); + if (name == null) return; + if (!pathNameByToken.has(tok)) pathNameByToken.set(tok, new Set()); + pathNameByToken.get(tok).add(name); + }; + + const groupToPaths = new Map(); // group_ID → Set(path_ID) + for (const it of items) { + if (!it.path_ID) continue; + if (it.taskType === 'GROUP' && it.group_ID) { + if (!groupToPaths.has(it.group_ID)) groupToPaths.set(it.group_ID, new Set()); + groupToPaths.get(it.group_ID).add(it.path_ID); + } else if (it.taskLegacyId != null) { + addToken(`${it.taskType}:${it.taskLegacyId}`, it.path_ID); + } + } + + const groupIds = [...groupToPaths.keys()]; + if (groupIds.length) { + const gpi = await db.run( + SELECT.from(GroupPathItems).columns('group_ID', 'tutorial.legacyId as tutLegacyId') + .where({ group_ID: { in: groupIds } }) + ); + for (const row of gpi) { + if (row.tutLegacyId == null) continue; + for (const pid of groupToPaths.get(row.group_ID) ?? []) { + addToken(`TUTORIAL:${row.tutLegacyId}`, pid); + } + } + } + + return { pathNameByToken, pathNameById }; +} + +/** + * Track names a single completed tutorial belongs to within an event's mission. + * Used by the realtime socket emit to label a live completion with the SAME + * track name(s) the display buckets use. Returns [] if the tutorial is not a + * member of any track (→ no bar, consistent with the fetch aggregation). + */ +export async function trackNamesForTutorial(event, tutorialLegacyId, db) { + const missionId = await resolveEventMissionId(event, db); + if (!missionId) return []; + const { pathNameByToken } = await buildTrackMap(missionId, db); + return [...(pathNameByToken.get(`TUTORIAL:${tutorialLegacyId}`) ?? [])]; +} + +/** + * Per-path ("track") completion buckets for an event's display dashboard. + * + * A "track" is one CompletionPaths row under the event's Mission — the SAME + * definition the app-space page renders (srv/developer-service.js + * getAppSpaceProgress). We deliberately reuse that resolution rather than + * invent a new grouping: + * + * event → mission (Events.mission_ID, else Missions.event_ID backlink) + * → CompletionPaths where mission_ID (each row = one track) + * → CompletionPathItems where path_ID in … ((taskType,taskLegacyId) → path) + * + GroupPathItems for GROUP items (child tutorial → group → path) + * + * Then window-scoped TUTORIAL completions ([startDate,endDate]) are mapped to + * their path(s) and counted. A completion can belong to more than one path + * (a tutorial reused across tracks) — it is counted once per path it belongs + * to, matching how app-space shows the tutorial in each track. + * + * Returns [{ bucketName: path.name, count, percentage }] sorted desc by count. + * Empty array when the event has no window, no mission, or no paths. + * + * @param {object} event resolved Events row (needs ID, startDate, endDate, mission_ID) + * @param {object} db cds db service + */ +export async function computePathBuckets(event, db) { + if (!event?.startDate || !event?.endDate) return []; + + const { TaskRecords } = cds.entities(NS); + + // 1. Resolve the event's mission + the (taskType:taskLegacyId) → track-name + // map — SAME resolution the app-space page uses (shared helpers). + const missionId = await resolveEventMissionId(event, db); + if (!missionId) return []; + const { pathNameByToken } = await buildTrackMap(missionId, db); + if (pathNameByToken.size === 0) return []; + + // 2. Window-scoped TUTORIAL completions → count distinct users per track. + const records = await db.run( + SELECT.from(TaskRecords).columns('user_ID', 'taskLegacyId') + .where({ taskType: 'TUTORIAL', status: { in: ['COMPLETED', 'SUPERSEDED'] } }) + .and(`completionDate >=`, event.startDate) + .and(`completionDate <=`, event.endDate) + ); + + const seen = new Set(); // dedupe (user, track-name) + const countByName = new Map(); + for (const r of records) { + const names = pathNameByToken.get(`TUTORIAL:${r.taskLegacyId}`); + if (!names) continue; + for (const name of names) { + const key = `${r.user_ID}|${name}`; + if (seen.has(key)) continue; + seen.add(key); + countByName.set(name, (countByName.get(name) || 0) + 1); + } + } + + const total = [...countByName.values()].reduce((s, n) => s + n, 0); + return [...countByName.entries()] + .map(([bucketName, count]) => ({ + bucketName, + count, + percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, + })) + .sort((a, b) => b.count - a.count); +} diff --git a/test/display-service.test.js b/test/display-service.test.js index 98bdf77a1..05205da89 100644 --- a/test/display-service.test.js +++ b/test/display-service.test.js @@ -24,7 +24,7 @@ describe('DisplayService', () => { describe('Event Data', () => { beforeAll(async () => { - const { Events, Users, Tutorials, Missions, TaskRecords } = cds.entities('com.sap.developers.ims'); + const { Events, Users, Tutorials, Missions, CompletionPaths, CompletionPathItems, TaskRecords } = cds.entities('com.sap.developers.ims'); await INSERT.into(Events).entries({ ID: 'aaaaaaaa-1111-0000-0000-000000000001', @@ -42,11 +42,18 @@ describe('DisplayService', () => { ]); await INSERT.into(Tutorials).entries([ - { ID: 'aaaaaaaa-1111-0000-0000-000000000021', title: 'Tut 1', slug: 'disp-tut-1', legacyId: 5021 }, + { ID: 'aaaaaaaa-1111-0000-0000-000000000021', title: 'Tut 1', slug: 'disp-tut-1', legacyId: 5021, status: 'ACTIVE' }, ]); + // Mission linked to the event (backlink), with one track containing Tut 1. await INSERT.into(Missions).entries([ - { ID: 'aaaaaaaa-1111-0000-0000-000000000031', title: 'Mission Alpha', legacyId: 5031 }, + { ID: 'aaaaaaaa-1111-0000-0000-000000000031', title: 'Mission Alpha', legacyId: 5031, event_ID: 'aaaaaaaa-1111-0000-0000-000000000001', published: true }, + ]); + await INSERT.into(CompletionPaths).entries([ + { ID: 'aaaaaaaa-1111-0000-0000-000000000041', name: 'Track One', legacyId: 5041, mission_ID: 'aaaaaaaa-1111-0000-0000-000000000031' }, + ]); + await INSERT.into(CompletionPathItems).entries([ + { ID: 'aaaaaaaa-1111-0000-0000-000000000051', legacyId: 5051, path_ID: 'aaaaaaaa-1111-0000-0000-000000000041', taskType: 'TUTORIAL', taskLegacyId: 5021, itemOrder: 1 }, ]); await INSERT.into(TaskRecords).entries([ @@ -69,13 +76,14 @@ describe('DisplayService', () => { expect(data.value[1].cumulative).toBe(3); }); - it('getEventBuckets returns user distribution', async () => { + it('getEventBuckets returns per-track completion counts', async () => { const { status, data } = await project.get( '/display/getEventBuckets(eventLegacyId=5001)', displayAuth ); expect(status).toBe(200); - expect(data.value.length).toBeGreaterThan(0); - expect(data.value[0].bucketName).toBe('1 tutorial'); + // Tut 1 is in "Track One"; 3 users completed it in-window → Track One = 3. + expect(data.value.length).toBe(1); + expect(data.value[0].bucketName).toBe('Track One'); expect(data.value[0].count).toBe(3); }); diff --git a/test/lib/event-path-buckets.test.js b/test/lib/event-path-buckets.test.js new file mode 100644 index 000000000..873992c90 --- /dev/null +++ b/test/lib/event-path-buckets.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { computePathBuckets } from '../../srv/lib/event-path-buckets.js'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +describe('computePathBuckets', () => { + let db; + + beforeAll(async () => { + db = await cds.connect.to('db'); + const { Events, Missions, Users, Tutorials, Groups, CompletionPaths, CompletionPathItems, GroupPathItems, TaskRecords } = + cds.entities('com.sap.developers.ims'); + + const now = Date.now(); + const start = new Date(now - 86400_000).toISOString(); + const end = new Date(now + 86400_000).toISOString(); + + const evID = 'event-pb-1'; + const misID = 'mission-pb-1'; + await INSERT.into(Events).entries({ ID: evID, legacyId: 900001, name: 'PB Event', startDate: start, endDate: end }); + // mission linked via backlink (event_ID) — the FK path getAppSpaceProgress falls back to + await INSERT.into(Missions).entries({ ID: misID, legacyId: 900002, title: 'PB Mission', event_ID: evID, published: true }); + + // two tracks + await INSERT.into(CompletionPaths).entries( + { ID: 'path-A', legacyId: 900010, name: 'Track Alpha', mission_ID: misID }, + { ID: 'path-B', legacyId: 900011, name: 'Track Beta', mission_ID: misID }, + ); + + await INSERT.into(Tutorials).entries( + { ID: 'tut-1', legacyId: 901, title: 'T1', status: 'ACTIVE' }, + { ID: 'tut-2', legacyId: 902, title: 'T2', status: 'ACTIVE' }, + { ID: 'tut-3', legacyId: 903, title: 'T3', status: 'ACTIVE' }, // reached via GROUP on Track Beta + ); + await INSERT.into(Groups).entries({ ID: 'grp-1', legacyId: 950, title: 'G1' }); + + // Track Alpha: direct TUTORIAL items 901, 902 + // Track Beta: a GROUP item (grp-1) that contains tutorial 903 + await INSERT.into(CompletionPathItems).entries( + { ID: 'cpi-1', legacyId: 9601, path_ID: 'path-A', taskType: 'TUTORIAL', taskLegacyId: 901, itemOrder: 1 }, + { ID: 'cpi-2', legacyId: 9602, path_ID: 'path-A', taskType: 'TUTORIAL', taskLegacyId: 902, itemOrder: 2 }, + { ID: 'cpi-3', legacyId: 9603, path_ID: 'path-B', taskType: 'GROUP', taskLegacyId: 950, group_ID: 'grp-1', itemOrder: 1 }, + ); + await INSERT.into(GroupPathItems).entries( + { ID: 'gpi-1', legacyId: 9701, group_ID: 'grp-1', tutorial_ID: 'tut-3', itemOrder: 1 }, + ); + + await INSERT.into(Users).entries( + { ID: 'u-1', legacyId: 9801 }, { ID: 'u-2', legacyId: 9802 }); + + const mid = new Date(now).toISOString(); + const before = new Date(now - 10 * 86400_000).toISOString(); // outside window + await INSERT.into(TaskRecords).entries( + // Alpha: u1 did 901 + 902 (2 distinct), u2 did 901 → Alpha count = 2 users (u1,u2) + { ID: 'tr-1', user_ID: 'u-1', taskLegacyId: 901, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + { ID: 'tr-2', user_ID: 'u-1', taskLegacyId: 902, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + { ID: 'tr-3', user_ID: 'u-2', taskLegacyId: 901, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + // Beta: u1 did 903 (the GROUP child) → Beta count = 1 + { ID: 'tr-4', user_ID: 'u-1', taskLegacyId: 903, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + // Out of window — excluded + { ID: 'tr-5', user_ID: 'u-2', taskLegacyId: 902, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: before }, + // Not a path tutorial — excluded from any track + { ID: 'tr-6', user_ID: 'u-2', taskLegacyId: 999, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + ); + }); + + it('groups completions by CompletionPath track, counting distinct users per path, with GROUP expansion', async () => { + const event = await SELECT.one.from(cds.entities('com.sap.developers.ims').Events).where({ legacyId: 900001 }); + const buckets = await computePathBuckets(event, db); + const byName = Object.fromEntries(buckets.map(b => [b.bucketName, b.count])); + + // Alpha: u1 (901 or 902) + u2 (901) = 2 distinct users + expect(byName['Track Alpha']).toBe(2); + // Beta: u1 via GROUP child 903 = 1 (proves GroupPathItems expansion works) + expect(byName['Track Beta']).toBe(1); + // tutorial 999 (not a path item) contributes to no track + expect(buckets.length).toBe(2); + // sorted desc by count + expect(buckets[0].bucketName).toBe('Track Alpha'); + }); + + it('returns empty when the event has no date window', async () => { + const { Events } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Events).entries({ ID: 'event-pb-nowin', legacyId: 900099, name: 'No Window' }); + const event = await SELECT.one.from(Events).where({ legacyId: 900099 }); + expect(await computePathBuckets(event, db)).toEqual([]); + }); +}); diff --git a/test/lib/event-stream-service.test.js b/test/lib/event-stream-service.test.js index a6d6e72b3..4624083eb 100644 --- a/test/lib/event-stream-service.test.js +++ b/test/lib/event-stream-service.test.js @@ -26,43 +26,55 @@ describe('EventStreamService', () => { expect(res.response?.status ?? res.status).toBe(404); }); - it('getEventBuckets counts completions within the event date window, ignoring event_ID', async () => { - const db = await cds.connect.to('db'); - const { Events, Users, TaskRecords } = cds.entities('com.sap.developers.ims'); + it('getEventBuckets returns per-track buckets grouped by CompletionPath, window-scoped', async () => { + const { Events, Missions, Users, Tutorials, CompletionPaths, CompletionPathItems, TaskRecords } = + cds.entities('com.sap.developers.ims'); - const eventID = cds.utils.uuid(); + const eventID = cds.utils.uuid(), misID = cds.utils.uuid(); await INSERT.into(Events).entries({ - ID: eventID, legacyId: 777001, name: 'Window Test Event', - eventType: 'TECHED', + ID: eventID, legacyId: 777001, name: 'Window Test Event', eventType: 'TECHED', startDate: '2026-10-26T14:00:00Z', endDate: '2026-10-29T16:00:00Z', }); + await INSERT.into(Missions).entries({ ID: misID, legacyId: 777050, title: 'M', event_ID: eventID, published: true }); + await INSERT.into(CompletionPaths).entries( + { ID: 'esp-A', legacyId: 777060, name: 'Alpha', mission_ID: misID }, + { ID: 'esp-B', legacyId: 777061, name: 'Beta', mission_ID: misID }, + ); + await INSERT.into(Tutorials).entries( + { ID: 'est-1', legacyId: 7701, title: 'T1', status: 'ACTIVE' }, + { ID: 'est-2', legacyId: 7702, title: 'T2', status: 'ACTIVE' }, + ); + await INSERT.into(CompletionPathItems).entries( + { ID: 'escpi-1', legacyId: 777070, path_ID: 'esp-A', taskType: 'TUTORIAL', taskLegacyId: 7701, itemOrder: 1 }, + { ID: 'escpi-2', legacyId: 777071, path_ID: 'esp-B', taskType: 'TUTORIAL', taskLegacyId: 7702, itemOrder: 1 }, + ); const u1 = cds.utils.uuid(), u2 = cds.utils.uuid(); - await INSERT.into(Users).entries( - { ID: u1, legacyId: 777101 }, { ID: u2, legacyId: 777102 }); - + await INSERT.into(Users).entries({ ID: u1, legacyId: 777101 }, { ID: u2, legacyId: 777102 }); await INSERT.into(TaskRecords).entries( - // In window, no event_ID — must still count (the bug: event_ID join missed these) - { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 1, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T10:00:00Z' }, - { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 2, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T11:00:00Z' }, - { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 1, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-28T09:00:00Z' }, - // Before window — excluded - { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 3, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-01T09:00:00Z' }, - // After window — excluded - { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 4, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-11-01T09:00:00Z' }, - // In window but wrong taskType — excluded - { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 5, taskType: 'MISSION', status: 'COMPLETED', completionDate: '2026-10-27T12:00:00Z' }, + // Alpha: u1 + u2 complete 7701 (in window) → Alpha = 2 + { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 7701, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T10:00:00Z' }, + { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 7701, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-28T09:00:00Z' }, + // Beta: u1 completes 7702 → Beta = 1 + { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 7702, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T11:00:00Z' }, + // Out of window — excluded + { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 7702, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-01T09:00:00Z' }, + // Not a path tutorial — contributes to no track + { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 7799, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T12:00:00Z' }, ); const res = await project.get('/rest/event-stream/getEventBuckets(eventLegacyId=777001)'); const body = res.data ?? res; - // u1 = 2 tutorials, u2 = 1 tutorial → buckets: {"1 tutorial":1, "2 tutorials":1} const byName = Object.fromEntries(body.buckets.map(b => [b.bucketName, b.count])); - expect(byName['2 tutorials']).toBe(1); - expect(byName['1 tutorial']).toBe(1); + expect(byName['Alpha']).toBe(2); + expect(byName['Beta']).toBe(1); + expect(body.buckets.length).toBe(2); await DELETE.from(TaskRecords).where({ user_ID: { in: [u1, u2] } }); await DELETE.from(Users).where({ ID: { in: [u1, u2] } }); + await DELETE.from(CompletionPathItems).where({ path_ID: { in: ['esp-A', 'esp-B'] } }); + await DELETE.from(CompletionPaths).where({ ID: { in: ['esp-A', 'esp-B'] } }); + await DELETE.from(Missions).where({ ID: misID }); await DELETE.from(Events).where({ ID: eventID }); });