diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 809c4f402..5500dbe07 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.27.0 +version: 1.27.1 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/srv/homepage-service.js b/srv/homepage-service.js index 37f05caae..1dfa43545 100644 --- a/srv/homepage-service.js +++ b/srv/homepage-service.js @@ -301,6 +301,14 @@ async function _devtoberfestAlways(db) { // the flag. Always included / region-agnostic (like Devtoberfest). Each card // links to its session URL, falling back to the /teched/ landing page. Returns // [] when the flag is off, no upcoming sessions exist, or the query fails. +// +// #2417 — Individual TechEd sessions are always surfaced as VIRTUAL on the band, +// regardless of the session's physical venue (BERLIN/VIRTUAL). The band card is +// a catalog link into the TechEd session catalog, not attendance guidance: a +// Berlin session is still watchable online, and treating it as in-person made +// it land in the EMEA (in-person) region section, which is wrong. Mapping every +// session to region 'VIRTUAL' / isVirtual:true keeps them out of the physical +// region lanes and shows them only under the "Virtual only" (or "All") filter. async function _techedAlways(db) { if (!isFlagEnabled('TECHED_HOMEPAGE_ENABLED')) return []; try { @@ -314,18 +322,18 @@ async function _techedAlways(db) { .limit(3) ); return (rows ?? []).map(s => { - const isVirtual = s.venue === 'VIRTUAL'; return { title: s.title || 'SAP TechEd 2026', startsAt: s.scheduledStart || null, endsAt: s.scheduledEnd || null, - location: s.room || (isVirtual ? 'Virtual' : 'Berlin'), + location: 'Virtual', url: s.url || '/teched/', format: 'teched', register: null, eventType: 'teched', - region: isVirtual ? 'VIRTUAL' : 'EMEA', - isVirtual, + // #2417 — always VIRTUAL / region-agnostic (see fn header). + region: 'VIRTUAL', + isVirtual: true, }; }); } catch (err) { diff --git a/srv/lib/devtoberfest-feed.js b/srv/lib/devtoberfest-feed.js index a12b09729..565c27e56 100644 --- a/srv/lib/devtoberfest-feed.js +++ b/srv/lib/devtoberfest-feed.js @@ -31,17 +31,14 @@ function normalizeSlugSet(rows) { return set; } -function assembleFeed({ sessions = [], activities = [], tracks = [], editions = [], activeEditionId = null, speakers = [], sessionSpeakers = [], relatedTechEdBySlug = new Map() }) { +function assembleFeed({ sessions = [], activities = [], tracks = [], editions = [], activeEditionId = null, speakers = [], sessionSpeakers = [], relatedTechEdBySession = new Map() }) { const trackById = new Map(tracks.map((t) => [t.ID, t])); const mapTrack = (id) => trackById.get(id) || {}; // Devtoberfest → TechEd cross-links (issue #2312, feature-flag gated + fail-open; - // an empty map — flag OFF, concept links / planner facade absent — yields []). - const activityById = new Map(activities.map((a) => [a.ID, a])); - const relatedTechEdFor = (session) => { - const act = session.ACTIVITY_ID ? activityById.get(session.ACTIVITY_ID) : null; - const taskSlug = (act?.TASKSLUG || '').toLowerCase(); - return (taskSlug && relatedTechEdBySlug.get(taskSlug)) || []; - }; + // an empty map — flag OFF, concept links absent — yields []). Keyed by + // Devtoberfest session ID: sessions are first-class KG nodes with their own + // concept links (#2311), so the match is session↔session, not via the activity. + const relatedTechEdFor = (session) => relatedTechEdBySession.get(session.ID) || []; const speakerById = new Map(speakers.map((sp) => [sp.ID, sp])); const speakersBySession = new Map(); for (const link of sessionSpeakers) { diff --git a/srv/lib/feature-flags/registry.js b/srv/lib/feature-flags/registry.js index 07e5a5f57..52c35918a 100644 --- a/srv/lib/feature-flags/registry.js +++ b/srv/lib/feature-flags/registry.js @@ -312,7 +312,7 @@ export const FEATURE_FLAGS = [ key: 'TECHED_DEVTOBERFEST_CROSSLINK_ENABLED', label: 'TechEd ↔ Devtoberfest session cross-links', category: 'Knowledge Graph', kind: 'db', imsConfigKey: 'flag.teched.devtoberfestCrosslink', valueType: 'boolean', default: false, issue: '#2312', status: 'dev-only', - description: 'Bidirectional related-session cross-linking between Devtoberfest sessions and SAP TechEd sessions, computed from shared Knowledge-Graph concepts (Devtoberfest session → tutorial via Activity.TASKSLUG → TutorialConceptLinks; TechEd session → TechEdSessionConceptLinks). When ON, the Devtoberfest schedule feed attaches relatedTechEdSessions and /build/teched attaches relatedDevtoberfestSessions (top 3 by concept overlap). Fail-open: when concept links are cold or the cross-container Devtoberfest planner facades are absent (e.g. unit SQLite), the related arrays are empty and nothing throws. DB-driven config (ImsConfig key flag.teched.devtoberfestCrosslink); no env var. Default OFF.', + description: 'Bidirectional related-session cross-linking between Devtoberfest sessions and SAP TechEd sessions, computed from shared Knowledge-Graph concepts. Both are first-class KG nodes (#2311): Devtoberfest session → DevtoberfestSessionConceptLinks; TechEd session → TechEdSessionConceptLinks, over the SAME Concepts registry. When ON, the Devtoberfest schedule feed attaches relatedTechEdSessions (keyed by session ID) and /build/teched attaches relatedDevtoberfestSessions (top 3 by concept overlap). Fail-open: when either concept-link table is cold/empty (e.g. unit SQLite), the related arrays are empty and nothing throws. DB-driven config (ImsConfig key flag.teched.devtoberfestCrosslink); no env var. Default OFF.', howToChange: featureFlagUpsert('TECHED_DEVTOBERFEST_CROSSLINK_ENABLED', 'flag.teched.devtoberfestCrosslink'), }, { diff --git a/srv/lib/teched-devtoberfest-crosslink.js b/srv/lib/teched-devtoberfest-crosslink.js index 53d16f337..473f78d12 100644 --- a/srv/lib/teched-devtoberfest-crosslink.js +++ b/srv/lib/teched-devtoberfest-crosslink.js @@ -2,10 +2,10 @@ // // Bidirectional related-session cross-linking between Devtoberfest sessions and // SAP TechEd sessions, based on shared Knowledge-Graph concepts (issue #2312, -// Unit 9). A Devtoberfest session links to a tutorial via its Activity -// (Activity.TASKSLUG); that tutorial has TutorialConceptLinks → Concepts. A -// TechEd session has TechEdSessionConceptLinks → the SAME Concepts registry. -// Two sessions are "related" when their concept sets overlap; ranked by overlap +// Unit 9). Both session types are first-class KG nodes (issue #2311): a +// Devtoberfest session has DevtoberfestSessionConceptLinks → Concepts, a TechEd +// session has TechEdSessionConceptLinks → the SAME Concepts registry. Two +// sessions are "related" when their concept sets overlap; ranked by overlap // count, capped at the top MAX_RELATED_SESSIONS. // // GATING & FAIL-OPEN CONTRACT (mirrors the KG feature-flag convention): @@ -13,23 +13,20 @@ // TECHED_DEVTOBERFEST_CROSSLINK_ENABLED (ImsConfig flag.teched.devtoberfestCrosslink, // default OFF, dev-only). When OFF the orchestrators return an EMPTY map so // callers attach empty related arrays. -// - Devtoberfest planner entities are @cds.persistence.exists cross-container -// facades — ABSENT on unit SQLite. Concept-link tables may also be empty. -// Every DB read is wrapped so a missing facade / cold KG degrades to empty -// arrays and NEVER throws into feed assembly or the /build/teched handler. +// - Both session concept-link tables are owned entities (no cross-container +// planner facade). They may be empty (cold KG, unit SQLite) — every DB read +// is wrapped so a missing/cold table degrades to empty arrays and NEVER +// throws into feed assembly or the /build/teched handler. // // The pure ranking helpers (buildRelated*) take pre-fetched, normalized inputs // and are trivially unit-testable with no cds/db access. import cds from '@sap/cds'; import { isFlagEnabled } from './feature-flags/db-flags.js'; -import { isVisibleStatus } from './devtoberfest-feed.js'; const LOG = cds.log('teched-crosslink'); -const KG_NS = 'com.sap.developers.ims'; const EXT_NS = 'com.sap.developers.ims.external'; -const DTF_NS = 'external.devtoberfest'; const FLAG = 'TECHED_DEVTOBERFEST_CROSSLINK_ENABLED'; @@ -60,16 +57,16 @@ function byOverlapThen(keyFn) { /** * Forward direction (Devtoberfest → TechEd). PURE. * - * @param {Map>} tutorialConceptsBySlug lowercased tutorial slug → concept-id set + * @param {Array<{id,slug,title,sessionCode,taskSlug,conceptIds}>} dtfSessions * @param {Array<{slug,title,sessionCode,venue,url,conceptIds}>} techEdSessions - * @returns {Map} lowercased tutorial slug → top related TechEd sessions + * @returns {Map} Devtoberfest session ID → top related TechEd sessions */ -export function buildRelatedTechEdBySlug(tutorialConceptsBySlug, techEdSessions) { +export function buildRelatedTechEdByDtfSession(dtfSessions, techEdSessions) { const out = new Map(); - if (!(tutorialConceptsBySlug instanceof Map) || tutorialConceptsBySlug.size === 0) return out; + if (!Array.isArray(dtfSessions) || dtfSessions.length === 0) return out; if (!Array.isArray(techEdSessions) || techEdSessions.length === 0) return out; - for (const [slug, concepts] of tutorialConceptsBySlug) { - const cset = toSet(concepts); + for (const d of dtfSessions) { + const cset = toSet(d.conceptIds); if (!cset.size) continue; const scored = []; for (const te of techEdSessions) { @@ -87,7 +84,7 @@ export function buildRelatedTechEdBySlug(tutorialConceptsBySlug, techEdSessions) } if (scored.length) { scored.sort(byOverlapThen((x) => x.slug)); - out.set(slug, scored.slice(0, MAX_RELATED_SESSIONS)); + out.set(d.id, scored.slice(0, MAX_RELATED_SESSIONS)); } } return out; @@ -189,71 +186,38 @@ export function __bustTechEdCacheForTest() { TECHED_CACHE.inflight = null; } -// lowercased tutorial slug → concept-id set (predicate 'teaches'), restricted to -// the requested slug set. Tutorial slugs are lowercase-canonical (CLAUDE.md). -async function loadTutorialConceptsBySlug(slugsLower) { - const set = slugsLower instanceof Set ? slugsLower : new Set(slugsLower || []); - if (!set.size) return new Map(); - const { TutorialConceptLinks, Tutorials } = cds.entities(KG_NS); - const tuts = await SELECT.from(Tutorials).columns('ID', 'slug').where({ slug: { in: [...set] } }); - if (!tuts.length) return new Map(); - const idToSlug = new Map(); - for (const t of tuts) { - if (t.slug) idToSlug.set(t.ID, String(t.slug).toLowerCase()); - } - const tutIds = [...idToSlug.keys()]; - const links = await SELECT.from(TutorialConceptLinks) - .columns('tutorial_ID', 'concept_ID') - .where({ tutorial_ID: { in: tutIds }, predicate: 'teaches' }); - const map = new Map(); +// Owned Devtoberfest sessions with their FIRST-CLASS concept-id sets, read from +// DevtoberfestSessions + DevtoberfestSessionConceptLinks (issue #2311 — sessions +// are KG nodes in their own right, extracted by the fetch-devtoberfest-sessions +// job). No cross-container planner facade, no Activity/tutorial walk, no edition +// scoping — the KG ingest already tracks only the current-edition catalog. On +// unit SQLite the tables exist but are empty, so this returns [] (fail-soft). +// Only sessions with a non-empty concept set are returned. Metadata-only columns +// (no LargeString `description`), so plain CDS QL is LOB-safe on HANA. +async function loadDevtoberfestSessionsWithConcepts() { + const { DevtoberfestSessions, DevtoberfestSessionConceptLinks } = cds.entities(EXT_NS); + const sessions = await SELECT.from(DevtoberfestSessions) + .columns('ID', 'slug', 'title', 'sessionCode', 'activityTaskSlug'); + if (!sessions.length) return []; + const links = await SELECT.from(DevtoberfestSessionConceptLinks).columns('session_ID', 'concept_ID'); + const bySession = new Map(); for (const l of links) { if (!l.concept_ID) continue; - const slug = idToSlug.get(l.tutorial_ID); - if (!slug) continue; - if (!map.has(slug)) map.set(slug, new Set()); - map.get(slug).add(l.concept_ID); + if (!bySession.has(l.session_ID)) bySession.set(l.session_ID, new Set()); + bySession.get(l.session_ID).add(l.concept_ID); } - return map; -} - -// Visible Devtoberfest sessions carrying the concept set of the tutorial they -// link to (via Activity.TASKSLUG). Reads the cross-container planner facades, -// which are absent on unit SQLite — the caller's try/catch turns that into an -// empty result (fail-soft). Scoped to the CURRENT edition (ISCURRENT) so the -// reverse cross-links stay symmetric with the edition-scoped forward feed and -// never surface sessions from a past Devtoberfest year. Only sessions with a -// non-empty concept set are returned. -async function loadDevtoberfestSessionsWithConcepts() { - let ext; - try { ext = cds.entities(DTF_NS); } catch { ext = null; } - if (!ext?.Session || !ext?.Activity || !ext?.Track || !ext?.Edition) return []; - - const currentEdition = await SELECT.one.from(ext.Edition).columns('ID').where({ ISCURRENT: true }); - if (!currentEdition?.ID) return []; - const tracks = await SELECT.from(ext.Track).columns('ID').where({ EDITION_ID: currentEdition.ID }); - const trackIds = tracks.map((t) => t.ID); - if (!trackIds.length) return []; - - const sessions = await SELECT.from(ext.Session) - .columns('ID', 'TITLE', 'SESSIONCODE', 'STATUS', 'ACTIVITY_ID') - .where({ TRACK_ID: { in: trackIds } }); - const visible = sessions.filter(isVisibleStatus); - if (!visible.length) return []; - const activityIds = [...new Set(visible.map((s) => s.ACTIVITY_ID).filter(Boolean))]; - const activities = activityIds.length - ? await SELECT.from(ext.Activity).columns('ID', 'TASKSLUG').where({ ID: { in: activityIds } }) - : []; - const slugByActivity = new Map( - activities.map((a) => [a.ID, (a.TASKSLUG || '').toLowerCase()]), - ); - const taskSlugs = new Set([...slugByActivity.values()].filter(Boolean)); - const tutMap = await loadTutorialConceptsBySlug(taskSlugs); const out = []; - for (const s of visible) { - const taskSlug = s.ACTIVITY_ID ? slugByActivity.get(s.ACTIVITY_ID) : ''; - const conceptIds = (taskSlug && tutMap.get(taskSlug)) || new Set(); - if (conceptIds.size) { - out.push({ id: s.ID, title: s.TITLE, sessionCode: s.SESSIONCODE, taskSlug, conceptIds }); + for (const s of sessions) { + const conceptIds = bySession.get(s.ID); + if (conceptIds?.size) { + out.push({ + id: s.ID, + slug: s.slug, + title: s.title, + sessionCode: s.sessionCode, + taskSlug: (s.activityTaskSlug || '').toLowerCase(), + conceptIds, + }); } } return out; @@ -262,26 +226,23 @@ async function loadDevtoberfestSessionsWithConcepts() { // ── orchestrators (flag-gated, fail-open) ─────────────────────────────────── /** - * Forward: lowercased tutorial slug → top related TechEd sessions. Empty map + * Forward: Devtoberfest session ID → top related TechEd sessions. Empty map * when the flag is OFF, inputs are empty, or any read fails. Never throws. * - * @param {Set|string[]} taskSlugsLower Devtoberfest activity task slugs (lowercased) * @returns {Promise>} */ -export async function computeRelatedTechEdBySlug(taskSlugsLower) { +export async function computeRelatedTechEdByDtfSession() { try { if (!isFlagEnabled(FLAG)) return new Map(); - const slugs = taskSlugsLower instanceof Set ? taskSlugsLower : new Set(taskSlugsLower || []); - if (!slugs.size) return new Map(); await cds.connect.to('db'); - const [techEd, tutMap] = await Promise.all([ + const [techEd, dtf] = await Promise.all([ loadTechEdSessionsWithConcepts(), - loadTutorialConceptsBySlug(slugs), + loadDevtoberfestSessionsWithConcepts(), ]); - if (!techEd.length || tutMap.size === 0) return new Map(); - return buildRelatedTechEdBySlug(tutMap, techEd); + if (!techEd.length || !dtf.length) return new Map(); + return buildRelatedTechEdByDtfSession(dtf, techEd); } catch (err) { - LOG.warn('computeRelatedTechEdBySlug failed; returning empty:', err?.message); + LOG.warn('computeRelatedTechEdByDtfSession failed; returning empty:', err?.message); return new Map(); } } diff --git a/srv/routes/devtoberfest-schedule.js b/srv/routes/devtoberfest-schedule.js index 5658664dd..ce7007f05 100644 --- a/srv/routes/devtoberfest-schedule.js +++ b/srv/routes/devtoberfest-schedule.js @@ -6,7 +6,7 @@ // soft (503 / empty) when the facades are unavailable (e.g. unit SQLite). import cds from '@sap/cds'; import { assembleFeed, completedActivityPoints, normalizeSlugSet, filterCompletionsWithinWindow } from '../lib/devtoberfest-feed.js'; -import { computeRelatedTechEdBySlug } from '../lib/teched-devtoberfest-crosslink.js'; +import { computeRelatedTechEdByDtfSession } from '../lib/teched-devtoberfest-crosslink.js'; import { buildICS, buildEventICS, addToCalendarLinks } from '../lib/devtoberfest-ical.js'; import { buildRSS } from '../lib/devtoberfest-rss.js'; import { resolveUser } from '../lib/resolve-user.js'; @@ -79,20 +79,18 @@ async function loadAssembledFeed(req) { } // Devtoberfest → TechEd related-session cross-links (issue #2312). Flag-gated - // + fail-open inside computeRelatedTechEdBySlug; the outer guard is belt-and- - // suspenders so a cross-link fault never blanks the schedule feed. - let relatedTechEdBySlug = new Map(); + // + fail-open inside computeRelatedTechEdByDtfSession; the outer guard is belt- + // and-suspenders so a cross-link fault never blanks the schedule feed. Keyed by + // Devtoberfest session ID — the sessions carry their own KG concepts (#2311). + let relatedTechEdBySession = new Map(); try { - const taskSlugs = new Set( - activities.map((a) => (a.TASKSLUG || '').toLowerCase()).filter(Boolean), - ); - relatedTechEdBySlug = await computeRelatedTechEdBySlug(taskSlugs); + relatedTechEdBySession = await computeRelatedTechEdByDtfSession(); } catch (err) { LOG.warn('teched cross-link failed, feed proceeds without it:', err.message); - relatedTechEdBySlug = new Map(); + relatedTechEdBySession = new Map(); } - const feed = assembleFeed({ sessions, activities, tracks, editions, activeEditionId: editionId, speakers, sessionSpeakers, relatedTechEdBySlug }); + const feed = assembleFeed({ sessions, activities, tracks, editions, activeEditionId: editionId, speakers, sessionSpeakers, relatedTechEdBySession }); return { ok: true, editionId, feed }; } diff --git a/test/unit/homepage-events-teched.test.js b/test/unit/homepage-events-teched.test.js index 3938ebc6a..9a1bb6652 100644 --- a/test/unit/homepage-events-teched.test.js +++ b/test/unit/homepage-events-teched.test.js @@ -89,7 +89,7 @@ describe('HomepageService.events() — TechEd band (#2312)', () => { expect(rows).toHaveLength(0); }); - it('flag ON → upcoming TechEd session appears as an EventCard, region-agnostic', async () => { + it('flag ON → upcoming TechEd session appears as an EventCard, always virtual (#2417)', async () => { await setTechedFlag(true); await seedTechEd({ title: 'TechEd Berlin Keynote' }); const svc = await cds.connect.to('HomepageService'); @@ -99,8 +99,28 @@ describe('HomepageService.events() — TechEd band (#2312)', () => { expect(card).toBeTruthy(); expect(card.eventType).toBe('teched'); expect(card.url).toBe('https://example.com/teched/session'); - expect(card.isVirtual).toBe(false); - expect(card.region).toBe('EMEA'); + // #2417 — a BERLIN (in-person) session is still surfaced as VIRTUAL on the + // band so it stays out of the EMEA/in-person region lanes. + expect(card.isVirtual).toBe(true); + expect(card.region).toBe('VIRTUAL'); + expect(card.location).toBe('Virtual'); + }); + + it('flag ON → BERLIN session is presented as virtual, never as an in-person card (#2417)', async () => { + await setTechedFlag(true); + await seedTechEd({ title: 'TechEd Berlin Keynote', venue: 'BERLIN', room: 'Hall A' }); + const svc = await cds.connect.to('HomepageService'); + // Under any filter the TechEd card is region-agnostic (always merged), but it + // must present as virtual — never carrying an in-person region/location that + // would slot it into the EMEA/in-person lane (#2417). + for (const region of ['ALL', 'EMEA', 'AMERICAS', 'APJ', 'VIRTUAL']) { + const rows = await svc.send('events', { region }); + const card = rows.find(r => r.title === 'TechEd Berlin Keynote'); + expect(card, `card present under ${region}`).toBeTruthy(); + expect(card.isVirtual, `isVirtual under ${region}`).toBe(true); + expect(card.region, `region under ${region}`).toBe('VIRTUAL'); + expect(card.location, `location under ${region}`).toBe('Virtual'); + } }); it('flag ON → url falls back to /teched/ when the session has none', async () => { diff --git a/test/unit/teched-devtoberfest-crosslink.test.js b/test/unit/teched-devtoberfest-crosslink.test.js index c319eff27..aeee9bcf8 100644 --- a/test/unit/teched-devtoberfest-crosslink.test.js +++ b/test/unit/teched-devtoberfest-crosslink.test.js @@ -2,13 +2,15 @@ // // Unit 9 of #2312 — bidirectional TechEd ↔ Devtoberfest related-session // cross-linking. Covers the pure ranking helpers, the assembleFeed wiring, and -// the flag-gated / fail-open orchestrators against in-memory SQLite. +// the flag-gated / fail-open orchestrators against in-memory SQLite. Both +// session types are first-class KG nodes (#2311): the match is session↔session +// via their own concept-link tables (no activity/tutorial walk). import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import cds from '@sap/cds'; import { - buildRelatedTechEdBySlug, + buildRelatedTechEdByDtfSession, buildRelatedDevtoberfestByTechEd, - computeRelatedTechEdBySlug, + computeRelatedTechEdByDtfSession, computeRelatedDevtoberfestByTechEd, MAX_RELATED_SESSIONS, __bustTechEdCacheForTest, @@ -17,7 +19,6 @@ import { assembleFeed } from '../../srv/lib/devtoberfest-feed.js'; import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; const FLAG = 'TECHED_DEVTOBERFEST_CROSSLINK_ENABLED'; -const KG_NS = 'com.sap.developers.ims'; const EXT_NS = 'com.sap.developers.ims.external'; const project = cds.test('serve', '--project', '.', '--in-memory'); @@ -25,7 +26,7 @@ void project; // ── PURE helpers ──────────────────────────────────────────────────────────── -describe('buildRelatedTechEdBySlug (pure)', () => { +describe('buildRelatedTechEdByDtfSession (pure)', () => { const techEd = [ { slug: 'te-a', title: 'TechEd A', sessionCode: 'A1', venue: 'BERLIN', url: 'u/a', conceptIds: new Set(['c1', 'c2']) }, { slug: 'te-b', title: 'TechEd B', sessionCode: 'B1', venue: 'VIRTUAL', url: 'u/b', conceptIds: new Set(['c1']) }, @@ -33,9 +34,9 @@ describe('buildRelatedTechEdBySlug (pure)', () => { ]; it('ranks by concept-overlap count and excludes zero-overlap sessions', () => { - const tut = new Map([['my-tut', new Set(['c1', 'c2'])]]); - const out = buildRelatedTechEdBySlug(tut, techEd); - const related = out.get('my-tut'); + const dtf = [{ id: 'd1', slug: 'dtf-1', title: 'DTF 1', conceptIds: new Set(['c1', 'c2']) }]; + const out = buildRelatedTechEdByDtfSession(dtf, techEd); + const related = out.get('d1'); expect(related.map((r) => r.slug)).toEqual(['te-a', 'te-b']); // te-c has 0 overlap expect(related[0].sharedConceptCount).toBe(2); expect(related[1].sharedConceptCount).toBe(1); @@ -45,15 +46,15 @@ describe('buildRelatedTechEdBySlug (pure)', () => { const many = Array.from({ length: 6 }, (_, i) => ({ slug: `te-${i}`, title: `T${i}`, conceptIds: new Set(['c1']), })); - const tut = new Map([['my-tut', new Set(['c1'])]]); - const out = buildRelatedTechEdBySlug(tut, many); - expect(out.get('my-tut')).toHaveLength(MAX_RELATED_SESSIONS); + const dtf = [{ id: 'd1', conceptIds: new Set(['c1']) }]; + const out = buildRelatedTechEdByDtfSession(dtf, many); + expect(out.get('d1')).toHaveLength(MAX_RELATED_SESSIONS); }); it('returns an empty map for empty / absent inputs (fail-open shape)', () => { - expect(buildRelatedTechEdBySlug(new Map(), techEd).size).toBe(0); - expect(buildRelatedTechEdBySlug(new Map([['s', new Set(['c1'])]]), []).size).toBe(0); - expect(buildRelatedTechEdBySlug(null, techEd).size).toBe(0); + expect(buildRelatedTechEdByDtfSession([], techEd).size).toBe(0); + expect(buildRelatedTechEdByDtfSession([{ id: 'd1', conceptIds: new Set(['c1']) }], []).size).toBe(0); + expect(buildRelatedTechEdByDtfSession(null, techEd).size).toBe(0); }); }); @@ -79,9 +80,9 @@ describe('assembleFeed relatedTechEdSessions wiring', () => { const sessions = [{ ID: 's1', TITLE: 'Intro', TRACK_ID: 't1', STATUS: 'Confirmed', ACTIVITY_ID: 'a1' }]; const activities = [{ ID: 'a1', TITLE: 'Do Intro', STATUS: 'Confirmed', TASKTYPE: 'TUTORIAL', TASKSLUG: 'Intro-Slug', TRACK_ID: 't1' }]; - it('attaches related TechEd sessions resolved via the activity task slug (lowercased)', () => { - const relatedTechEdBySlug = new Map([['intro-slug', [{ slug: 'te-a', title: 'TechEd A', sharedConceptCount: 2 }]]]); - const out = assembleFeed({ sessions, activities, tracks, editions: [], activeEditionId: null, relatedTechEdBySlug }); + it('attaches related TechEd sessions keyed by Devtoberfest session ID', () => { + const relatedTechEdBySession = new Map([['s1', [{ slug: 'te-a', title: 'TechEd A', sharedConceptCount: 2 }]]]); + const out = assembleFeed({ sessions, activities, tracks, editions: [], activeEditionId: null, relatedTechEdBySession }); expect(out.sessions[0].relatedTechEdSessions).toHaveLength(1); expect(out.sessions[0].relatedTechEdSessions[0].slug).toBe('te-a'); }); @@ -96,23 +97,21 @@ describe('assembleFeed relatedTechEdSessions wiring', () => { describe('cross-link orchestrators (SQLite)', () => { async function seed() { - const { Tutorials, Concepts, TutorialConceptLinks } = cds.entities(KG_NS); - const { TechEdSessions, TechEdSessionConceptLinks } = cds.entities(EXT_NS); - await DELETE.from(TutorialConceptLinks); + const { Concepts } = cds.entities('com.sap.developers.ims'); + const { + TechEdSessions, TechEdSessionConceptLinks, + DevtoberfestSessions, DevtoberfestSessionConceptLinks, + } = cds.entities(EXT_NS); await DELETE.from(TechEdSessionConceptLinks); await DELETE.from(TechEdSessions); - await DELETE.from(Tutorials); + await DELETE.from(DevtoberfestSessionConceptLinks); + await DELETE.from(DevtoberfestSessions); await DELETE.from(Concepts); await INSERT.into(Concepts).entries([ { ID: 'c1', slug: 'concept-1', status: 'ACTIVE' }, { ID: 'c2', slug: 'concept-2', status: 'ACTIVE' }, { ID: 'c3', slug: 'concept-3', status: 'ACTIVE' }, ]); - await INSERT.into(Tutorials).entries([{ ID: 'tut1', slug: 'my-tutorial', title: 'My Tutorial' }]); - await INSERT.into(TutorialConceptLinks).entries([ - { ID: 'l1', tutorial_ID: 'tut1', concept_ID: 'c1', predicate: 'teaches' }, - { ID: 'l2', tutorial_ID: 'tut1', concept_ID: 'c2', predicate: 'teaches' }, - ]); await INSERT.into(TechEdSessions).entries([ { ID: 'te1', slug: 'teched-a', title: 'TechEd A', sessionCode: 'A1', venue: 'BERLIN', url: 'u/a', sourceId: 'src-a' }, { ID: 'te2', slug: 'teched-b', title: 'TechEd B', sessionCode: 'B1', venue: 'VIRTUAL', url: 'u/b', sourceId: 'src-b' }, @@ -120,9 +119,18 @@ describe('cross-link orchestrators (SQLite)', () => { ]); await INSERT.into(TechEdSessionConceptLinks).entries([ { ID: 'k1', session_ID: 'te1', concept_ID: 'c1' }, - { ID: 'k2', session_ID: 'te1', concept_ID: 'c2' }, // te-a overlap 2 - { ID: 'k3', session_ID: 'te2', concept_ID: 'c1' }, // te-b overlap 1 - { ID: 'k4', session_ID: 'te3', concept_ID: 'c3' }, // te-c overlap 0 + { ID: 'k2', session_ID: 'te1', concept_ID: 'c2' }, // te-a overlap 2 with d1 + { ID: 'k3', session_ID: 'te2', concept_ID: 'c1' }, // te-b overlap 1 with d1 + { ID: 'k4', session_ID: 'te3', concept_ID: 'c3' }, // te-c overlap 0 with d1 + ]); + await INSERT.into(DevtoberfestSessions).entries([ + { ID: 'd1', slug: 'dtf-a', title: 'DTF A', sessionCode: 'DA', activityTaskSlug: 'my-tutorial', sourceId: 'psrc-a' }, + { ID: 'd2', slug: 'dtf-b', title: 'DTF B', sessionCode: 'DB', activityTaskSlug: 'other', sourceId: 'psrc-b' }, + ]); + await INSERT.into(DevtoberfestSessionConceptLinks).entries([ + { ID: 'p1', session_ID: 'd1', concept_ID: 'c1' }, + { ID: 'p2', session_ID: 'd1', concept_ID: 'c2' }, // d1 overlaps te-a(2), te-b(1) + { ID: 'p3', session_ID: 'd2', concept_ID: 'c3' }, // d2 overlaps te-c only ]); } @@ -133,25 +141,34 @@ describe('cross-link orchestrators (SQLite)', () => { }); afterEach(() => __resetFlagsForTest()); - it('computeRelatedTechEdBySlug populates related sessions ranked by overlap when flag ON', async () => { + it('computeRelatedTechEdByDtfSession ranks related TechEd sessions by overlap when flag ON', async () => { __setFlagForTest(FLAG, true); - const out = await computeRelatedTechEdBySlug(new Set(['my-tutorial'])); - const related = out.get('my-tutorial'); + const out = await computeRelatedTechEdByDtfSession(); + const related = out.get('d1'); expect(related.map((r) => r.slug)).toEqual(['teched-a', 'teched-b']); expect(related[0].sharedConceptCount).toBe(2); expect(related[1].sharedConceptCount).toBe(1); + expect(out.get('d2').map((r) => r.slug)).toEqual(['teched-c']); }); - it('computeRelatedTechEdBySlug returns empty map when flag OFF', async () => { + it('computeRelatedTechEdByDtfSession returns empty map when flag OFF', async () => { __setFlagForTest(FLAG, false); - const out = await computeRelatedTechEdBySlug(new Set(['my-tutorial'])); + const out = await computeRelatedTechEdByDtfSession(); expect(out.size).toBe(0); }); - it('computeRelatedDevtoberfestByTechEd fails soft to empty map when planner facades absent (SQLite)', async () => { + it('computeRelatedDevtoberfestByTechEd ranks related Devtoberfest sessions by overlap when flag ON', async () => { __setFlagForTest(FLAG, true); const out = await computeRelatedDevtoberfestByTechEd(); - expect(out).toBeInstanceOf(Map); - expect(out.size).toBe(0); // DTF facades are @cds.persistence.exists — not created on SQLite + const related = out.get('teched-a'); + expect(related.map((r) => r.sessionId)).toEqual(['d1']); + expect(related[0].sharedConceptCount).toBe(2); + expect(out.get('teched-c').map((r) => r.sessionId)).toEqual(['d2']); + }); + + it('computeRelatedDevtoberfestByTechEd returns empty map when flag OFF', async () => { + __setFlagForTest(FLAG, false); + const out = await computeRelatedDevtoberfestByTechEd(); + expect(out.size).toBe(0); }); });