diff --git a/db/data/com.sap.developers.ims-KttLessons.csv b/db/data/com.sap.developers.ims-KttLessons.csv new file mode 100644 index 000000000..fec730250 --- /dev/null +++ b/db/data/com.sap.developers.ims-KttLessons.csv @@ -0,0 +1,13 @@ +ID;legacyId;slug;unitId;title +9077e000-0000-4000-8000-000000090001;90001;unit-1-platform-lesson-1;unit-1-platform;Who's SAP, and what's BTP? +9077e000-0000-4000-8000-000000090002;90002;unit-1-platform-lesson-2;unit-1-platform;The CAP Stack +9077e000-0000-4000-8000-000000090003;90003;unit-1-platform-lesson-3;unit-1-platform;Deploying & Operating +9077e000-0000-4000-8000-000000090004;90004;unit-2-cds-lesson-1;unit-2-cds;The CDS Language Family +9077e000-0000-4000-8000-000000090005;90005;unit-2-cds-lesson-2;unit-2-cds;Protocols & Standards +9077e000-0000-4000-8000-000000090006;90006;unit-2-cds-lesson-3;unit-2-cds;Tools of the Trade +9077e000-0000-4000-8000-000000090007;90007;unit-3-hana-lesson-1;unit-3-hana;The HANA Universe +9077e000-0000-4000-8000-000000090008;90008;unit-3-hana-lesson-2;unit-3-hana;Integration & Migration +9077e000-0000-4000-8000-000000090009;90009;unit-3-hana-lesson-3;unit-3-hana;Data Engineering Basics +9077e000-0000-4000-8000-000000090010;90010;unit-4-security-lesson-1;unit-4-security;Authentication & Tokens +9077e000-0000-4000-8000-000000090011;90011;unit-4-security-lesson-2;unit-4-security;Identity & Factors +9077e000-0000-4000-8000-000000090012;90012;unit-4-security-lesson-3;unit-4-security;Business & Governance diff --git a/db/ktt.cds b/db/ktt.cds new file mode 100644 index 000000000..0d9c12e79 --- /dev/null +++ b/db/ktt.cds @@ -0,0 +1,13 @@ +namespace com.sap.developers.ims; +using { com.sap.developers.ims as ims, cuid } from './schema'; + +/** Catalog of KTT lessons — metadata only, so a KTT_LESSON completion + row in TaskRecords can join to its title/kind in MyCompletions. + Progress lives in TaskRecords, not here. Seeded from hugo/data/ktt_lessons.json. */ +entity KttLessons : cuid { + legacyId : Integer @assert.unique; // stable int used as TaskRecords.taskLegacyId + slug : String(120) @assert.unique; + unitId : String(60); + title : String(200); + order : Integer default 0; +} diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index 93a5a1fde..2d94fe013 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -3840,6 +3840,48 @@ }, "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_UIEVENT" }, + "com.sap.developers.ims.KttLessons": { + "kind": "entity", + "doc": "Catalog of KTT lessons — metadata only, so a KTT_LESSON completion\nrow in TaskRecords can join to its title/kind in MyCompletions.\nProgress lives in TaskRecords, not here. Seeded from hugo/data/ktt_lessons.json.", + "@cds.persistence.journal": true, + "elements": { + "ID": { + "key": true, + "type": "cds.String", + "length": 36, + "@cds.persistence.name": "ID" + }, + "legacyId": { + "@assert.unique": true, + "type": "cds.Integer", + "@cds.persistence.name": "LEGACYID" + }, + "slug": { + "@assert.unique": true, + "type": "cds.String", + "length": 120, + "@cds.persistence.name": "SLUG" + }, + "unitId": { + "type": "cds.String", + "length": 60, + "@cds.persistence.name": "UNITID" + }, + "title": { + "type": "cds.String", + "length": 200, + "@cds.persistence.name": "TITLE" + }, + "order": { + "type": "cds.Integer", + "default": { + "val": 0 + }, + "@cds.persistence.name": "ORDER" + } + }, + "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_KTTLESSONS" + }, "com.sap.developers.ims.CatGameAwards": { "kind": "entity", "doc": "\"Hit the Cat\" mini-game daily point ledger (issue #2042).\n\nOne row = one calendar day on which a signed-in player earned the daily\ncat-game award during a given Devtoberfest event. The award endpoint\n(POST /api/devtoberfest/cat-game/award) writes exactly one row per\n(user, event, day): 5 points per day, once per day, capped at 100 points\ntotal per event, and only while that event is active.\n\nThe natural (user, event, awardDate) tuple is the PRIMARY KEY — no surrogate\nID — so HANA enforces the once-per-day rule at the DB layer: a second insert\nfor the same day collides on the PK and the handler maps it to\n\"already-today\" (race-safe, unlike a CAP-only @assert.unique which this\nrepo's raw db.run() inserts bypass — see srv/lib/resolve-db-user.js).\n\n`createdAt` (from `managed`) is the actual award instant and is what the\ncross-container GAMEBOARD_BONUS_V1 view exposes as AWARD_DATE, so the\ngameboard's event-window filter always sees an in-window timestamp (the\nendpoint only ever inserts while the event is live).", diff --git a/db/persistence.cds b/db/persistence.cds index bf4a0619a..2fbe94b83 100644 --- a/db/persistence.cds +++ b/db/persistence.cds @@ -56,3 +56,4 @@ annotate ims.ChannelCollections with @cds.persistence.journal; annotate ims.ChannelCollectionItems with @cds.persistence.journal; annotate ims.ChannelTopicMap with @cds.persistence.journal; annotate ims.ChannelSubmissions with @cds.persistence.journal; +annotate ims.KttLessons with @cds.persistence.journal; diff --git a/db/schema.cds b/db/schema.cds index f377adc88..84309c677 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -6,6 +6,7 @@ using from './advocates'; using from './devtoberfest'; using from './homepage'; using from './channels'; +using from './ktt'; // Sequence-backed business ID for backward compatibility with legacy integer IDs aspect LegacyKeyed { @@ -16,7 +17,7 @@ aspect LegacyKeyed { type ExperienceLevel : String(255) enum { beginner; intermediate; advanced; } type TaskStatus : String(50) enum { ACTIVE; INACTIVE; } type MissionType : String(20) enum { SEQUENTIAL; SET; } -type TaskType : String(20) enum { TUTORIAL; GROUP; CHECKPOINT; PUZZLE; PETOBERFEST; } +type TaskType : String(20) enum { TUTORIAL; GROUP; CHECKPOINT; PUZZLE; PETOBERFEST; KTT_LESSON; } // KTT (#KTT) — mostly-fun acronym trainer type EventType : String(20) enum { DEVTOBERFEST; TECHED; CODEJAM; CHALLENGE; OTHER; } aspect TaskBase : cuid, managed, LegacyKeyed { @@ -192,7 +193,7 @@ entity TaskRecords : cuid, managed, LegacyKeyed { // NVARCHAR(5000); the enum is enforced at the CDS layer (@Common.ValueList // dropdowns + admin write-paths) rather than as a DB constraint, so this // change requires no .hdbmigrationtable ALTER. - taskType : String enum { TUTORIAL; MISSION; GROUP; STEP; CHECKPOINT; PUZZLE; PETOBERFEST; }; + taskType : String enum { TUTORIAL; MISSION; GROUP; STEP; CHECKPOINT; PUZZLE; PETOBERFEST; KTT_LESSON; }; // KTT (#KTT) — mostly-fun acronym trainer status : String enum { COMPLETED; IN_PROGRESS; SUPERSEDED; }; progress : Integer default 0; completionTime : Int64; diff --git a/db/src/com.sap.developers.ims.KttLessons.hdbmigrationtable b/db/src/com.sap.developers.ims.KttLessons.hdbmigrationtable new file mode 100644 index 000000000..436da796a --- /dev/null +++ b/db/src/com.sap.developers.ims.KttLessons.hdbmigrationtable @@ -0,0 +1,10 @@ +== version=1 +COLUMN TABLE com_sap_developers_ims_KttLessons ( + ID NVARCHAR(36) NOT NULL, + legacyId INTEGER, + slug NVARCHAR(120), + unitId NVARCHAR(60), + title NVARCHAR(200), + "ORDER" INTEGER DEFAULT 0, + PRIMARY KEY(ID) +) diff --git a/docs/superpowers/plans/2026-09-11-ktt-kasimir-teaches-tlas.md b/docs/superpowers/plans/2026-09-11-ktt-kasimir-teaches-tlas.md new file mode 100644 index 000000000..d40ad4e46 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-ktt-kasimir-teaches-tlas.md @@ -0,0 +1,1152 @@ +# KTT — Kasimir Teaches TLAs — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a mostly-fun `/explore/ktt/` page: a Duolingo-style, story-driven, animated-Kasimir experience that teaches SAP three-letter acronyms, with progress that reuses the platform's existing completion model. + +**Architecture:** A Hugo page under "Explore" mounts a single Vue 3 island (mirroring the `puzzle` island) that runs the whole game. A thin CAP service (`/ktt`) records lesson completions as `TaskRecords` rows (new `KTT_LESSON` task type) so they surface in MyCompletions, syncs local↔HANA progress on login, and returns an optional fail-open AI "banter" line. Lesson content is a git-committed, build-time-generated, human-reviewed JSON data file. + +**Tech Stack:** SAP CAP (Node.js, `cds.ql`), SAP HANA Cloud, Vue 3 + Vite (hugo-apps islands), Hugo, `@cap-js/ai`, Vitest (`--project unit`), Playwright (post-deploy e2e). + +**Spec:** `docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md` + +## Global Constraints + +- **Never write raw SQL** — use `cds.ql` / CQL. (SQLite unit tests only; never SELECT a HANA BLOB — N/A here, no BLOBs.) +- **`@requires` on write paths** — `completeLesson`/`syncProgress` are `@(requires: 'authenticated-user')`; read/`banter` may be `@requires: 'any'`. +- **Feature flags are DB config, never env** — register in `srv/lib/feature-flags/registry.js` (`kind:'db'`), read with `isFlagEnabled('KTT_ENABLED')`. Default OFF. +- **SAP facts grounded, not invented** — TLA→expansion values are verified against `sap-devs-server`/`cds-mcp`/official docs and human-reviewed before commit. AI drafts only jokes + distractors. +- **NGDS autosend excluded for KTT in v1** — do not add a KTT branch to `srv/lib/ngds-client.js`. +- **`ignore-scripts=true` globally** — new build steps must be explicit entries in `build:all`, never `pre*/post*` hooks. +- **New persisted entity ⇒ `db/persistence.cds` journal entry**, and run `npx cds deploy --to sqlite::memory:` before committing model changes. +- **`TaskRecords.taskType` is NVARCHAR** — adding `KTT_LESSON` needs **no** `.hdbmigrationtable` ALTER (comment at `db/schema.cds:191`). +- **Built island JS is content-hashed** (`/js/ktt-.js`); reference it only via `{{ partial "island-src.html" "ktt" }}`, never a literal path. +- **hugo-apps `.vue`/`.ts` tests run from repo root** via `npm test` (`vitest run --project unit`); `.vue` tests need `// @vitest-environment happy-dom` as the first line. +- **Resolve users by `sapId`, not uuid**, when keying test/lookup logic. +- **srv/lib cp-list audit** — if any new file under `srv/lib/` becomes reachable from `srv/lib/content-store.js`, re-walk `.deploy/mta.yaml`'s `srv-qa` `cp` list. (KTT files are self-contained; expected not reachable — verify in Task 12.) + +--- + +## File Structure + +**Backend / model** +- `db/schema.cds` (modify) — add `KTT_LESSON` to `type TaskType` (`:19`) and `TaskRecords.taskType` (`:195`). +- `db/ktt.cds` (create) — `KttLessons` catalog entity. +- `db/persistence.cds` (modify) — journal entry for `KttLessons`. +- `srv/ktt-service.cds` (create) — `@path:'/ktt'` service: `KttLessons` projection + actions. +- `srv/ktt-service.js` (create) — handlers: `completeLesson`, `syncProgress`, `banter`, `getKttProgress`. +- `srv/lib/ktt/completion.js` (create) — pure helper: build the `TaskRecords` entry + idempotency guard (kept out of the `content-store.js` import graph). +- `srv/lib/ktt/merge.js` (create) — pure local↔remote progress merge (union mastered, max XP/streak). +- `srv/lib/user-progress.js` (modify) — surface `KTT_LESSON` in MyCompletions. +- `srv/lib/feature-flags/registry.js` (modify) — register `KTT_ENABLED`. + +**Content pipeline** +- `scripts/generate-ktt-lessons.ts` (create) — build-time generator (facts curated+grounded, jokes/distractors AI-drafted). +- `scripts/lib/ktt-lessons-schema.ts` (create) — shared TS types + `validateLessons()`. +- `hugo/data/ktt_lessons.json` (create) — the reviewed content (source of truth). +- `package.json` (modify) — add `generate-ktt-lessons` npm script + wire into `build:all`. + +**Frontend island (`hugo-apps/src/ktt/`)** +- `main.ts` (create) — mount on `#ktt-mount`. +- `App.vue` (create) — router-less screen switch (landing / map / lesson / results). +- `lib/engine.ts` (create) — pure lesson engine (beat sequencing, drill scoring, re-queue, XP/streak). +- `lib/progress.ts` (create) — localStorage load/save + `mergeProgress`. +- `lib/server.ts` (create) — CAP client (`fetchLessons`, `completeLesson`, `syncProgress`, `fetchBanter`, `isAuthenticated`). +- `components/KasimirStage.vue` (create) — rigged SVG Kasimir + mood state machine. +- `components/SkillTreeMap.vue` (create) — units → lesson nodes. +- `components/Lesson.vue` (create) — story beats + drills. +- `components/Results.vue` (create) — streak/XP/mastered. +- `hugo-apps/vite.config.ts` (modify) — add `ktt` to `rollupOptions.input`. +- `hugo-apps/src/ktt/__tests__/*.test.ts` (create) — engine, merge, KasimirStage, App. + +**Hugo page + nav** +- `hugo/content/explore/ktt/_index.md` (create) — page front-matter. +- `hugo/layouts/explore/ktt.html` (create) — SSR shell + island mount. +- `hugo/data/navigation.yaml` (modify) — Explore menu entry (`:82` group). + +**e2e** +- `test/e2e/ktt.spec.ts` (create) — post-deploy, self-skipping Playwright smoke. + +**Deferred (documented, not built in v1):** unit-level rollup (seed each unit as a mission parent + `CompletionPathItems` `KTT_LESSON:` tokens so `completion-rollup.js:159` produces "unit mastered" rows); endless mode; per-acronym `STEP` rows; NGDS autosend. See "Deferred Work" at end. + +--- + +## Phase A — Data model & backend + +### Task 1: Add `KTT_LESSON` task type + `KttLessons` catalog entity + +**Files:** +- Modify: `db/schema.cds:19` and `db/schema.cds:195` +- Create: `db/ktt.cds` +- Modify: `db/persistence.cds` +- Test: `test/unit/ktt-model.test.js` + +**Interfaces:** +- Produces: `com.sap.developers.ims.KttLessons { key ID: UUID; legacyId: Integer; slug: String(120); unitId: String(60); title: String(200); order: Integer }`; `TaskRecords.taskType` and `type TaskType` both accept `'KTT_LESSON'`. + +- [ ] **Step 1: Write the failing test** — `test/unit/ktt-model.test.js`: + +```js +const cds = require('@sap/cds'); +const { test, expect } = require('vitest'); + +test('TaskRecords accepts KTT_LESSON and KttLessons entity exists', async () => { + const model = await cds.load(['db', 'srv'], { root: process.cwd() }); + const csn = cds.linked(model); + const tr = csn.definitions['com.sap.developers.ims.TaskRecords']; + const enumVals = Object.keys(tr.elements.taskType.enum); + expect(enumVals).toContain('KTT_LESSON'); + expect(csn.definitions['com.sap.developers.ims.KttLessons']).toBeDefined(); + expect(csn.definitions['com.sap.developers.ims.KttLessons'].elements.legacyId).toBeDefined(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/ktt-model.test.js` +Expected: FAIL (`KttLessons` undefined / enum lacks `KTT_LESSON`). + +- [ ] **Step 3: Add the enum values.** In `db/schema.cds`, add `KTT_LESSON;` to the `type TaskType` enum block (near `:19`) and to the `TaskRecords.taskType` inline enum block (near `:195`). Add a trailing comment `// KTT (#KTT) — mostly-fun acronym trainer`. + +- [ ] **Step 4: Create `db/ktt.cds`:** + +```cds +namespace com.sap.developers.ims; +using { com.sap.developers.ims as ims } from './schema'; + +/** Catalog of KTT lessons — metadata only, so a KTT_LESSON completion + row in TaskRecords can join to its title/kind in MyCompletions. + Progress lives in TaskRecords, not here. Seeded from hugo/data/ktt_lessons.json. */ +entity KttLessons : cuid { + legacyId : Integer @assert.unique; // stable int used as TaskRecords.taskLegacyId + slug : String(120) @assert.unique; + unitId : String(60); + title : String(200); + order : Integer default 0; +} +``` + +- [ ] **Step 5: Add the persistence journal entry.** In `db/persistence.cds`, add the `@cds.persistence.journal` annotation for `KttLessons` following the exact pattern of the neighboring entities in that file (mirror the closest `cuid` entity entry). + +- [ ] **Step 6: Verify the model deploys** + +Run: `npx cds deploy --to sqlite::memory:` +Expected: deploys with no error. + +- [ ] **Step 7: Run test to verify it passes** + +Run: `npx vitest run test/unit/ktt-model.test.js` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add db/schema.cds db/ktt.cds db/persistence.cds test/unit/ktt-model.test.js +git commit -m "feat(ktt): add KTT_LESSON task type and KttLessons catalog entity" +``` + +--- + +### Task 2: `KttService` + `completeLesson` (writes TaskRecords) + +**Files:** +- Create: `srv/ktt-service.cds`, `srv/ktt-service.js`, `srv/lib/ktt/completion.js` +- Test: `test/unit/ktt-complete.test.js` + +**Interfaces:** +- Consumes: `com.sap.developers.ims.{TaskRecords, KttLessons, Users}` (Task 1); `getNextLegacyId` from `srv/lib/legacy-id.js`; `stampSubmissionId` from `srv/lib/task-record-submission-id.js`. +- Produces: action `completeLesson(lessonSlug: String, legacyId: Integer, title: String) returns { ok: Boolean; alreadyDone: Boolean }` on service `KttService` at `@path:'/ktt'`; helper `buildKttCompletionEntry({ userId, legacyId, title, attemptNumber })` and `findExistingKttRecord(db, TaskRecords, userId, legacyId)` in `srv/lib/ktt/completion.js`. + +- [ ] **Step 1: Write the failing test** — `test/unit/ktt-complete.test.js`: + +```js +const cds = require('@sap/cds'); +const { expect, test, beforeAll } = require('vitest'); +const { GET, POST } = cds.test('serve', '--in-memory', '--project', process.cwd()); + +let authed; +beforeAll(() => { authed = { auth: { username: 'alice' } }; }); + +test('completeLesson writes one KTT_LESSON TaskRecord and is idempotent', async () => { + const body = { lessonSlug: 'core-1', legacyId: 90001, title: 'Meet the Platform' }; + const r1 = await POST('/ktt/completeLesson', body, authed); + expect(r1.data.ok).toBe(true); + expect(r1.data.alreadyDone).toBe(false); + const r2 = await POST('/ktt/completeLesson', body, authed); + expect(r2.data.alreadyDone).toBe(true); + const db = await cds.connect.to('db'); + const { TaskRecords } = db.entities('com.sap.developers.ims'); + const rows = await SELECT.from(TaskRecords).where({ taskLegacyId: 90001, taskType: 'KTT_LESSON' }); + expect(rows.length).toBe(1); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/ktt-complete.test.js` +Expected: FAIL (`/ktt/completeLesson` 404 — service not defined). + +- [ ] **Step 3: Create `srv/ktt-service.cds`:** + +```cds +using { com.sap.developers.ims as ims } from '../db/ktt'; + +@path: '/ktt' +@requires: 'any' +service KttService { + + @readonly entity Lessons as projection on ims.KttLessons; + + @(requires: 'authenticated-user') + action completeLesson(lessonSlug: String, legacyId: Integer, title: String) + returns { ok: Boolean; alreadyDone: Boolean }; + + @(requires: 'authenticated-user') + action syncProgress(localJson: LargeString) + returns { xp: Integer; streak: Integer; mastered: array of String }; + + function banter(context: String) returns String; +} +``` + +- [ ] **Step 4: Create `srv/lib/ktt/completion.js`:** + +```js +import { stampSubmissionId } from '../task-record-submission-id.js'; + +/** Build a KTT_LESSON TaskRecords entry (does not set ID — CAP default handles it). */ +export function buildKttCompletionEntry({ userId, legacyId, title, attemptNumber = 1, nextLegacyId }) { + return stampSubmissionId({ + user_ID: userId, + taskLegacyId: legacyId, + taskType: 'KTT_LESSON', + status: 'COMPLETED', + progress: 100, + completionDate: new Date().toISOString(), + titleSnapshot: (title || '').slice(0, 255), + legacyId: nextLegacyId, + attemptNumber, + }); +} + +/** Idempotency guard tuple (user_ID, taskLegacyId, KTT_LESSON) excluding SUPERSEDED. */ +export async function findExistingKttRecord(TaskRecords, userId, legacyId) { + return SELECT.one.from(TaskRecords).where({ + user_ID: userId, taskLegacyId: legacyId, taskType: 'KTT_LESSON', status: { '!=': 'SUPERSEDED' }, + }); +} +``` + +- [ ] **Step 5: Create `srv/ktt-service.js`** (`completeLesson` only; other actions added in later tasks): + +```js +import cds from '@sap/cds'; +import { getNextLegacyId } from './lib/legacy-id.js'; +import { buildKttCompletionEntry, findExistingKttRecord } from './lib/ktt/completion.js'; + +export default class KttService extends cds.ApplicationService { + async init() { + const db = await cds.connect.to('db'); + const { TaskRecords, Users } = cds.entities('com.sap.developers.ims'); + + this.on('completeLesson', async (req) => { + const { legacyId, title } = req.data; + const dbUser = await SELECT.one.from(Users).where({ sapId: req.user.id }); + if (!dbUser) return req.reject(403, 'Unknown user'); + const existing = await findExistingKttRecord(TaskRecords, dbUser.ID, legacyId); + if (existing) return { ok: true, alreadyDone: true }; + const entry = buildKttCompletionEntry({ + userId: dbUser.ID, legacyId, title, + attemptNumber: 1, nextLegacyId: await getNextLegacyId('TaskRecords', db), + }); + await INSERT.into(TaskRecords).entries(entry); + return { ok: true, alreadyDone: false }; + }); + + return super.init(); + } +} +``` + +> Note: resolve the user by `sapId` (`req.user.id`), matching the platform's test-key convention (`resolveUsers by sapId not uuid`). + +- [ ] **Step 6: Run test to verify it passes** + +Run: `npx vitest run test/unit/ktt-complete.test.js` +Expected: PASS (one row, second call `alreadyDone`). + +- [ ] **Step 7: Commit** + +```bash +git add srv/ktt-service.cds srv/ktt-service.js srv/lib/ktt/completion.js test/unit/ktt-complete.test.js +git commit -m "feat(ktt): KttService.completeLesson records KTT_LESSON TaskRecord (idempotent)" +``` + +--- + +### Task 3: Progress merge + `syncProgress` + +**Files:** +- Create: `srv/lib/ktt/merge.js` +- Modify: `srv/ktt-service.js` +- Test: `test/unit/ktt-merge.test.js` + +**Interfaces:** +- Produces: `mergeProgress(local, remote) => { xp, streak, mastered }` in `srv/lib/ktt/merge.js` — `mastered` = sorted union of arrays of lesson slugs, `xp`/`streak` = numeric max. Action `syncProgress(localJson)` reconstructs remote state from the user's `KTT_LESSON` TaskRecords, merges, records any newly-mastered lessons via the Task 2 path, and returns the merged totals. + +- [ ] **Step 1: Write the failing test** — `test/unit/ktt-merge.test.js`: + +```js +const { expect, test } = require('vitest'); +const { mergeProgress } = require('../../srv/lib/ktt/merge.js'); + +test('mergeProgress unions mastered and takes max xp/streak', () => { + const local = { xp: 120, streak: 3, mastered: ['core-1', 'core-2'] }; + const remote = { xp: 90, streak: 5, mastered: ['core-2', 'sec-1'] }; + expect(mergeProgress(local, remote)).toEqual({ + xp: 120, streak: 5, mastered: ['core-1', 'core-2', 'sec-1'], + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/ktt-merge.test.js` +Expected: FAIL (module not found). + +- [ ] **Step 3: Create `srv/lib/ktt/merge.js`:** + +```js +export function mergeProgress(local = {}, remote = {}) { + const mastered = Array.from(new Set([...(local.mastered || []), ...(remote.mastered || [])])).sort(); + return { + xp: Math.max(Number(local.xp) || 0, Number(remote.xp) || 0), + streak: Math.max(Number(local.streak) || 0, Number(remote.streak) || 0), + mastered, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/ktt-merge.test.js` +Expected: PASS. + +- [ ] **Step 5: Add `syncProgress` handler** to `srv/ktt-service.js` `init()` (uses `mergeProgress` + Task 2 helpers). Import at top: `import { mergeProgress } from './lib/ktt/merge.js';` and `import { KttLessons } from` via `cds.entities`. Handler: + +```js +this.on('syncProgress', async (req) => { + const local = JSON.parse(req.data.localJson || '{}'); + const dbUser = await SELECT.one.from(Users).where({ sapId: req.user.id }); + if (!dbUser) return req.reject(403, 'Unknown user'); + const done = await SELECT.from(TaskRecords) + .columns('taskLegacyId') + .where({ user_ID: dbUser.ID, taskType: 'KTT_LESSON', status: { '!=': 'SUPERSEDED' } }); + const doneIds = done.map((r) => r.taskLegacyId); + const catalog = await SELECT.from(KttLessons).columns('legacyId', 'slug'); + const slugByLegacy = new Map(catalog.map((c) => [c.legacyId, c.slug])); + const remote = { xp: 0, streak: 0, mastered: doneIds.map((id) => slugByLegacy.get(id)).filter(Boolean) }; + const merged = mergeProgress(local, remote); + // Record any locally-mastered lessons not yet in HANA (reuse completeLesson guard). + const legacyBySlug = new Map(catalog.map((c) => [c.slug, c])); + for (const slug of merged.mastered) { + const cat = legacyBySlug.get(slug); + if (!cat || doneIds.includes(cat.legacyId)) continue; + const existing = await findExistingKttRecord(TaskRecords, dbUser.ID, cat.legacyId); + if (existing) continue; + await INSERT.into(TaskRecords).entries(buildKttCompletionEntry({ + userId: dbUser.ID, legacyId: cat.legacyId, title: cat.slug, + nextLegacyId: await getNextLegacyId('TaskRecords', db), + })); + } + return merged; +}); +``` + +Add `KttLessons` to the `cds.entities('com.sap.developers.ims')` destructure at the top of `init()`. + +- [ ] **Step 6: Extend the test** — add a served-mode assertion in `test/unit/ktt-complete.test.js` (or a new `ktt-sync.test.js`): seed one `KttLessons` row (`legacyId:90001, slug:'core-1'`), POST `/ktt/syncProgress` with `localJson='{"xp":50,"streak":2,"mastered":["core-1"]}'`, assert response `mastered` contains `core-1` and a `KTT_LESSON` TaskRecord for `90001` now exists. + +- [ ] **Step 7: Run tests** + +Run: `npx vitest run test/unit/ktt-merge.test.js test/unit/ktt-complete.test.js` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add srv/lib/ktt/merge.js srv/ktt-service.js test/unit/ktt-merge.test.js test/unit/ktt-complete.test.js +git commit -m "feat(ktt): syncProgress merges local and HANA progress on login" +``` + +--- + +### Task 4: Surface KTT lessons in MyCompletions + +**Files:** +- Modify: `srv/lib/user-progress.js` (~`:175`, `:183`, `:213`) +- Test: `test/unit/ktt-mycompletions.test.js` + +**Interfaces:** +- Consumes: `getMyCompletedTutorials(user)` in `srv/lib/user-progress.js` (called by `getMyCompletions` in `srv/developer-service.js:453`). +- Produces: entries with `kind:'ktt'` for KTT lesson completions. + +- [ ] **Step 1: Write the failing test** — `test/unit/ktt-mycompletions.test.js` (served mode): seed a `KttLessons` row + a `KTT_LESSON` COMPLETED `TaskRecords` row for user `alice`, then call the `getMyCompletions` function and assert an entry with `{ kind: 'ktt', slug: 'core-1', title: 'Meet the Platform' }` is returned. + +```js +const cds = require('@sap/cds'); +const { expect, test } = require('vitest'); +const { GET } = cds.test('serve', '--in-memory', '--project', process.cwd()); + +test('getMyCompletions includes KTT lessons as kind:ktt', async () => { + // Arrange: insert KttLessons + TaskRecords for alice via db (see repo seed helpers). + // Act: + const res = await GET('/browse/getMyCompletions()', { auth: { username: 'alice' } }); + // Assert: + expect(res.data.value.some((e) => e.kind === 'ktt' && e.slug === 'core-1')).toBe(true); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/ktt-mycompletions.test.js` +Expected: FAIL (no `kind:'ktt'` entry). + +- [ ] **Step 3: Extend `srv/lib/user-progress.js`:** + - Add `'KTT_LESSON'` to the `taskType IN (...)` list near `:175`. + - Add a `kttIds` bucket near `:183` collecting `taskLegacyId`s where `taskType==='KTT_LESSON'`. + - Add a `SELECT.from(KttLessons).where({ legacyId: { in: kttIds } })` metadata fetch (import `KttLessons` from `cds.entities`). + - Add a branch near `:213` mapping a KTT row to `{ kind: 'ktt', slug, title: titleSnapshot || lesson.title, completionDate, attemptNumber }`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/ktt-mycompletions.test.js` +Expected: PASS. + +- [ ] **Step 5: Regression** — run the existing user-progress suite to confirm TUTORIAL/PUZZLE/PETOBERFEST still behave: + +Run: `npx vitest run test/unit/user-progress.test.js` (adjust to the real filename if different) +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/user-progress.js test/unit/ktt-mycompletions.test.js +git commit -m "feat(ktt): surface KTT lessons in MyCompletions as kind:ktt" +``` + +--- + +### Task 5: `banter` AI action (fail-open) + +**Files:** +- Modify: `srv/ktt-service.js` +- Test: `test/unit/ktt-banter.test.js` + +**Interfaces:** +- Produces: `function banter(context: String) returns String` — returns a short in-character line; on any AI error/timeout returns a deterministic scripted fallback line (never throws). + +- [ ] **Step 1: Write the failing test** — `test/unit/ktt-banter.test.js`: + +```js +const { expect, test, vi } = require('vitest'); + +test('banter returns a fallback line when AICore fails', async () => { + const { computeBanter } = require('../../srv/lib/ktt/banter.js'); + const brokenAi = { chat: vi.fn(async () => { throw new Error('AICore down'); }) }; + const line = await computeBanter(brokenAi, { event: 'streak', streak: 5 }); + expect(typeof line).toBe('string'); + expect(line.length).toBeGreaterThan(0); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/ktt-banter.test.js` +Expected: FAIL (`srv/lib/ktt/banter.js` not found). + +- [ ] **Step 3: Create `srv/lib/ktt/banter.js`** (pure, testable; the service wires the real AICore in): + +```js +const FALLBACKS = { + streak: "Purr-fect. Another one mastered — my nap is well earned.", + wrong: "A miss! Even I cough up the occasional hairball. Try again.", + correct: "Correct. You may pet me. Once.", + default: "Acronyms: SAP's favorite indoor sport.", +}; +const MAX_LEN = 160; + +/** ai: object with async chat(prompt) → string, or null. Never throws. */ +export async function computeBanter(ai, ctx = {}) { + const fallback = FALLBACKS[ctx.event] || FALLBACKS.default; + if (!ai || typeof ai.chat !== 'function') return fallback; + try { + const prompt = `You are Professor Kasimir, a witty SAP cat. One short, tongue-in-cheek line (<160 chars) for event "${ctx.event}". No emojis.`; + const out = await Promise.race([ + ai.chat(prompt), + new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 2500)), + ]); + const line = String(out || '').trim().slice(0, MAX_LEN); + return line || fallback; + } catch { + return fallback; + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run test/unit/ktt-banter.test.js` +Expected: PASS. + +- [ ] **Step 5: Wire the action** in `srv/ktt-service.js` `init()`: + +```js +import { computeBanter } from './lib/ktt/banter.js'; +// ... inside init(): +let ai = null; +try { ai = await cds.connect.to('AICore'); } catch { ai = null; } +const aiAdapter = ai ? { chat: async (p) => (await ai.send('POST', '/chat', { prompt: p }))?.text } : null; +this.on('banter', async (req) => computeBanter(aiAdapter, { event: req.data.context })); +``` + +> The exact AICore call shape follows the `@cap-js/ai` usage in the repo; `computeBanter` is agnostic to it and fail-open, so a wrong call shape degrades to the fallback rather than breaking the lesson. Local dev uses `AICore-mocked`; do not require a live model for tests. + +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/ktt/banter.js srv/ktt-service.js test/unit/ktt-banter.test.js +git commit -m "feat(ktt): fail-open Kasimir banter action backed by @cap-js/ai" +``` + +--- + +### Task 6: Feature flag `KTT_ENABLED` + gate the service + +**Files:** +- Modify: `srv/lib/feature-flags/registry.js`, `srv/ktt-service.js` +- Test: `test/unit/feature-flags-registry.test.js` (existing drift test must pass) + +**Interfaces:** +- Produces: registry entry `KTT_ENABLED` (`imsConfigKey:'flag.ktt.enabled'`), read via `isFlagEnabled('KTT_ENABLED')`. + +- [ ] **Step 1: Add the registry entry.** Append to the `FEATURE_FLAGS` array in `srv/lib/feature-flags/registry.js`: + +```js +{ + key: 'KTT_ENABLED', label: 'KTT — Kasimir Teaches TLAs', category: 'Content', + kind: 'db', imsConfigKey: 'flag.ktt.enabled', + valueType: 'boolean', default: false, status: 'beta', + description: 'Enables the /explore/ktt/ acronym trainer and its /ktt CAP endpoints. Off → completeLesson/syncProgress reject 503.', + howToChange: featureFlagUpsert('KTT_ENABLED', 'flag.ktt.enabled'), +}, +``` + +- [ ] **Step 2: Run the drift test to verify the registry is well-formed** + +Run: `npx vitest run test/unit/feature-flags-registry.test.js` +Expected: PASS (the entry is valid; no drift). + +- [ ] **Step 3: Gate the write actions.** At the top of `completeLesson` and `syncProgress` handlers in `srv/ktt-service.js`, add: + +```js +import { isFlagEnabled } from './lib/feature-flags/db-flags.js'; +// first line of each write handler: +if (!isFlagEnabled('KTT_ENABLED')) return req.reject(503, 'KTT is not enabled'); +``` + +- [ ] **Step 4: Add a served test** asserting `completeLesson` rejects `503` when the flag default (OFF) is in effect, and passes when the flag is forced on (set the ImsConfig row / mock `isFlagEnabled` per the repo's existing flag-test pattern). Guard any DB-flag boot-seed behind `!process.env.VITEST`. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/feature-flags/registry.js srv/ktt-service.js test/unit/*.test.js +git commit -m "feat(ktt): register KTT_ENABLED db flag and gate /ktt write actions" +``` + +--- + +## Phase B — Content pipeline + +### Task 7: Lesson schema, generator, and reviewed data file + +**Files:** +- Create: `scripts/lib/ktt-lessons-schema.ts`, `scripts/generate-ktt-lessons.ts`, `hugo/data/ktt_lessons.json` +- Modify: `package.json` (script only; `build:all` wiring in Task 16) +- Test: `test/unit/ktt-lessons-data.test.ts` + +**Interfaces:** +- Produces: TS types `KttLesson`, `KttUnit`, `KttBeat`, and `validateLessons(data): string[]` (returns error messages; empty = valid) in `scripts/lib/ktt-lessons-schema.ts`. The generated `hugo/data/ktt_lessons.json` matches the spec §5 shape. + +- [ ] **Step 1: Write the failing validation test** — `test/unit/ktt-lessons-data.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { validateLessons } from '../../scripts/lib/ktt-lessons-schema'; + +describe('ktt_lessons.json', () => { + const data = JSON.parse(readFileSync('hugo/data/ktt_lessons.json', 'utf8')); + it('has 3-4 units and passes structural validation', () => { + expect(data.units.length).toBeGreaterThanOrEqual(3); + expect(data.units.length).toBeLessThanOrEqual(4); + expect(validateLessons(data)).toEqual([]); + }); + it('every drill has an answer and >=2 distractors; legacyIds unique', () => { + const errs = validateLessons(data); + expect(errs).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run test/unit/ktt-lessons-data.test.ts` +Expected: FAIL (schema module + data file missing). + +- [ ] **Step 3: Create `scripts/lib/ktt-lessons-schema.ts`:** + +```ts +export interface KttAcronym { tla: string; expansion: string; blurb: string; category: string; } +export interface KttDrill { + type: 'drill'; kind: 'mc' | 'match' | 'type'; tla: string; prompt: string; + answer: string; distractors: string[]; kasimirRight: string; kasimirWrong: string; +} +export interface KttStory { type: 'story'; kasimir: string; mood: 'idle'|'teaching'|'thinking'|'correct'|'wrong'|'celebrate'; } +export type KttBeat = KttStory | KttDrill; +export interface KttLesson { id: string; legacyId: number; title: string; acronyms: KttAcronym[]; beats: KttBeat[]; } +export interface KttUnit { id: string; title: string; icon: string; order: number; lessons: KttLesson[]; } +export interface KttData { units: KttUnit[]; } + +export function validateLessons(data: KttData): string[] { + const errs: string[] = []; + const legacyIds = new Set(); + for (const u of data.units || []) { + for (const l of u.lessons || []) { + if (typeof l.legacyId !== 'number') errs.push(`${l.id}: missing numeric legacyId`); + if (legacyIds.has(l.legacyId)) errs.push(`${l.id}: duplicate legacyId ${l.legacyId}`); + legacyIds.add(l.legacyId); + for (const a of l.acronyms || []) if (!a.expansion) errs.push(`${l.id}/${a.tla}: missing expansion`); + for (const b of l.beats || []) { + if (b.type === 'drill') { + if (!b.answer) errs.push(`${l.id}: drill for ${b.tla} missing answer`); + if (!b.distractors || b.distractors.length < 2) errs.push(`${l.id}: drill for ${b.tla} needs >=2 distractors`); + } + } + } + } + return errs; +} +``` + +- [ ] **Step 4: Create `scripts/generate-ktt-lessons.ts`.** It (a) reads a curated seed list of `{tla, expansion, category, unitId}` embedded in the script (facts grounded against `sap-devs`/`cds-mcp`/official docs during authoring — NOT invented), (b) optionally calls the AI drafting step for `kasimir*` lines + `distractors` when `--draft` is passed, (c) assembles `KttData`, (d) runs `validateLessons` and **exits non-zero on any error**, (e) writes `hugo/data/ktt_lessons.json` with stable ordering. Skeleton: + +```ts +import { writeFileSync } from 'node:fs'; +import { validateLessons, KttData } from './lib/ktt-lessons-schema'; + +const SEED = [/* curated + grounded TLA facts, assigned to units + legacyIds */]; + +function assemble(): KttData { /* group SEED by unitId, build beats (story+drills) */ return { units: [] }; } + +const data = assemble(); +const errs = validateLessons(data); +if (errs.length) { console.error('KTT lesson validation failed:\n' + errs.join('\n')); process.exit(1); } +writeFileSync('hugo/data/ktt_lessons.json', JSON.stringify(data, null, 2) + '\n'); +console.log(`Wrote ${data.units.length} units.`); +``` + +- [ ] **Step 5: Author the reviewed data file.** Run the generator (or hand-author) to produce `hugo/data/ktt_lessons.json` with 3–4 units and ~50–60 acronyms. **The TLA→expansion facts are human-reviewed for correctness before commit** (Global Constraints). This is the content-authoring step — grounded, not model-guessed. + +- [ ] **Step 6: Add the npm script** to `package.json` `scripts`: + +```json +"generate-ktt-lessons": "npx tsx scripts/generate-ktt-lessons.ts" +``` + +- [ ] **Step 7: Run the validation test to verify it passes** + +Run: `npx vitest run test/unit/ktt-lessons-data.test.ts` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add scripts/lib/ktt-lessons-schema.ts scripts/generate-ktt-lessons.ts hugo/data/ktt_lessons.json package.json test/unit/ktt-lessons-data.test.ts +git commit -m "feat(ktt): lesson schema, generator, and reviewed content data file" +``` + +--- + +## Phase C — Frontend island + +### Task 8: Scaffold `ktt` island + register in Vite + +**Files:** +- Create: `hugo-apps/src/ktt/main.ts`, `hugo-apps/src/ktt/App.vue` +- Modify: `hugo-apps/vite.config.ts` (add `ktt` to `rollupOptions.input`, near `:349`) +- Test: `hugo-apps/src/ktt/__tests__/App.test.ts` + +**Interfaces:** +- Produces: island entry mounting `App.vue` on `#ktt-mount`, reading `data-api` (default `/ktt`) and `data-lessons` (path to `ktt_lessons.json`, default `/data/ktt_lessons.json`... — see note). Screen switch driven by a `screen` ref: `'landing'|'map'|'lesson'|'results'`. + +> Lessons data note: Hugo bakes `hugo/data/ktt_lessons.json` into the page; pass it to the island via a `data-lessons` attribute containing the JSON (or a URL). Simplest: emit the JSON inline in the layout (Task 14) and read `mount.dataset.lessons`. The island therefore does NOT fetch lessons from CAP in v1. + +- [ ] **Step 1: Write the failing test** — `hugo-apps/src/ktt/__tests__/App.test.ts`: + +```ts +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import App from '../App.vue'; + +const LESSONS = { units: [{ id: 'core', title: 'Core Platform', icon: 'home', order: 1, + lessons: [{ id: 'core-1', legacyId: 90001, title: 'Meet the Platform', acronyms: [], beats: [] }] }] }; + +describe('KTT App', () => { + it('renders the landing screen with a start CTA', () => { + const wrapper = mount(App, { props: { apiUrl: '/ktt', lessons: LESSONS } }); + expect(wrapper.text()).toContain('Kasimir'); + expect(wrapper.find('[data-testid="ktt-start"]').exists()).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- hugo-apps/src/ktt/__tests__/App.test.ts` +Expected: FAIL (`App.vue` missing). + +- [ ] **Step 3: Create `hugo-apps/src/ktt/main.ts`:** + +```ts +import { createApp } from 'vue'; +import App from './App.vue'; + +const mount = document.getElementById('ktt-mount') as HTMLElement | null; +if (mount) { + let lessons = {}; + try { lessons = JSON.parse(mount.dataset.lessons || '{}'); } catch { lessons = {}; } + createApp(App, { apiUrl: mount.dataset.api || '/ktt', lessons }).mount(mount); +} +``` + +- [ ] **Step 4: Create a minimal `hugo-apps/src/ktt/App.vue`** (landing only for now; screens added in later tasks): + +```vue + + +``` + +> This imports `KasimirStage` (Task 10). To keep Task 8 independently green, create a one-line stub `components/KasimirStage.vue` now (``) and replace it in Task 10. + +- [ ] **Step 5: Register the island** in `hugo-apps/vite.config.ts` `rollupOptions.input` (near `:349`, beside `puzzle`): + +```ts +ktt: resolve(__dirname, 'src/ktt/main.ts'), +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: `npm test -- hugo-apps/src/ktt/__tests__/App.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add hugo-apps/src/ktt hugo-apps/vite.config.ts +git commit -m "feat(ktt): scaffold ktt Vue island and register Vite entry" +``` + +--- + +### Task 9: Lesson engine (pure) + +**Files:** +- Create: `hugo-apps/src/ktt/lib/engine.ts` +- Test: `hugo-apps/src/ktt/__tests__/engine.test.ts` + +**Interfaces:** +- Produces: + - `createSession(lesson: KttLesson): Session` where `Session = { beats: KttBeat[]; index: number; xp: number; correct: number; wrong: number; queue: number[] }`. + - `currentBeat(s): KttBeat | null` + - `answerDrill(s, answer: string): { correct: boolean; requeued: boolean }` — wrong answers push the beat index to the end of `queue` (re-queue once); correct awards `XP_PER_CORRECT`. + - `advance(s): void` — moves to the next queued beat. + - `isComplete(s): boolean` + - constant `XP_PER_CORRECT = 10`. + +- [ ] **Step 1: Write the failing test** — `hugo-apps/src/ktt/__tests__/engine.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { createSession, currentBeat, answerDrill, advance, isComplete, XP_PER_CORRECT } from '../lib/engine'; + +const lesson = { id: 'core-1', legacyId: 90001, title: 't', acronyms: [], beats: [ + { type: 'story', kasimir: 'hi', mood: 'teaching' }, + { type: 'drill', kind: 'mc', tla: 'BTP', prompt: '?', answer: 'Business Technology Platform', + distractors: ['a','b'], kasimirRight: 'y', kasimirWrong: 'n' }, +]} as any; + +describe('engine', () => { + it('awards XP on correct and re-queues on wrong', () => { + const s = createSession(lesson); + advance(s); // move past story to drill + expect(currentBeat(s).type).toBe('drill'); + const wrong = answerDrill(s, 'a'); + expect(wrong.correct).toBe(false); + expect(wrong.requeued).toBe(true); + advance(s); + const right = answerDrill(s, 'Business Technology Platform'); + expect(right.correct).toBe(true); + expect(s.xp).toBe(XP_PER_CORRECT); + advance(s); + expect(isComplete(s)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- hugo-apps/src/ktt/__tests__/engine.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement `hugo-apps/src/ktt/lib/engine.ts`** to satisfy the interface above (session tracks a `queue` of remaining beat indices; `answerDrill` compares case-insensitively; wrong beats re-queue at most once via a `requeuedSet`). + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test -- hugo-apps/src/ktt/__tests__/engine.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add hugo-apps/src/ktt/lib/engine.ts hugo-apps/src/ktt/__tests__/engine.test.ts +git commit -m "feat(ktt): pure lesson engine — scoring, XP, wrong-answer re-queue" +``` + +--- + +### Task 10: `KasimirStage` — rigged SVG + mood state machine + +**Files:** +- Replace stub: `hugo-apps/src/ktt/components/KasimirStage.vue` +- Test: `hugo-apps/src/ktt/__tests__/KasimirStage.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: component prop `mood: 'idle'|'teaching'|'thinking'|'correct'|'wrong'|'celebrate'`. Root element carries `class="kasimir kasimir--"` and `aria-hidden="true"`. Honors `prefers-reduced-motion` by adding `kasimir--still`. + +- [ ] **Step 1: Write the failing test** — `hugo-apps/src/ktt/__tests__/KasimirStage.test.ts`: + +```ts +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import KasimirStage from '../components/KasimirStage.vue'; + +describe('KasimirStage', () => { + it('reflects mood in the root class and is aria-hidden', () => { + const w = mount(KasimirStage, { props: { mood: 'celebrate' } }); + const root = w.find('.kasimir'); + expect(root.classes()).toContain('kasimir--celebrate'); + expect(root.attributes('aria-hidden')).toBe('true'); + expect(w.find('svg').exists()).toBe(true); + }); + it('switches class when mood changes', async () => { + const w = mount(KasimirStage, { props: { mood: 'idle' } }); + await w.setProps({ mood: 'wrong' }); + expect(w.find('.kasimir').classes()).toContain('kasimir--wrong'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- hugo-apps/src/ktt/__tests__/KasimirStage.test.ts` +Expected: FAIL (stub has no `svg`/mood class). + +- [ ] **Step 3: Implement `KasimirStage.vue`.** Build an **original stylized SVG Kasimir** (grey tuxedo body, white blaze/muzzle/chest/paws, big blue eyes + pupils, pink nose, dark collar + blue SAP-arrow tag) with each animated part as its own `` (`ears`, `eyelids`, `pupils`, `mouth`, `whiskers`, `tail`, `pawLeft`, `pawRight`, `head`, `body`). Root binds the mood class; CSS keyframes per mood drive the rig; `prefers-reduced-motion` freezes to a calm pose. Structure: + +```vue + + + +``` + +> The functional contract (prop → root class → `svg` present → reduced-motion) is fully tested here. **Visual refinement of the SVG paths against the reference images is an in-task iteration** using `run` to view the island in a browser; the art is original stylized vector work (SAP-owned likeness, recreated — not a copy of the source illustrations). Ship a recognizable Kasimir; polish is expected before the flag flips ON. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test -- hugo-apps/src/ktt/__tests__/KasimirStage.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add hugo-apps/src/ktt/components/KasimirStage.vue hugo-apps/src/ktt/__tests__/KasimirStage.test.ts +git commit -m "feat(ktt): rigged SVG Kasimir with mood state machine + reduced-motion" +``` + +--- + +### Task 11: `SkillTreeMap` component + +**Files:** +- Create: `hugo-apps/src/ktt/components/SkillTreeMap.vue` +- Test: `hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts` + +**Interfaces:** +- Consumes: `lessons: KttData`, `mastered: string[]` (lesson ids), emits `select(lessonId: string)`. +- Produces: renders one node per lesson; a node is `is-locked` unless it's the first of a unit or its previous sibling id is in `mastered`; mastered nodes get `is-done`. + +- [ ] **Step 1: Write the failing test** — assert: given two lessons in a unit and `mastered=[]`, the first node is selectable and the second has class `is-locked`; clicking the first emits `select` with its id; with `mastered=['core-1']`, `core-2` is unlocked. + +- [ ] **Step 2: Run to verify it fails.** Run: `npm test -- hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts` → FAIL. + +- [ ] **Step 3: Implement `SkillTreeMap.vue`** to satisfy the lock/unlock/done + emit contract above. Include a `` beside the map. + +- [ ] **Step 4: Run to verify it passes.** Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add hugo-apps/src/ktt/components/SkillTreeMap.vue hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts +git commit -m "feat(ktt): skill-tree map with lesson lock/unlock/done states" +``` + +--- + +### Task 12: `Lesson` component + wire App screens + local progress + server client + +**Files:** +- Create: `hugo-apps/src/ktt/components/Lesson.vue`, `hugo-apps/src/ktt/components/Results.vue`, `hugo-apps/src/ktt/lib/progress.ts`, `hugo-apps/src/ktt/lib/server.ts` +- Modify: `hugo-apps/src/ktt/App.vue` +- Test: `hugo-apps/src/ktt/__tests__/progress.test.ts`, `hugo-apps/src/ktt/__tests__/Lesson.test.ts` + +**Interfaces:** +- Produces: + - `progress.ts`: `loadLocal(): { xp:number; streak:number; mastered:string[] }`, `saveLocal(p)`, `mergeProgress(local, remote)` (same union/max semantics as the backend `srv/lib/ktt/merge.js`). + - `server.ts`: `isAuthenticated(): Promise` (probes `/auth/user`, checks JSON `body.authenticated`, NOT `r.ok`), `completeLesson(apiUrl, {lessonSlug, legacyId, title})`, `syncProgress(apiUrl, local)`, `fetchBanter(apiUrl, context)`. All server calls are best-effort: failures are caught and never throw into the UI. + - `Lesson.vue`: props `lesson`, drives the Task 9 engine, shows current beat, binds `KasimirStage` mood (`teaching` on story, `correct`/`wrong` after answers, `celebrate` on complete), emits `complete(lessonId)`. + +- [ ] **Step 1: Write failing `progress.test.ts`** — assert `saveLocal`/`loadLocal` round-trip via `localStorage` (happy-dom), and `mergeProgress` matches the backend semantics (union mastered, max xp/streak). Reuse the exact expectation from `test/unit/ktt-merge.test.js`. + +- [ ] **Step 2: Run to verify it fails.** Run: `npm test -- hugo-apps/src/ktt/__tests__/progress.test.ts` → FAIL. + +- [ ] **Step 3: Implement `progress.ts` and `server.ts`.** `isAuthenticated` must parse JSON and check `body.authenticated` (per the platform's auth-probe gotcha). `server.ts` uses native `fetch`. + +- [ ] **Step 4: Write failing `Lesson.test.ts`** — mount `Lesson` with a two-beat lesson (story + one drill), simulate a correct answer, assert XP shows and `complete` is emitted after the last beat; assert Kasimir mood becomes `celebrate` on completion. Stub `server.ts` via `vi.mock`. + +- [ ] **Step 5: Run to verify it fails.** → FAIL. + +- [ ] **Step 6: Implement `Lesson.vue` + `Results.vue`, and wire `App.vue`:** landing → map (`SkillTreeMap`) → lesson (`Lesson`) → results (`Results`). On `Lesson`'s `complete`: update local progress (`saveLocal`), then if `isAuthenticated()` call `completeLesson` + `syncProgress` (best-effort), then show `Results` / return to map with the node marked done. Fetch a `banter` line opportunistically (fail-open — never block). + +- [ ] **Step 7: Run all ktt island tests.** Run: `npm test -- hugo-apps/src/ktt` → PASS. + +- [ ] **Step 8: srv/lib cp-list audit.** Confirm no new `srv/lib/ktt/*` file is imported (transitively) by `srv/lib/content-store.js`: + +Run: `npx ast-grep -p "require('./ktt/$_')" srv/lib/content-store.js` (and grep for `ktt/` import specifiers reachable from it). Expected: no matches → no `.deploy/mta.yaml` `srv-qa` cp-list change needed. If any match appears, add the file(s) to the `srv-qa` `cp` list. + +- [ ] **Step 9: Commit** + +```bash +git add hugo-apps/src/ktt +git commit -m "feat(ktt): lesson runner, results, local progress + best-effort server sync" +``` + +--- + +## Phase D — Hugo page, nav, build wiring, e2e + +### Task 13: Hugo page + layout (SSR shell + island mount) + +**Files:** +- Create: `hugo/content/explore/ktt/_index.md`, `hugo/layouts/explore/ktt.html` +- Test: manual Hugo render (`npm run dev`) — verified in Task 16 build. + +**Interfaces:** +- Consumes: `hugo/data/ktt_lessons.json` (Task 7); `island-src.html` partial (`ktt`). +- Produces: `/explore/ktt/` route rendering an SSR shell + `#ktt-mount` with `data-api="/ktt"` and `data-lessons` = the baked lessons JSON. + +- [ ] **Step 1: Create `hugo/content/explore/ktt/_index.md`:** + +```yaml +--- +title: "KTT — Kasimir Teaches TLAs" +description: "Professor Kasimir teaches you SAP's endless three-letter acronyms — a mostly-fun, tongue-in-cheek trainer." +type: explore +layout: ktt +slug: ktt +--- +``` + +- [ ] **Step 2: Create `hugo/layouts/explore/ktt.html`:** + +```html +{{ define "main" }} +
+ +
+

{{ .Title }}

+

{{ .Description }}

+
    + {{ range site.Data.ktt_lessons.units }}
  • {{ .title }}
  • {{ end }} +
+
+
+ + +{{ end }} +``` + +- [ ] **Step 3: Commit** + +```bash +git add hugo/content/explore/ktt/_index.md hugo/layouts/explore/ktt.html +git commit -m "feat(ktt): Hugo page + layout with SSR shell and island mount" +``` + +--- + +### Task 14: Explore nav entry + +**Files:** +- Modify: `hugo/data/navigation.yaml` (under the `id: explore` group, `:82`) + +- [ ] **Step 1: Add the menu item** under the Explore group (mirror the existing explore items' fields): + +```yaml +- id: explore-ktt + label: "Kasimir Teaches TLAs" + href: /explore/ktt/ + icon: learning-assistant + paletteLabel: "KTT — Kasimir Teaches TLAs" + keywords: ["acronym", "tla", "kasimir", "quiz", "learn"] +``` + +- [ ] **Step 2: Verify Hugo builds the nav** — `npm run dev`, load `/`, confirm "Kasimir Teaches TLAs" appears under Explore and links to `/explore/ktt/`. + +- [ ] **Step 3: Commit** + +```bash +git add hugo/data/navigation.yaml +git commit -m "feat(ktt): add KTT to the Explore navigation menu" +``` + +--- + +### Task 15: Wire content generation into `build:all` + +**Files:** +- Modify: `package.json` (`build:all`) + +- [ ] **Step 1: Insert `generate-ktt-lessons`** into the leading `fetch-*` chain of `build:all` (before `build:apps`), analogous to `fetch-tutorials`: + +``` +... && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run generate-ktt-lessons && npm run build:icon-subset && ... +``` + +- [ ] **Step 2: Run a scoped build to verify wiring** — regenerate lessons + build apps + manifest + hugo: + +Run: `npm run generate-ktt-lessons && npm run build:apps && npm run build:island-manifest` +Expected: `hugo/data/ktt_lessons.json` regenerated; `hugo/static/js/ktt-.js` produced; `ktt` key present in `hugo/data/island_manifest.json`. + +- [ ] **Step 3: Verify the page renders end-to-end** — `npm run build:hugo` then serve `hugo/public/explore/ktt/index.html`; confirm the `#ktt-mount` `data-lessons` is populated and the script src is the hashed `/js/ktt-.js`. + +- [ ] **Step 4: Commit** + +```bash +git add package.json +git commit -m "build(ktt): generate ktt lessons as part of build:all" +``` + +--- + +### Task 16: Post-deploy e2e smoke (self-skipping) + +**Files:** +- Create: `test/e2e/ktt.spec.ts` + +**Interfaces:** +- Consumes: `SMOKE_BASE_URL` env (self-skips when absent, per the platform e2e pattern). + +- [ ] **Step 1: Write `test/e2e/ktt.spec.ts`** — skip unless `SMOKE_BASE_URL` set; navigate to `${SMOKE_BASE_URL}/explore/ktt/`; assert the SSR shell `

` and unit list render; click `[data-testid="ktt-start"]`; assert the map screen appears; start the first lesson; answer one drill; assert Kasimir/feedback updates. Follow the structure in `test/e2e/README.md`. + +- [ ] **Step 2: Local self-skip check** + +Run: `npm run test:e2e` (with `SMOKE_BASE_URL` unset) +Expected: test self-skips (no failure). + +- [ ] **Step 3: Commit** + +```bash +git add test/e2e/ktt.spec.ts +git commit -m "test(ktt): post-deploy e2e smoke for /explore/ktt/ (self-skipping)" +``` + +--- + +## Final verification (before opening the PR) + +- [ ] Run the full unit suite: `npm test` → PASS (KTT + no regressions). +- [ ] `npx cds deploy --to sqlite::memory:` → clean. +- [ ] `npm run build:all` (with `CAP_BASE_URL` pointed at the deployed backend per repo convention) → completes; `ktt` island hashed + in manifest; `/explore/ktt/` renders. +- [ ] Confirm `KTT_ENABLED` is **OFF** by default (route/endpoints fail closed) — flip ON only after DEV verification. +- [ ] `git fetch origin` and diff the branch against `origin/DEV` (KTT work targets DEV, not main). +- [ ] Open a PR to **DEV** (`gh pr create`), not a direct merge. + +--- + +## Deferred Work (explicitly out of v1 scope) + +1. **Unit-level rollup.** Seed each unit as a mission-style parent (`Missions`/`Groups` row) with `CompletionPathItems` children `KTT_LESSON:`, and add a `rollUpParentsForCompletion({ task:{ taskType:'KTT_LESSON', taskLegacyId } })` call in `completeLesson`. `completion-rollup.js:159` then produces "unit mastered" rows with no rollup-code change. Requires confirming the Missions/`CompletionPathItems` seed shape first (not specified here to avoid guessing). +2. **Endless mode** over the full acronym firehose — the engine's beat source is already pluggable; add a CAP-backed acronym feed. +3. **Per-acronym `STEP` completion rows** for finer mastery tracking. +4. **NGDS autosend** for KTT completions. +5. **Homepage/featured surfacing** and gameboard scoring tie-ins. diff --git a/docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md b/docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md new file mode 100644 index 000000000..41b1ca060 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-ktt-kasimir-teaches-tlas-design.md @@ -0,0 +1,274 @@ +# KTT — Kasimir Teaches TLAs — Design + +**Date:** 2026-09-11 +**Status:** Design approved; pending spec review before implementation planning +**Route:** `/explore/ktt/` (new page under the "Explore" nav) + +## 1. Summary + +KTT ("Kasimir Teaches TLAs") is a mostly-fun, tongue-in-cheek page on +developers.sap.com that teaches the enormous set of SAP three-letter +acronyms through a Duolingo-style, **story-heavy** experience narrated by +an animated Professor Kasimir (SAP's cat mascot). The core loop is +**story-driven** (Kasimir teaches with dialogue and jokes) with +**quiz drills mixed in** (multiple-choice / match / type). Lessons are +grouped into a skill-tree of 3–4 units covering ~50–60 acronyms for v1. + +Kasimir is rendered as an original **stylized/flat SVG** recreation of +SAP's Kasimir (grey tuxedo cat, white blaze/muzzle/chest/paws, big blue +eyes, pink nose, dark collar + blue SAP-arrow tag), rigged and animated +in code (CSS keyframes + SMIL). It is not a photoreal reproduction of the +3D-rendered character; the art lives behind a component interface so it +can later be swapped for a commissioned Lottie / rendered asset without +touching lesson logic. + +## 2. Goals & non-goals + +**Goals (v1):** +- A skill-tree map with 3–4 themed units, ~50–60 TLAs total. +- Story-first lessons with drills interspersed; wrong answers re-queued. +- An animated Kasimir present on every screen, driven by a small mood + state machine. +- Progress that works logged-out (local) and follows a signed-in user + (synced to HANA), reusing the existing completion model so KTT lessons + appear in MyCompletions and unit rollups. +- Content that is correct on the facts and fun in the voice. + +**Non-goals (v1):** +- "Endless mode" over the full acronym firehose — **seam left in**, not built. +- NGDS autosend for KTT completions — **explicitly excluded** for v1. +- Per-acronym `STEP`-level completion rows — mastery stays inside the + lesson / local state for v1. +- Photoreal 3D Kasimir, commissioned Lottie, or badges/leaderboard beyond + what MyCompletions/gameboard give for free. + +## 3. Architecture + +Chosen approach: **Hugo page + a single Vue 3 island** (the established +platform pattern; ~17 islands already build into `hugo/static/js/`). + +```text +/explore/ktt/ (Hugo page — SSR shell so the route is not an empty div) + └─ mounts one Vue 3 island: the KTT game + ├─ Landing / hero + ├─ Skill-tree map (units → lesson nodes) + ├─ Lesson (story beats + drills) + └─ Progress / results + island talks to CAP only for: + (1) progress sync when logged in + (2) live Kasimir "banter" line (AI, fail-open) + +Content: hugo/data/ktt_lessons.json (build-time AI-drafted, human-reviewed) +Backend: srv/ktt-service.cds (@path:'/ktt') — thin +Progress rows: reuse com.sap.developers.ims.TaskRecords (new taskType KTT_LESSON) +``` + +The island inherits Hugo nav, the Horizon theme, and the site build +pipeline. The CAP surface stays deliberately small. + +## 4. Screens & UX flow + +Four screens inside the one island: + +1. **Landing / hero** — Kasimir (idle animation) introduces himself with a + tongue-in-cheek line; "Start / Continue" CTA; streak + XP badge. +2. **Skill-tree map** — Duolingo-style path: 3–4 units, each a chain of + lesson nodes (locked → available → completed). Suggested units: + *Core Platform*, *Data & HANA*, *Security & Identity*, + *ABAP & Extensibility*. Kasimir reacts alongside progress. +3. **Lesson** — the core loop: + - **Story beats** — Kasimir teaches a cluster of TLAs with dialogue + + jokes (scripted lines; occasional live-AI banter for freshness). + - **Drills** — interspersed: multiple-choice, match-the-expansion, + type-the-acronym. Wrong answers re-queued Duolingo-style; Kasimir + facepalms then encourages. + - **Lesson complete** — Kasimir celebrates (hop + confetti), XP awarded, + next node unlocks. +4. **Progress / results** — streak, XP, acronyms mastered, per-unit + completion; feeds local + HANA sync. + +## 5. Content model & pipeline + +Lesson content is **one reviewed JSON file**, `hugo/data/ktt_lessons.json`, +committed to git as the source of truth (regenerable, but never generated +live at build). + +Shape (approximate): + +```jsonc +{ + "units": [ + { + "id": "core-platform", + "title": "Core Platform", + "icon": "...", + "order": 1, + "lessons": [ + { + "id": "core-platform-1", + "legacyId": 90001, // stable int, used for TaskRecords + rollup tokens + "title": "Meet the Platform", + "acronyms": [ + { "tla": "BTP", "expansion": "Business Technology Platform", + "blurb": "…", "category": "platform" } + ], + "beats": [ + { "type": "story", "kasimir": "…", "mood": "teaching" }, + { "type": "drill", "kind": "mc", "tla": "BTP", + "prompt": "BTP stands for…", "answer": "Business Technology Platform", + "distractors": ["…", "…"], + "kasimirRight": "…", "kasimirWrong": "…" } + ] + } + ] + } + ] +} +``` + +**Build-time generation** — `scripts/generate-ktt-lessons.ts`: +- Seeds from a **hand-curated acronym → expansion + category list**. The + facts (TLA → expansion) are **generated by Claude but grounded against + `sap-devs-server` / `cds-mcp` / official docs — not invented from + training data** — and are reviewed by a human before commit + (per the project rule: never rely on training data for SAP facts). +- AI drafts **only** Kasimir's personality lines and plausible-but-wrong + distractors; these land in the JSON for human review. +- Wired as an **explicit `build:all` step** (not a `pre/post` hook — those + are silenced by the global `ignore-scripts=true`). + +**Endless-mode seam:** the lesson engine reads beats from a **pluggable +source** (data file now; a CAP-backed acronym feed later), so a future +"endless mode" over the full acronym set drops in without a rewrite. + +## 6. Animated Kasimir — `KasimirStage` component + +A self-contained Vue component wrapping **one rigged inline SVG** of +stylized Kasimir. Parts (ears, eyelids, pupils, mouth, whiskers, tail, +front paws, head) are separate SVG groups so they animate independently. + +- **State machine** prop: `idle | teaching | thinking | correct | wrong | + celebrate`, driven with CSS keyframes + SMIL: + - idle — breathing + tail flick + periodic blink + - teaching — gentle gesture, eyes toward content + - thinking — ear twitch, eyes up + - correct — ears perk, eye sparkle + - wrong — facepalm paw, flattened ears + - celebrate — hop + confetti burst +- **Accessibility:** honors `prefers-reduced-motion` (freezes to a calm + pose); the SVG is decorative (`aria-hidden`), while Kasimir's teaching + text lives in real DOM for screen readers. +- **Swappable art:** the SVG sits behind the component interface, so a + future commissioned Lottie / rendered Kasimir replaces the innards + without touching lesson logic. +- **Themed** to Horizon tokens so it fits `sap_horizon`. + +## 7. Backend — reuse the completion model + +The completable unit is a **KTT lesson**. It reuses the existing +completion machinery rather than a new progress entity. + +### 7.1 Progress rows — `TaskRecords` + +- Each completed lesson writes a `com.sap.developers.ims.TaskRecords` row + (`db/schema.cds:188`) with `taskType:'KTT_LESSON'`, `status`, `progress`, + `completionDate`. +- Upserted on the de-facto tuple **`(user_ID, taskLegacyId, taskType)`** + (same convention as `puzzle-service.js` / `petoberfest-upload.js`), so + re-completion updates rather than duplicates. + +### 7.2 Catalog entity — `KttLessons` + +- One lightweight entity (id, `legacyId`, slug, unit, title), seeded from + `ktt_lessons.json`, mirroring how `Tutorials` / `Puzzles` / + `Petoberfests` exist so a completion row can join to its metadata for + MyCompletions. +- New persisted entity → add a `db/persistence.cds` journal entry; + run `cds deploy --to sqlite::memory:` before committing the model. + +### 7.3 Unit rollups (free) + +- Each unit is modeled as a mission-style parent via `CompletionPathItems` + whose children are `KTT_LESSON:` tokens. +- `srv/lib/completion-rollup.js` already matches mission parents by + `{taskType, taskLegacyId}` (`:159`) and upserts the parent GROUP/MISSION + row — so "unit mastered" appears with **no rollup-code changes**. + +### 7.4 `KttService` (`srv/ktt-service.cds`, `@path:'/ktt'`) + +- `completeLesson` (XSUAA-scoped write) — writes the `TaskRecords` row and + calls `rollUpParentsForCompletion({ task:{ taskType:'KTT_LESSON', … } })` + (same call shape as `puzzle-service.js:223`). +- `syncProgress` — merges local ↔ HANA on login: union of mastered TLAs, + max of XP/streak; idempotent via the upsert tuple. +- `banter(context)` — `@cap-js/ai` action returning a short in-character + line. `AICore-mocked` locally, `AICore-btp` in hybrid/prod. **Fail-open:** + any error/timeout returns a scripted line from the data file; rate-limited + and length-capped; runs edge-side, never inside a progress transaction. + +### 7.5 Progress: local-first + hybrid + +- Logged-out: everything lives in `localStorage`; the game is fully playable. +- On login: local completions replay through `completeLesson` as + `TaskRecords` rows; the upsert tuple prevents double-counting. + +### 7.6 Concrete edit sites + +- `db/schema.cds:195` — add `KTT_LESSON` to `TaskRecords.taskType` + (NVARCHAR column; **no `.hdbmigrationtable` ALTER needed** — see comment + at `:191`). +- `db/schema.cds:19` — add `KTT_LESSON` to `type TaskType` so KTT lessons + pass the `@assert.range` at `:418` when used as `CompletionPathItems` + children. +- `srv/lib/user-progress.js` (~`:175`, `:183`, `:213`) — add `KTT_LESSON` + to the MyCompletions `taskType IN (…)` list, an ID bucket, a metadata + SELECT against `KttLessons`, and a `kind:'ktt'` branch. +- New `KttService` handlers per 7.4. +- **Not changed for v1:** `srv/lib/ngds-client.js` (NGDS autosend excluded), + `featured-resolve.js`, `admin-service.js`, exports — revisit only if KTT + later joins those flows. + +### 7.7 Feature flag + +- KTT gated by an `ImsConfig` DB flag registered in + `feature-flags/registry.js` (`kind:'db'`) so it surfaces in the admin + Feature Flags UI and toggles without redeploy. **Default OFF** until + DEV-verified. Flag off ⇒ `/explore/ktt/` fails closed (coming-soon / 404 + per convention). + +## 8. Error handling & resilience + +- **AI banter non-blocking** — timeout + try/catch; falls back to a scripted + line. Lesson loop never stalls on the model. +- **Progress sync best-effort** — a failed `TaskRecords` write shows a quiet + toast, never loses local state, retries on next completion; never throws + into the rollup transaction. +- **Data file validated at build** — `generate-ktt-lessons.ts` + a unit test + assert: every drill has a correct answer and ≥2 distractors, every acronym + has an expansion, `legacyId`s are unique. Malformed lessons fail the build. +- **Reduced-motion** honored by `KasimirStage`. + +## 9. Testing + +- **Unit (in-memory SQLite):** `completeLesson` writes a `TaskRecords` row on + the `(user, legacyId, KTT_LESSON)` tuple; re-completion upserts (no dupes); + unit-parent rollup produces the mission/group row via existing + `completion-rollup.js`; `getMyCompletions` returns the `kind:'ktt'` entry. + Guard the model with `cds deploy --to sqlite::memory:` pre-commit. +- **Lesson-engine unit tests:** drill scoring, wrong-answer re-queue, + XP/streak math, local↔HANA merge (union mastered, max XP/streak). +- **Island component tests:** `KasimirStage` state transitions; run via + `--project unit` from repo root (hugo-apps `.vue` gotcha). +- **AI banter:** mocked (`AICore-mocked`) locally; test the fail-open path + explicitly. +- **e2e (post-deploy, self-skipping):** committed Playwright spec — load + `/explore/ktt/`, start a lesson, answer a drill, see Kasimir react. +- **srv-qa cp-list audit** if any KTT helper lands under `srv/lib/` reachable + from `content-store.js`. + +## 10. Open items for implementation planning + +- Final unit list and the ~50–60 acronym set (drafted grounded, human-reviewed). +- Exact XP/streak formula and re-queue policy. +- Whether the landing SSR shell needs any crawler-facing acronym content. +- Confirm `/explore/ktt/` nav placement and label wording under "Explore". diff --git a/hugo-apps/src/ktt/App.vue b/hugo-apps/src/ktt/App.vue new file mode 100644 index 000000000..5b09641d9 --- /dev/null +++ b/hugo-apps/src/ktt/App.vue @@ -0,0 +1,174 @@ + + + diff --git a/hugo-apps/src/ktt/__tests__/App.test.ts b/hugo-apps/src/ktt/__tests__/App.test.ts new file mode 100644 index 000000000..d7a68ac42 --- /dev/null +++ b/hugo-apps/src/ktt/__tests__/App.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; + +// Mock the server module so the feature-gate probe is deterministic — no real +// fetch. isKttEnabled is toggled per-test via the mock's return value. +vi.mock('../lib/server', () => ({ + isKttEnabled: vi.fn(), + isAuthenticated: vi.fn().mockResolvedValue(false), + completeLesson: vi.fn().mockResolvedValue(null), + syncProgress: vi.fn().mockResolvedValue(null), + fetchBanter: vi.fn().mockResolvedValue(null), +})); + +import App from '../App.vue'; +import { isKttEnabled } from '../lib/server'; + +const LESSONS = { units: [{ id: 'core', title: 'Core Platform', icon: 'home', order: 1, + lessons: [{ id: 'core-1', legacyId: 90001, title: 'Meet the Platform', acronyms: [], beats: [] }] }] }; + +describe('KTT App', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders the landing screen with a start CTA when KTT is enabled', async () => { + (isKttEnabled as any).mockResolvedValue(true); + const wrapper = mount(App, { props: { apiUrl: '/ktt', lessons: LESSONS } }); + await flushPromises(); + expect(wrapper.text()).toContain('Kasimir'); + expect(wrapper.find('[data-testid="ktt-start"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="ktt-coming-soon"]').exists()).toBe(false); + }); + + it('fails closed with a coming-soon message and no drill engine when KTT is disabled', async () => { + (isKttEnabled as any).mockResolvedValue(false); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const wrapper = mount(App, { props: { apiUrl: '/ktt', lessons: LESSONS } }); + await flushPromises(); + + // Coming-soon shows; the start CTA / map / lesson engine must NOT render. + expect(wrapper.find('[data-testid="ktt-coming-soon"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="ktt-start"]').exists()).toBe(false); + expect(wrapper.find('.ktt-map').exists()).toBe(false); + expect(wrapper.find('.ktt-lesson-screen').exists()).toBe(false); + + // MUST NOT write localStorage progress while disabled. + expect(setItem).not.toHaveBeenCalled(); + }); +}); diff --git a/hugo-apps/src/ktt/__tests__/KasimirStage.test.ts b/hugo-apps/src/ktt/__tests__/KasimirStage.test.ts new file mode 100644 index 000000000..b1270e592 --- /dev/null +++ b/hugo-apps/src/ktt/__tests__/KasimirStage.test.ts @@ -0,0 +1,19 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import KasimirStage from '../components/KasimirStage.vue'; + +describe('KasimirStage', () => { + it('reflects mood in the root class and is aria-hidden', () => { + const w = mount(KasimirStage, { props: { mood: 'celebrate' } }); + const root = w.find('.kasimir'); + expect(root.classes()).toContain('kasimir--celebrate'); + expect(root.attributes('aria-hidden')).toBe('true'); + expect(w.find('svg').exists()).toBe(true); + }); + it('switches class when mood changes', async () => { + const w = mount(KasimirStage, { props: { mood: 'idle' } }); + await w.setProps({ mood: 'wrong' }); + expect(w.find('.kasimir').classes()).toContain('kasimir--wrong'); + }); +}); diff --git a/hugo-apps/src/ktt/__tests__/Lesson.test.ts b/hugo-apps/src/ktt/__tests__/Lesson.test.ts new file mode 100644 index 000000000..726402dfc --- /dev/null +++ b/hugo-apps/src/ktt/__tests__/Lesson.test.ts @@ -0,0 +1,198 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import Lesson from '../components/Lesson.vue'; + +// Stub server.ts — best-effort calls must never throw into the UI +vi.mock('../lib/server', () => ({ + isAuthenticated: vi.fn().mockResolvedValue(false), + completeLesson: vi.fn().mockResolvedValue(null), + syncProgress: vi.fn().mockResolvedValue(null), + fetchBanter: vi.fn().mockResolvedValue(null), +})); + +const storyLesson = { + id: 'core-1', + legacyId: 90001, + title: 'Meet BTP', + acronyms: ['BTP'], + beats: [ + { type: 'story', kasimir: 'BTP stands for Business Technology Platform!', mood: 'teaching' }, + { + type: 'drill', + kind: 'mc', + tla: 'BTP', + prompt: 'What does BTP stand for?', + answer: 'Business Technology Platform', + distractors: ['Better Tech Portal', 'Basic Trial Program'], + kasimirRight: 'Correct!', + kasimirWrong: 'Try again…', + }, + ], +}; + +// Lesson with two drills — for XP credit testing +const twoDrillLesson = { + id: 'core-2', + legacyId: 90002, + title: 'Two Drills', + acronyms: ['BTP', 'SAP'], + beats: [ + { + type: 'drill', + kind: 'mc', + tla: 'BTP', + prompt: 'What is BTP?', + answer: 'Business Technology Platform', + distractors: ['Better Tech Portal'], + kasimirRight: 'Yes!', + kasimirWrong: 'Nope', + }, + { + type: 'drill', + kind: 'mc', + tla: 'SAP', + prompt: 'What is SAP?', + answer: 'Systems Applications Products', + distractors: ['Some App Platform'], + kasimirRight: 'Right!', + kasimirWrong: 'Wrong', + }, + ], +}; + +describe('Lesson', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders current story beat text initially', () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + expect(w.text()).toContain('BTP stands for Business Technology Platform!'); + }); + + it('KasimirStage has teaching mood on story beat', () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + // KasimirStage renders div.kasimir-- + expect(w.find('.kasimir--teaching').exists()).toBe(true); + }); + + it('shows Next button on story beat and advances to drill on click', async () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + const btn = w.find('[data-testid="ktt-next"]'); + expect(btn.exists()).toBe(true); + await btn.trigger('click'); + // Should now show drill beat UI + expect(w.text()).toContain('What does BTP stand for?'); + }); + + it('shows answer choices on drill beat', async () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + // advance past story + await w.find('[data-testid="ktt-next"]').trigger('click'); + // Should show the correct answer and distractors as buttons + expect(w.find('[data-testid="ktt-choice"]').exists()).toBe(true); + }); + + it('correct answer shows correct mood and XP, then emits complete with lessonId and xp', async () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + // advance past story beat + await w.find('[data-testid="ktt-next"]').trigger('click'); + // click the correct answer choice — select by text, not index (shuffle-safe) + const choices = w.findAll('[data-testid="ktt-choice"]'); + const correct = choices.find(c => c.text() === 'Business Technology Platform'); + expect(correct).toBeTruthy(); + await correct!.trigger('click'); + // Kasimir should show correct mood + expect(w.find('.kasimir--correct').exists()).toBe(true); + // advance to completion + await w.find('[data-testid="ktt-next"]').trigger('click'); + // Allow a tick for any async work + await new Promise(r => setTimeout(r, 0)); + expect(w.emitted('complete')).toBeTruthy(); + // 1A: emits (lessonId, sessionXp) — one correct drill = 10 XP + expect(w.emitted('complete')![0]).toEqual(['core-1', 10]); + }); + + it('wrong answer shows wrong mood and does not emit complete', async () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + await w.find('[data-testid="ktt-next"]').trigger('click'); + const choices = w.findAll('[data-testid="ktt-choice"]'); + const wrong = choices.find(c => c.text() === 'Better Tech Portal'); + expect(wrong).toBeTruthy(); + await wrong!.trigger('click'); + expect(w.find('.kasimir--wrong').exists()).toBe(true); + expect(w.emitted('complete')).toBeFalsy(); + }); + + it('shows celebrate mood on lesson completion', async () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + // advance past story + await w.find('[data-testid="ktt-next"]').trigger('click'); + // answer correctly — select by text (shuffle-safe) + const choices = w.findAll('[data-testid="ktt-choice"]'); + await choices.find(c => c.text() === 'Business Technology Platform')!.trigger('click'); + // advance + await w.find('[data-testid="ktt-next"]').trigger('click'); + await new Promise(r => setTimeout(r, 0)); + // When complete, celebrate mood should appear + expect(w.find('.kasimir--celebrate').exists()).toBe(true); + }); + + it('displays earned XP during lesson', async () => { + const w = mount(Lesson, { props: { lesson: storyLesson } }); + await w.find('[data-testid="ktt-next"]').trigger('click'); + const choices = w.findAll('[data-testid="ktt-choice"]'); + // Select by text — shuffle-safe + await choices.find(c => c.text() === 'Business Technology Platform')!.trigger('click'); + // Should show XP + expect(w.text()).toContain('10'); + }); + + // 1A: multi-drill lesson credits the true earned XP + it('two-drill lesson emits complete with accumulated XP (20)', async () => { + const w = mount(Lesson, { props: { lesson: twoDrillLesson } }); + + // Answer first drill correctly (select by text) + let choices = w.findAll('[data-testid="ktt-choice"]'); + await choices.find(c => c.text() === 'Business Technology Platform')!.trigger('click'); + await w.find('[data-testid="ktt-next"]').trigger('click'); + + // Answer second drill correctly (select by text) + choices = w.findAll('[data-testid="ktt-choice"]'); + await choices.find(c => c.text() === 'Systems Applications Products')!.trigger('click'); + await w.find('[data-testid="ktt-next"]').trigger('click'); + + await new Promise(r => setTimeout(r, 0)); + expect(w.emitted('complete')).toBeTruthy(); + // Two correct answers = 20 XP + expect(w.emitted('complete')![0]).toEqual(['core-2', 20]); + }); + + // 1B: the emitted XP is the session delta (sessionXp), not a floor + it('emitted XP equals session earned XP (delta), not a hardcoded floor', async () => { + const w = mount(Lesson, { props: { lesson: twoDrillLesson } }); + // Answer first correctly, second wrongly then correctly (requeue logic) + let choices = w.findAll('[data-testid="ktt-choice"]'); + await choices.find(c => c.text() === 'Business Technology Platform')!.trigger('click'); + await w.find('[data-testid="ktt-next"]').trigger('click'); + + // Answer second wrong first + choices = w.findAll('[data-testid="ktt-choice"]'); + await choices.find(c => c.text() === 'Some App Platform')!.trigger('click'); + await w.find('[data-testid="ktt-next"]').trigger('click'); + + // Now re-queued — answer correctly + choices = w.findAll('[data-testid="ktt-choice"]'); + await choices.find(c => c.text() === 'Systems Applications Products')!.trigger('click'); + await w.find('[data-testid="ktt-next"]').trigger('click'); + + await new Promise(r => setTimeout(r, 0)); + expect(w.emitted('complete')).toBeTruthy(); + // Only 2 correct answers (10 each) despite 3 total attempts + const emittedXp = w.emitted('complete')![0][1] as number; + expect(emittedXp).toBe(20); + // It's definitely NOT the hardcoded floor of 10 + expect(emittedXp).toBeGreaterThanOrEqual(10); + }); +}); diff --git a/hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts b/hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts new file mode 100644 index 000000000..813d2ec21 --- /dev/null +++ b/hugo-apps/src/ktt/__tests__/SkillTreeMap.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import SkillTreeMap from '../components/SkillTreeMap.vue'; + +const twoLessonData = { + units: [ + { + id: 'core', + title: 'Core TLAs', + icon: '📚', + order: 1, + lessons: [ + { id: 'core-1', legacyId: 1, title: 'Lesson 1', acronyms: [], beats: [] }, + { id: 'core-2', legacyId: 2, title: 'Lesson 2', acronyms: [], beats: [] }, + ], + }, + ], +}; + +describe('SkillTreeMap', () => { + it('first lesson is selectable (not is-locked); second is is-locked when mastered=[]', () => { + const w = mount(SkillTreeMap, { + props: { lessons: twoLessonData, mastered: [] }, + }); + const node1 = w.find('[data-testid="ktt-node-core-1"]'); + const node2 = w.find('[data-testid="ktt-node-core-2"]'); + expect(node1.exists()).toBe(true); + expect(node2.exists()).toBe(true); + expect(node1.classes()).not.toContain('is-locked'); + expect(node2.classes()).toContain('is-locked'); + }); + + it('clicking the unlocked first node emits select with its id', async () => { + const w = mount(SkillTreeMap, { + props: { lessons: twoLessonData, mastered: [] }, + }); + await w.find('[data-testid="ktt-node-core-1"]').trigger('click'); + expect(w.emitted('select')).toBeTruthy(); + expect(w.emitted('select')![0]).toEqual(['core-1']); + }); + + it('clicking a locked node does not emit select', async () => { + const w = mount(SkillTreeMap, { + props: { lessons: twoLessonData, mastered: [] }, + }); + await w.find('[data-testid="ktt-node-core-2"]').trigger('click'); + expect(w.emitted('select')).toBeFalsy(); + }); + + it('core-2 is unlocked when mastered includes core-1', () => { + const w = mount(SkillTreeMap, { + props: { lessons: twoLessonData, mastered: ['core-1'] }, + }); + expect(w.find('[data-testid="ktt-node-core-2"]').classes()).not.toContain('is-locked'); + }); + + it('mastered lessons get is-done class', () => { + const w = mount(SkillTreeMap, { + props: { lessons: twoLessonData, mastered: ['core-1'] }, + }); + expect(w.find('[data-testid="ktt-node-core-1"]').classes()).toContain('is-done'); + }); +}); diff --git a/hugo-apps/src/ktt/__tests__/engine.test.ts b/hugo-apps/src/ktt/__tests__/engine.test.ts new file mode 100644 index 000000000..38c70a536 --- /dev/null +++ b/hugo-apps/src/ktt/__tests__/engine.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { createSession, currentBeat, answerDrill, advance, isComplete, XP_PER_CORRECT } from '../lib/engine'; + +const lesson = { id: 'core-1', legacyId: 90001, title: 't', acronyms: [], beats: [ + { type: 'story', kasimir: 'hi', mood: 'teaching' }, + { type: 'drill', kind: 'mc', tla: 'BTP', prompt: '?', answer: 'Business Technology Platform', + distractors: ['a','b'], kasimirRight: 'y', kasimirWrong: 'n' }, +]} as any; + +describe('engine', () => { + it('awards XP on correct and re-queues on wrong', () => { + const s = createSession(lesson); + advance(s); // move past story to drill + expect(currentBeat(s).type).toBe('drill'); + const wrong = answerDrill(s, 'a'); + expect(wrong.correct).toBe(false); + expect(wrong.requeued).toBe(true); + advance(s); + const right = answerDrill(s, 'Business Technology Platform'); + expect(right.correct).toBe(true); + expect(s.xp).toBe(XP_PER_CORRECT); + advance(s); + expect(isComplete(s)).toBe(true); + }); + + it('re-queues at most once (second wrong answer not re-queued)', () => { + const s = createSession(lesson); + advance(s); // skip story beat + const first = answerDrill(s, 'wrong-answer'); + expect(first.requeued).toBe(true); + // queue is now [1] at end; advance to reach re-queued beat + advance(s); + const second = answerDrill(s, 'still-wrong'); + expect(second.correct).toBe(false); + expect(second.requeued).toBe(false); // NOT re-queued again + expect(s.wrong).toBe(2); + }); + + it('case-insensitive answer matching', () => { + const s = createSession(lesson); + advance(s); // skip story beat + const r = answerDrill(s, 'BUSINESS TECHNOLOGY PLATFORM'); + expect(r.correct).toBe(true); + expect(s.xp).toBe(XP_PER_CORRECT); + }); + + it('answerDrill on a story beat returns not-correct, not-requeued', () => { + const s = createSession(lesson); + // index=0 is the story beat + const r = answerDrill(s, 'anything'); + expect(r.correct).toBe(false); + expect(r.requeued).toBe(false); + }); +}); diff --git a/hugo-apps/src/ktt/__tests__/progress.test.ts b/hugo-apps/src/ktt/__tests__/progress.test.ts new file mode 100644 index 000000000..10e6141f8 --- /dev/null +++ b/hugo-apps/src/ktt/__tests__/progress.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { loadLocal, saveLocal, mergeProgress } from '../lib/progress'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('progress — localStorage round-trip', () => { + it('loadLocal returns defaults when storage is empty', () => { + const p = loadLocal(); + expect(p).toEqual({ xp: 0, streak: 0, mastered: [] }); + }); + + it('saveLocal + loadLocal round-trips', () => { + const data = { xp: 120, streak: 3, mastered: ['core-1', 'core-2'] }; + saveLocal(data); + expect(loadLocal()).toEqual(data); + }); + + it('loadLocal is robust against corrupted JSON', () => { + localStorage.setItem('ktt_progress', 'NOT_JSON'); + expect(loadLocal()).toEqual({ xp: 0, streak: 0, mastered: [] }); + }); +}); + +describe('mergeProgress — matches backend semantics', () => { + it('unions mastered and takes max xp/streak', () => { + const local = { xp: 120, streak: 3, mastered: ['core-1', 'core-2'] }; + const remote = { xp: 90, streak: 5, mastered: ['core-2', 'sec-1'] }; + // Exact expectations from test/unit/ktt-merge.test.js + expect(mergeProgress(local, remote)).toEqual({ + xp: 120, streak: 5, mastered: ['core-1', 'core-2', 'sec-1'], + }); + }); + + it('handles empty local and remote gracefully', () => { + expect(mergeProgress({}, {})).toEqual({ xp: 0, streak: 0, mastered: [] }); + }); + + it('takes max xp when remote is higher', () => { + const local = { xp: 50, streak: 1, mastered: [] }; + const remote = { xp: 200, streak: 0, mastered: ['a'] }; + const result = mergeProgress(local, remote); + expect(result.xp).toBe(200); + expect(result.streak).toBe(1); + }); +}); diff --git a/hugo-apps/src/ktt/components/KasimirStage.vue b/hugo-apps/src/ktt/components/KasimirStage.vue new file mode 100644 index 000000000..82381363b --- /dev/null +++ b/hugo-apps/src/ktt/components/KasimirStage.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/hugo-apps/src/ktt/components/Lesson.vue b/hugo-apps/src/ktt/components/Lesson.vue new file mode 100644 index 000000000..6534d7412 --- /dev/null +++ b/hugo-apps/src/ktt/components/Lesson.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/hugo-apps/src/ktt/components/Results.vue b/hugo-apps/src/ktt/components/Results.vue new file mode 100644 index 000000000..f777cd6c5 --- /dev/null +++ b/hugo-apps/src/ktt/components/Results.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/hugo-apps/src/ktt/components/SkillTreeMap.vue b/hugo-apps/src/ktt/components/SkillTreeMap.vue new file mode 100644 index 000000000..a14092f77 --- /dev/null +++ b/hugo-apps/src/ktt/components/SkillTreeMap.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/hugo-apps/src/ktt/lib/engine.ts b/hugo-apps/src/ktt/lib/engine.ts new file mode 100644 index 000000000..03379a0fc --- /dev/null +++ b/hugo-apps/src/ktt/lib/engine.ts @@ -0,0 +1,130 @@ +/** + * KTT Lesson Engine — pure TypeScript, no DOM/Vue dependencies. + * + * Manages session state for a single KTT lesson: beat queue, XP scoring, + * and wrong-answer re-queue logic (re-queue at most once per beat). + */ + +// --------------------------------------------------------------------------- +// Minimal local types — do NOT import from scripts/; keep the island self-contained. +// --------------------------------------------------------------------------- + +export interface KttStoryBeat { + type: 'story'; + kasimir: string; + mood: string; +} + +export interface KttDrillBeat { + type: 'drill'; + kind: 'mc' | 'type'; + tla: string; + prompt: string; + answer: string; + distractors: string[]; + kasimirRight: string; + kasimirWrong: string; +} + +export type KttBeat = KttStoryBeat | KttDrillBeat; + +export interface KttLesson { + id: string; + legacyId: number; + title: string; + acronyms: string[]; + beats: KttBeat[]; +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export interface Session { + beats: KttBeat[]; + /** Current position within `queue` (index into queue array, not beat index). */ + index: number; + xp: number; + correct: number; + wrong: number; + /** Ordered list of beat indices still to visit. */ + queue: number[]; + /** Tracks which beat indices have already been re-queued (re-queue once limit). */ + requeuedSet: Set; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const XP_PER_CORRECT = 10; + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +/** Create a fresh session for the given lesson. */ +export function createSession(lesson: KttLesson): Session { + const queue = lesson.beats.map((_, i) => i); + return { + beats: lesson.beats, + index: 0, + xp: 0, + correct: 0, + wrong: 0, + queue, + requeuedSet: new Set(), + }; +} + +/** Return the beat at the current queue position, or null when exhausted. */ +export function currentBeat(s: Session): KttBeat | null { + if (s.index >= s.queue.length) return null; + return s.beats[s.queue[s.index]] ?? null; +} + +/** + * Evaluate an answer against the current beat. + * + * - Only meaningful for `drill` beats; story beats always return `{ correct: false, requeued: false }`. + * - Comparison is case-insensitive (trim + toLowerCase). + * - Correct: increment `correct`, add XP_PER_CORRECT to `xp`. + * - Wrong: increment `wrong`; push beat index to end of queue ONCE (tracked via requeuedSet). + */ +export function answerDrill(s: Session, answer: string): { correct: boolean; requeued: boolean } { + const beat = currentBeat(s); + if (!beat || beat.type !== 'drill') { + return { correct: false, requeued: false }; + } + + const isCorrect = answer.trim().toLowerCase() === beat.answer.trim().toLowerCase(); + + if (isCorrect) { + s.correct += 1; + s.xp += XP_PER_CORRECT; + return { correct: true, requeued: false }; + } + + // Wrong answer + s.wrong += 1; + const beatIndex = s.queue[s.index]; + if (!s.requeuedSet.has(beatIndex)) { + s.requeuedSet.add(beatIndex); + s.queue.push(beatIndex); + return { correct: false, requeued: true }; + } + + return { correct: false, requeued: false }; +} + +/** Advance to the next beat in the queue. */ +export function advance(s: Session): void { + if (s.index < s.queue.length) { + s.index += 1; + } +} + +/** Return true when all beats in the queue have been visited. */ +export function isComplete(s: Session): boolean { + return currentBeat(s) === null; +} diff --git a/hugo-apps/src/ktt/lib/progress.ts b/hugo-apps/src/ktt/lib/progress.ts new file mode 100644 index 000000000..ea80011fc --- /dev/null +++ b/hugo-apps/src/ktt/lib/progress.ts @@ -0,0 +1,55 @@ +/** + * KTT local progress helpers — localStorage persistence + merge semantics. + * Mirrors the backend srv/lib/ktt/merge.js logic exactly. + */ + +const STORAGE_KEY = 'ktt_progress'; + +export interface KttProgress { + xp: number; + streak: number; + mastered: string[]; +} + +const DEFAULTS: KttProgress = { xp: 0, streak: 0, mastered: [] }; + +/** Load KTT progress from localStorage. Returns defaults on parse error or missing key. */ +export function loadLocal(): KttProgress { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULTS, mastered: [] }; + const parsed = JSON.parse(raw); + return { + xp: Number(parsed.xp) || 0, + streak: Number(parsed.streak) || 0, + mastered: Array.isArray(parsed.mastered) ? parsed.mastered : [], + }; + } catch { + return { ...DEFAULTS, mastered: [] }; + } +} + +/** Persist KTT progress to localStorage. */ +export function saveLocal(p: KttProgress): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); +} + +/** + * Merge local (localStorage) KTT progress with remote (HANA) progress. + * - xp/streak: take the numeric max. + * - mastered: sorted union of slugs. + * Matches the backend srv/lib/ktt/merge.js mergeProgress semantics exactly. + */ +export function mergeProgress( + local: Partial = {}, + remote: Partial = {}, +): KttProgress { + const mastered = Array.from( + new Set([...(local.mastered ?? []), ...(remote.mastered ?? [])]), + ).sort(); + return { + xp: Math.max(Number(local.xp) || 0, Number(remote.xp) || 0), + streak: Math.max(Number(local.streak) || 0, Number(remote.streak) || 0), + mastered, + }; +} diff --git a/hugo-apps/src/ktt/lib/server.ts b/hugo-apps/src/ktt/lib/server.ts new file mode 100644 index 000000000..dbd59df48 --- /dev/null +++ b/hugo-apps/src/ktt/lib/server.ts @@ -0,0 +1,101 @@ +/** + * KTT server client — best-effort, never throws into the UI. + * Uses native fetch (Node/browser built-in — no axios or other HTTP libs). + * + * Auth probe gotcha: /auth/user is cached by Akamai so r.ok is unreliable. + * MUST parse JSON and check body.authenticated === true. + */ + +import { csrfFetch } from '@shared/csrf-fetch'; +import type { KttProgress } from './progress'; + +/** + * Returns true when the current user is authenticated. + * Probes /auth/user, parses JSON, checks body.authenticated. + * Does NOT rely on r.ok (Akamai caches /auth/user). + */ +export async function isAuthenticated(): Promise { + try { + const r = await fetch('/auth/user'); + const body = await r.json(); + return body.authenticated === true; + } catch { + return false; + } +} + +/** + * Probe whether the KTT feature is enabled server-side. + * The KTT_ENABLED flag is a runtime DB value (no redeploy), so the client must + * ask the server: it fetches the readonly Lessons projection, which the service + * rejects with 503 when the flag is OFF. Returns false on 503/network error, + * true on ok — best-effort, never throws. + */ +export async function isKttEnabled(apiUrl: string): Promise { + try { + const r = await fetch(`${apiUrl}/Lessons?$top=1`); + return r.ok; + } catch { + return false; + } +} + +/** + * Notify the server that a lesson was completed. + * Best-effort: failures are caught and resolve to null. + */ +export async function completeLesson( + apiUrl: string, + payload: { lessonSlug: string; legacyId: number; title: string }, +): Promise<{ ok: boolean; alreadyDone: boolean } | null> { + try { + const r = await csrfFetch(`${apiUrl}/completeLesson`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!r.ok) return null; + return await r.json(); + } catch { + return null; + } +} + +/** + * Sync local progress to the server and receive the merged remote copy. + * Best-effort: failures are caught and resolve to null. + */ +export async function syncProgress( + apiUrl: string, + local: KttProgress, +): Promise<{ xp: number; streak: number; mastered: string[] } | null> { + try { + const r = await csrfFetch(`${apiUrl}/syncProgress`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ localJson: JSON.stringify(local) }), + }); + if (!r.ok) return null; + return await r.json(); + } catch { + return null; + } +} + +/** + * Fetch a banter line from Kasimir for the given context. + * Best-effort: failures return null so the UI falls back to scripted lines. + */ +export async function fetchBanter( + apiUrl: string, + context: string, +): Promise { + try { + const r = await fetch(`${apiUrl}/banter(context='${encodeURIComponent(context)}')`); + if (!r.ok) return null; + const body = await r.json(); + return typeof body.value === 'string' ? body.value : null; + } catch { + return null; + } +} diff --git a/hugo-apps/src/ktt/main.ts b/hugo-apps/src/ktt/main.ts new file mode 100644 index 000000000..0453a6b28 --- /dev/null +++ b/hugo-apps/src/ktt/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +const mount = document.getElementById('ktt-mount') as HTMLElement | null; +if (mount) { + let lessons = {}; + try { lessons = JSON.parse(mount.dataset.lessons || '{}'); } catch { lessons = {}; } + createApp(App, { apiUrl: mount.dataset.api || '/ktt', lessons }).mount(mount); +} diff --git a/hugo-apps/vite.config.ts b/hugo-apps/vite.config.ts index 254699420..fdd74b9a1 100644 --- a/hugo-apps/vite.config.ts +++ b/hugo-apps/vite.config.ts @@ -322,6 +322,7 @@ export default defineConfig({ 'topic-clusters-band': resolve(__dirname, 'src/topic-clusters-band/main.ts'), 'homepage-events-band': resolve(__dirname, 'src/homepage-events-band/main.ts'), puzzle: resolve(__dirname, 'src/puzzle/main.ts'), + ktt: resolve(__dirname, 'src/ktt/main.ts'), petoberfest: resolve(__dirname, 'src/petoberfest/main.ts'), 'devtoberfest-schedule': resolve(__dirname, 'src/devtoberfest-schedule/main.ts'), 'devtoberfest-sessions-grid': resolve(__dirname, 'src/devtoberfest-sessions-grid/main.ts'), diff --git a/hugo/content/explore/ktt/_index.md b/hugo/content/explore/ktt/_index.md new file mode 100644 index 000000000..8cbf32be7 --- /dev/null +++ b/hugo/content/explore/ktt/_index.md @@ -0,0 +1,7 @@ +--- +title: "KTT — Kasimir Teaches TLAs" +description: "Professor Kasimir teaches you SAP's endless three-letter acronyms — a mostly-fun, tongue-in-cheek trainer." +type: explore +layout: ktt +slug: ktt +--- diff --git a/hugo/data/ktt_lessons.json b/hugo/data/ktt_lessons.json new file mode 100644 index 000000000..79ee3698d --- /dev/null +++ b/hugo/data/ktt_lessons.json @@ -0,0 +1,1176 @@ +{ + "units": [ + { + "id": "unit-1-platform", + "title": "SAP Core Platform", + "icon": "🏗️", + "order": 1, + "lessons": [ + { + "id": "unit-1-platform-lesson-1", + "legacyId": 90001, + "title": "Who's SAP, and what's BTP?", + "acronyms": [ + { + "tla": "SAP", + "expansion": "Systems, Applications, Products in Data Processing", + "blurb": "The full name of the company behind your daily BTP adventures. Founded in Walldorf, 1972.", + "category": "company" + }, + { + "tla": "BTP", + "expansion": "SAP Business Technology Platform", + "blurb": "SAP's unified cloud platform for building, extending, and integrating business applications. The successor to SAP Cloud Platform (SCP).", + "category": "platform" + }, + { + "tla": "SCP", + "expansion": "SAP Cloud Platform", + "blurb": "The former name of SAP BTP (deprecated 2021). You'll still see SCP in older tutorials and Stack Overflow answers.", + "category": "platform" + }, + { + "tla": "IAS", + "expansion": "SAP Identity Authentication Service", + "blurb": "SAP's cloud-based Identity Provider (IdP) on BTP. Handles authentication for SAP apps and custom services via SAML 2.0 and OIDC.", + "category": "security" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Ah, a fresh student! Before we write a single line of CDS, let's settle the most fundamental questions: who runs this show, and what platform are we on?", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "SAP", + "prompt": "What does SAP stand for?", + "answer": "Systems, Applications, Products in Data Processing", + "distractors": [ + "Software Architecture Platform", + "Service Automation Protocol" + ], + "kasimirRight": "Correct! Even I know who signs my cat food invoices. 🐱", + "kasimirWrong": "SAP = Systems, Applications, Products in Data Processing. It's literally on the letterhead!" + }, + { + "type": "drill", + "kind": "mc", + "tla": "BTP", + "prompt": "What does BTP stand for?", + "answer": "SAP Business Technology Platform", + "distractors": [ + "SAP Big-data Transaction Platform", + "SAP Business Transaction Processing" + ], + "kasimirRight": "BTP — the cloud with a view from Walldorf! Nicely done. ☁️", + "kasimirWrong": "BTP = SAP Business Technology Platform. The \"T\" is Technology, not Transaction." + }, + { + "type": "drill", + "kind": "mc", + "tla": "SCP", + "prompt": "What does SCP stand for?", + "answer": "SAP Cloud Platform", + "distractors": [ + "SAP Continuous Provisioning", + "SAP Core Protocol" + ], + "kasimirRight": "SCP — the old BTP. Nostalgia is allowed. 📜", + "kasimirWrong": "SCP = SAP Cloud Platform, the predecessor to BTP. History matters in enterprise software!" + }, + { + "type": "drill", + "kind": "mc", + "tla": "IAS", + "prompt": "What does IAS stand for?", + "answer": "SAP Identity Authentication Service", + "distractors": [ + "Integrated Authorization Service", + "Internal Application Server" + ], + "kasimirRight": "IAS — the bouncer of the SAP cloud. Excellent! 🚪", + "kasimirWrong": "IAS = SAP Identity Authentication Service. It's the IdP, not just any authorization layer." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered SAP, BTP, SCP, IAS. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-1-platform-lesson-2", + "legacyId": 90002, + "title": "The CAP Stack", + "acronyms": [ + { + "tla": "CAP", + "expansion": "SAP Cloud Application Programming Model", + "blurb": "SAP's framework for building cloud-native business applications using CDS, Node.js, and Java. The backbone of tutorials-ims.", + "category": "framework" + }, + { + "tla": "BAS", + "expansion": "SAP Business Application Studio", + "blurb": "SAP's browser-based IDE on BTP, built on VS Code Open Source. The recommended tool for CAP, Fiori, and ABAP Cloud development.", + "category": "tooling" + }, + { + "tla": "SDK", + "expansion": "Software Development Kit", + "blurb": "A bundle of tools, libraries, and documentation for building on a platform. SAP Cloud SDK adds BTP destinations, resilience, and type-safe OData clients.", + "category": "tooling" + }, + { + "tla": "API", + "expansion": "Application Programming Interface", + "blurb": "A defined contract that lets software components talk to each other. SAP publishes thousands of APIs on SAP Business Accelerator Hub (api.sap.com).", + "category": "architecture" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Now we're cooking! CAP is where the magic happens. Let me introduce you to my favourite framework — and no, I'm not biased just because it's named after something a professor wears.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "CAP", + "prompt": "What does CAP stand for?", + "answer": "SAP Cloud Application Programming Model", + "distractors": [ + "Cloud API Protocol", + "Customer Application Pack" + ], + "kasimirRight": "CAP! You've earned a treat. My whole world runs on CAP. 🐾", + "kasimirWrong": "CAP = SAP Cloud Application Programming Model. CDS, Node.js, Java — it's all in there." + }, + { + "type": "drill", + "kind": "mc", + "tla": "BAS", + "prompt": "What does BAS stand for?", + "answer": "SAP Business Application Studio", + "distractors": [ + "Backend Automation System", + "Business API Services" + ], + "kasimirRight": "BAS — where humans write the code that Kasimir reviews. Well played! 💻", + "kasimirWrong": "BAS = SAP Business Application Studio — the cloud IDE on BTP, not a backend system." + }, + { + "type": "drill", + "kind": "mc", + "tla": "SDK", + "prompt": "What does SDK stand for?", + "answer": "Software Development Kit", + "distractors": [ + "Service Deployment Kit", + "Standard Data Keyring" + ], + "kasimirRight": "SDK — the box of LEGO bricks every developer loves. Exactly right! 🧰", + "kasimirWrong": "SDK = Software Development Kit. Not \"Service Deployment\" anything." + }, + { + "type": "drill", + "kind": "mc", + "tla": "API", + "prompt": "What does API stand for?", + "answer": "Application Programming Interface", + "distractors": [ + "Automated Process Integration", + "Application Persistence Index" + ], + "kasimirRight": "API — the handshake of the cloud world. I knew you'd get this one! 🤝", + "kasimirWrong": "API = Application Programming Interface. The \"P\" is Programming, not Process." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered CAP, BAS, SDK, API. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-1-platform-lesson-3", + "legacyId": 90003, + "title": "Deploying & Operating", + "acronyms": [ + { + "tla": "MTA", + "expansion": "Multi-Target Application", + "blurb": "SAP's deployment artefact format for BTP. A single `.mtar` archive bundles multiple modules (backend, UI, DB) and wires up service bindings automatically.", + "category": "deployment" + }, + { + "tla": "ANS", + "expansion": "SAP Alert Notification Service", + "blurb": "A BTP service that sends real-time alerts about application events to email, Slack, or custom webhooks. Used in tutorials-ims for operational monitoring.", + "category": "operations" + }, + { + "tla": "SAC", + "expansion": "SAP Analytics Cloud", + "blurb": "SAP's SaaS business intelligence platform for dashboards, planning, and predictive analytics. Lives on BTP.", + "category": "analytics" + }, + { + "tla": "MDK", + "expansion": "SAP Mobile Development Kit", + "blurb": "A cross-platform toolkit for building native-looking SAP mobile apps that work offline. Based on a metadata-driven approach — no separate iOS/Android code.", + "category": "mobile" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Congratulations — you've built a CAP app! Now how do you actually ship it? MTA archives, alert services, analytics clouds — the operational layer awaits.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "MTA", + "prompt": "What does MTA stand for?", + "answer": "Multi-Target Application", + "distractors": [ + "Managed Tenant Architecture", + "Module Transfer Archive" + ], + "kasimirRight": "MTA — one package to rule them all! Purrfectly correct. 📦", + "kasimirWrong": "MTA = Multi-Target Application. It bundles all your BTP modules into one deployable archive." + }, + { + "type": "drill", + "kind": "mc", + "tla": "ANS", + "prompt": "What does ANS stand for?", + "answer": "SAP Alert Notification Service", + "distractors": [ + "Automated Notification System", + "Application Node Service" + ], + "kasimirRight": "ANS — the smoke alarm of your BTP app. Alert! You're correct! 🚨", + "kasimirWrong": "ANS = SAP Alert Notification Service. It notifies, alerts, and generally keeps you from sleeping." + }, + { + "type": "drill", + "kind": "mc", + "tla": "SAC", + "prompt": "What does SAC stand for?", + "answer": "SAP Analytics Cloud", + "distractors": [ + "SAP Application Console", + "SAP Automation Center" + ], + "kasimirRight": "SAC — where data becomes pretty pictures! Right on target. 📊", + "kasimirWrong": "SAC = SAP Analytics Cloud. Dashboards, planning, predictions — all in the cloud." + }, + { + "type": "drill", + "kind": "mc", + "tla": "MDK", + "prompt": "What does MDK stand for?", + "answer": "SAP Mobile Development Kit", + "distractors": [ + "Mobile Data Kit", + "Metadata Deployment Kit" + ], + "kasimirRight": "MDK — go mobile! Even I have a paw-friendly app. Great answer! 📱", + "kasimirWrong": "MDK = SAP Mobile Development Kit. Cross-platform, metadata-driven, and furiously offline-capable." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered MTA, ANS, SAC, MDK. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + } + ] + }, + { + "id": "unit-2-cds", + "title": "CDS & App Development", + "icon": "🛠️", + "order": 2, + "lessons": [ + { + "id": "unit-2-cds-lesson-1", + "legacyId": 90004, + "title": "The CDS Language Family", + "acronyms": [ + { + "tla": "CDS", + "expansion": "Core Data Services", + "blurb": "CAP's universal declarative language for data models and service definitions. Also powers S/4HANA views. The backbone of everything in CAP.", + "category": "framework" + }, + { + "tla": "CSN", + "expansion": "Core Schema Notation", + "blurb": "CDS models compiled to a compact JSON representation (pronounced \"Season\"). The runtime format CAP uses internally — generated from CDL source files.", + "category": "framework" + }, + { + "tla": "CDL", + "expansion": "Conceptual Definition Language", + "blurb": "The human-readable source syntax you write in `.cds` files — entities, services, annotations. Official CAP docs state: \"We use CDS's Conceptual Definition Language (CDL)…\". Compiles to CSN for runtime use.", + "category": "framework" + }, + { + "tla": "CQL", + "expansion": "CDS Query Language", + "blurb": "The SQL-like query language for CAP: `SELECT.from(Books).where(...)`. Type-safe, composable, and database-agnostic. Part of the `cds.ql` API.", + "category": "framework" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "CDS is not one thing — it's a whole language family. CSN, CDL, CQL… I collect acronyms the way others collect stamps. Let's unpack them.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "CDS", + "prompt": "What does CDS stand for?", + "answer": "Core Data Services", + "distractors": [ + "Cloud Data Store", + "Connected Data Services" + ], + "kasimirRight": "CDS — the language I dream in! You've clearly been reading the docs. 📚", + "kasimirWrong": "CDS = Core Data Services — SAP's universal modeling language. Not \"Cloud\" anything." + }, + { + "type": "drill", + "kind": "mc", + "tla": "CSN", + "prompt": "What does CSN stand for?", + "answer": "Core Schema Notation", + "distractors": [ + "Cloud Service Network", + "Core Serialization Notation" + ], + "kasimirRight": "CSN — like JSON Schema but for full entity-relationship models. Season greetings! 🎄", + "kasimirWrong": "CSN = Core Schema Notation, pronounced \"Season\". It's the JSON form of your CDS models." + }, + { + "type": "drill", + "kind": "mc", + "tla": "CDL", + "prompt": "What does CDL stand for?", + "answer": "Conceptual Definition Language", + "distractors": [ + "Cloud Deployment Language", + "Core Data Library" + ], + "kasimirRight": "CDL — what you write, CSN is what the machine reads. You're fluent! ✍️", + "kasimirWrong": "CDL = Conceptual Definition Language (official CAP term). The .cds files you write are CDL; they compile down to CSN." + }, + { + "type": "drill", + "kind": "mc", + "tla": "CQL", + "prompt": "What does CQL stand for?", + "answer": "CDS Query Language", + "distractors": [ + "Cloud Query Layer", + "Core Queue Language" + ], + "kasimirRight": "CQL — SELECT * FROM correct_answers! Top marks. 🐱‍💻", + "kasimirWrong": "CQL = CDS Query Language — the SELECT.from(...) syntax in `cds.ql`. Never write raw SQL!" + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered CDS, CSN, CDL, CQL. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-2-cds-lesson-2", + "legacyId": 90005, + "title": "Protocols & Standards", + "acronyms": [ + { + "tla": "OData", + "expansion": "Open Data Protocol", + "blurb": "An OASIS standard REST-based protocol for CRUD APIs with query capabilities ($filter, $expand, $top…). All CAP services can be served as OData V4.", + "category": "protocol" + }, + { + "tla": "RAP", + "expansion": "ABAP RESTful Application Programming Model", + "blurb": "SAP's recommended framework for building Fiori apps and OData services on ABAP. Uses CDS views, behavior definitions, and service bindings.", + "category": "framework" + }, + { + "tla": "REST", + "expansion": "Representational State Transfer", + "blurb": "The architectural style behind most web APIs: stateless HTTP, resources as URLs, verbs as HTTP methods (GET/POST/PUT/DELETE). OData is a RESTful protocol.", + "category": "protocol" + }, + { + "tla": "IDE", + "expansion": "Integrated Development Environment", + "blurb": "A software tool that combines code editor, debugger, and build tools in one UI. SAP's cloud IDE is BAS; locally VS Code + CDS extension is the go-to.", + "category": "tooling" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Every API speaks a language. OData, REST, RAP — the protocols that let your app talk to the world. Put on your headphones; it's time for a multilingual lesson.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "OData", + "prompt": "What does OData stand for?", + "answer": "Open Data Protocol", + "distractors": [ + "Optimised Data Access", + "Object Data Architecture" + ], + "kasimirRight": "OData — the protocol that makes SAP APIs queryable from Excel. Impressive! 📡", + "kasimirWrong": "OData = Open Data Protocol (OASIS standard). Think REST + $filter + $expand — pure power." + }, + { + "type": "drill", + "kind": "mc", + "tla": "RAP", + "prompt": "What does RAP stand for?", + "answer": "ABAP RESTful Application Programming Model", + "distractors": [ + "Remote Application Protocol", + "Resource-Aware Processing" + ], + "kasimirRight": "RAP — the CAP of the ABAP world! You're now officially bilingual. 🎓", + "kasimirWrong": "RAP = ABAP RESTful Application Programming Model. It's to ABAP what CAP is to Node.js/Java." + }, + { + "type": "drill", + "kind": "mc", + "tla": "REST", + "prompt": "What does REST stand for?", + "answer": "Representational State Transfer", + "distractors": [ + "Remote Event Stream Transfer", + "Reliable Endpoint Service Technology" + ], + "kasimirRight": "REST — the law of the web land. Stateless, resourceful, correct! 🌐", + "kasimirWrong": "REST = Representational State Transfer (Roy Fielding, 2000). Every web API architect owes him a coffee." + }, + { + "type": "drill", + "kind": "mc", + "tla": "IDE", + "prompt": "What does IDE stand for?", + "answer": "Integrated Development Environment", + "distractors": [ + "Internal Deployment Engine", + "Interface Design Editor" + ], + "kasimirRight": "IDE — where the magic happens before the bug report! You knew it. 🖥️", + "kasimirWrong": "IDE = Integrated Development Environment. VS Code + CDS extension = one happy IDE." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered OData, RAP, REST, IDE. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-2-cds-lesson-3", + "legacyId": 90006, + "title": "Tools of the Trade", + "acronyms": [ + { + "tla": "FLP", + "expansion": "SAP Fiori Launchpad", + "blurb": "The web shell that hosts SAP Fiori apps as tiles in a unified homepage. Handles navigation, routing, and user personalization across all Fiori apps.", + "category": "ui" + }, + { + "tla": "UI5", + "expansion": "SAPUI5", + "blurb": "SAPUI5 is a product name, not a spelled-out acronym — \"5\" is the generation designator for SAP's enterprise JavaScript UI framework. Built on OpenUI5 (the open-source base), it delivers Fiori-ready components, Horizon theme, and OData V4 model binding out of the box.", + "category": "ui" + }, + { + "tla": "OPA", + "expansion": "One Page Acceptance Test Framework", + "blurb": "SAP's asynchronous integration-test framework for UI5/Fiori apps. Tests page objects and UI flows without flakiness, running inside the browser.", + "category": "testing" + }, + { + "tla": "CLI", + "expansion": "Command-Line Interface", + "blurb": "A text-based tool for interacting with software. CAP provides `cds` CLI; BTP has `btp`; CF has `cf`. Together they're the developer's Swiss army knife.", + "category": "tooling" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "A developer without tools is just someone staring at a blank screen. FLP, UI5, OPA, CLI — the toolkit that turns ideas into shipped features.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "FLP", + "prompt": "What does FLP stand for?", + "answer": "SAP Fiori Launchpad", + "distractors": [ + "Flexible Layout Page", + "Fiori Link Provider" + ], + "kasimirRight": "FLP — the tile garden where Fiori apps bloom! Nailed it. 🏡", + "kasimirWrong": "FLP = SAP Fiori Launchpad — the shell that tiles up your Fiori apps in one place." + }, + { + "type": "drill", + "kind": "mc", + "tla": "UI5", + "prompt": "What does UI5 stand for?", + "answer": "SAPUI5", + "distractors": [ + "Universal Interface 5", + "SAP User Interaction 5" + ], + "kasimirRight": "UI5 — the framework behind every Fiori face! Stylishly correct. 🎨", + "kasimirWrong": "UI5 = SAPUI5 — SAP's enterprise JavaScript framework. OpenUI5 is its open-source twin." + }, + { + "type": "drill", + "kind": "mc", + "tla": "OPA", + "prompt": "What does OPA stand for?", + "answer": "One Page Acceptance Test Framework", + "distractors": [ + "Open Protocol Adapter", + "Object Page Action" + ], + "kasimirRight": "OPA — the test cat catches every bug! Acceptably correct. 🧪", + "kasimirWrong": "OPA = One Page Acceptance Test Framework — UI5's built-in asynchronous integration test library." + }, + { + "type": "drill", + "kind": "mc", + "tla": "CLI", + "prompt": "What does CLI stand for?", + "answer": "Command-Line Interface", + "distractors": [ + "Cloud Layer Interface", + "Component Library Index" + ], + "kasimirRight": "CLI — where humans type furiously and things actually happen! Correct! ⌨️", + "kasimirWrong": "CLI = Command-Line Interface. `cds watch`, `btp login`, `cf push` — all CLI commands." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered FLP, UI5, OPA, CLI. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + } + ] + }, + { + "id": "unit-3-hana", + "title": "HANA & Data", + "icon": "🗄️", + "order": 3, + "lessons": [ + { + "id": "unit-3-hana-lesson-1", + "legacyId": 90007, + "title": "The HANA Universe", + "acronyms": [ + { + "tla": "HANA", + "expansion": "High-performance ANalytic Appliance", + "blurb": "SAP's in-memory relational database. Processes analytical and transactional workloads simultaneously (\"HTAP\"). The default production database for CAP apps on BTP.", + "category": "database" + }, + { + "tla": "HDI", + "expansion": "HANA Deployment Infrastructure", + "blurb": "A SAP HANA feature that manages database artefacts (tables, views, procedures) in isolated, versioned containers. CAP uses HDI containers for all HANA deployments.", + "category": "database" + }, + { + "tla": "SDA", + "expansion": "SAP HANA Smart Data Access", + "blurb": "A HANA feature for querying remote data sources (S/4HANA, Oracle, etc.) as virtual tables — without physically moving the data. Federation without ETL.", + "category": "database" + }, + { + "tla": "SDI", + "expansion": "SAP HANA Smart Data Integration", + "blurb": "HANA's data integration layer: real-time replication, batch loading, and transformation from heterogeneous sources using Data Provisioning Agents.", + "category": "database" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "SAP HANA — high-performance, in-memory, and living in its own acronym universe. HDI, SDA, SDI… even I need a map sometimes.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "HANA", + "prompt": "What does HANA stand for?", + "answer": "High-performance ANalytic Appliance", + "distractors": [ + "Hybrid Application and Network Architecture", + "Hosted Analytical Node Application" + ], + "kasimirRight": "HANA — in-memory, in-the-cloud, and in your heart. Right answer! 💾", + "kasimirWrong": "HANA = High-performance ANalytic Appliance. The capital letters in the middle are the clue!" + }, + { + "type": "drill", + "kind": "mc", + "tla": "HDI", + "prompt": "What does HDI stand for?", + "answer": "HANA Deployment Infrastructure", + "distractors": [ + "HANA Data Index", + "Hybrid Deployment Interface" + ], + "kasimirRight": "HDI — HANA Deployment Infrastructure. Container-ised perfection! 📦", + "kasimirWrong": "HDI = HANA Deployment Infrastructure. It's the container system that keeps your DB artefacts isolated." + }, + { + "type": "drill", + "kind": "mc", + "tla": "SDA", + "prompt": "What does SDA stand for?", + "answer": "SAP HANA Smart Data Access", + "distractors": [ + "Smart Database Adapter", + "Structured Data Access" + ], + "kasimirRight": "SDA — federation without migration! Your HANA knowledge is sharp. 🔗", + "kasimirWrong": "SDA = SAP HANA Smart Data Access — virtual tables pointing at remote sources. No data movement needed." + }, + { + "type": "drill", + "kind": "mc", + "tla": "SDI", + "prompt": "What does SDI stand for?", + "answer": "SAP HANA Smart Data Integration", + "distractors": [ + "Structured Data Index", + "Smart Deployment Interface" + ], + "kasimirRight": "SDI — data flows like water to a thirsty cat! Correct! 🌊", + "kasimirWrong": "SDI = SAP HANA Smart Data Integration. Real-time replication + transformations via DP Agents." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered HANA, HDI, SDA, SDI. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-3-hana-lesson-2", + "legacyId": 90008, + "title": "Integration & Migration", + "acronyms": [ + { + "tla": "CPI", + "expansion": "SAP Cloud Platform Integration", + "blurb": "The former name for SAP Integration Suite's cloud integration capability. Still widely used in docs and project names. Handles message routing and transformation between systems.", + "category": "integration" + }, + { + "tla": "DWC", + "expansion": "SAP Data Warehouse Cloud", + "blurb": "SAP's cloud data warehouse solution, rebranded as SAP Datasphere in 2023. Provides data modelling, integration, and analytics on a single platform.", + "category": "analytics" + }, + { + "tla": "SQL", + "expansion": "Structured Query Language", + "blurb": "The standard language for relational databases. In CAP, you should use CQL (CDS Query Language) instead of raw SQL — but HANA's SQL dialect is what runs under the hood.", + "category": "database" + }, + { + "tla": "ODP", + "expansion": "Operational Data Provisioning", + "blurb": "SAP's framework for extracting data from S/4HANA and ECC sources into analytics or integration platforms. Exposes CDS views, BW extractors, and SLT as replication sources.", + "category": "integration" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Data doesn't just sit still. CPI, DWC, SQL, ODP — the connectors and extractors that keep data flowing where it needs to go.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "CPI", + "prompt": "What does CPI stand for?", + "answer": "SAP Cloud Platform Integration", + "distractors": [ + "Cloud Process Interface", + "Core Protocol Integration" + ], + "kasimirRight": "CPI — connecting the dots since before it was called Integration Suite! 🔌", + "kasimirWrong": "CPI = SAP Cloud Platform Integration — now part of SAP Integration Suite, but the name persists." + }, + { + "type": "drill", + "kind": "mc", + "tla": "DWC", + "prompt": "What does DWC stand for?", + "answer": "SAP Data Warehouse Cloud", + "distractors": [ + "Distributed Warehouse Controller", + "Data Web Console" + ], + "kasimirRight": "DWC — now Datasphere but the acronym lives on. Historically accurate! 📐", + "kasimirWrong": "DWC = SAP Data Warehouse Cloud — rebranded to SAP Datasphere in 2023 but DWC still appears everywhere." + }, + { + "type": "drill", + "kind": "mc", + "tla": "SQL", + "prompt": "What does SQL stand for?", + "answer": "Structured Query Language", + "distractors": [ + "Scalable Query Logic", + "Sequential Queue Language" + ], + "kasimirRight": "SQL — the granddaddy of data languages. CAP wraps it, but knowing it never hurts! ✅", + "kasimirWrong": "SQL = Structured Query Language. Use CQL in CAP code, but SQL is what HANA actually runs." + }, + { + "type": "drill", + "kind": "mc", + "tla": "ODP", + "prompt": "What does ODP stand for?", + "answer": "Operational Data Provisioning", + "distractors": [ + "Open Data Provisioning", + "Output Data Protocol" + ], + "kasimirRight": "ODP — the SAP data extraction expressway! Spot on. 🚂", + "kasimirWrong": "ODP = Operational Data Provisioning — SAP's framework for exporting data from ERP systems to analytics." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered CPI, DWC, SQL, ODP. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-3-hana-lesson-3", + "legacyId": 90009, + "title": "Data Engineering Basics", + "acronyms": [ + { + "tla": "ETL", + "expansion": "Extract, Transform, Load", + "blurb": "The classic data pipeline pattern: extract from source, transform (cleanse/enrich), load into target. Used in SAP Data Services, BW, and Datasphere pipelines.", + "category": "integration" + }, + { + "tla": "HDB", + "expansion": "SAP HANA Database", + "blurb": "The \"HDB\" prefix appears across HANA tooling: `hdbsql` (CLI SQL client), `.hdbtable` artefact files, and `@sap/hana-client` (the Node.js driver).", + "category": "database" + }, + { + "tla": "DDL", + "expansion": "Data Definition Language", + "blurb": "The SQL subset for defining database structures: CREATE TABLE, ALTER TABLE, DROP. In CAP, CDS handles DDL generation automatically from your models.", + "category": "database" + }, + { + "tla": "LOB", + "expansion": "Large Object", + "blurb": "A database column type for storing large binary (BLOB) or text (CLOB) data. SAP HANA supports LOBs natively; CAP uses raw `db.run()` to read them (locators expire on SELECT).", + "category": "database" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Time to get into the weeds: ETL pipelines, HDB artefacts, DDL, and the dreaded LOB column. Grab a coffee — or a warm saucer of milk.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "ETL", + "prompt": "What does ETL stand for?", + "answer": "Extract, Transform, Load", + "distractors": [ + "Enterprise Transfer Layer", + "Event Trigger Logic" + ], + "kasimirRight": "ETL — the cat-nap to data-warehouse pipeline of legend! Extract correct answer, +1. 📥", + "kasimirWrong": "ETL = Extract, Transform, Load — the three-step dance every data warehouse knows by heart." + }, + { + "type": "drill", + "kind": "mc", + "tla": "HDB", + "prompt": "What does HDB stand for?", + "answer": "SAP HANA Database", + "distractors": [ + "Hybrid Data Bus", + "HANA Deployment Bundle" + ], + "kasimirRight": "HDB — you'll see this prefix everywhere in HANA-land. Well spotted! 🐾", + "kasimirWrong": "HDB = SAP HANA Database — the prefix behind hdbsql, .hdbtable files, and the Node.js driver." + }, + { + "type": "drill", + "kind": "mc", + "tla": "DDL", + "prompt": "What does DDL stand for?", + "answer": "Data Definition Language", + "distractors": [ + "Dynamic Data Loader", + "Distributed Definition Layer" + ], + "kasimirRight": "DDL — CREATE TABLE? More like CREATE SUCCESS. Correct! 🏗️", + "kasimirWrong": "DDL = Data Definition Language — CREATE, ALTER, DROP. CAP generates it from CDS; you rarely write it directly." + }, + { + "type": "drill", + "kind": "mc", + "tla": "LOB", + "prompt": "What does LOB stand for?", + "answer": "Large Object", + "distractors": [ + "Linked Object Buffer", + "Local Object Buffer" + ], + "kasimirRight": "LOB — big data in a small column. Don't mix it with a CDS QL query! Correct! 📦", + "kasimirWrong": "LOB = Large Object (BLOB/CLOB) — in HANA, never SELECT a LOB alongside metadata in one CDS QL query." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered ETL, HDB, DDL, LOB. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + } + ] + }, + { + "id": "unit-4-security", + "title": "Security & Ops", + "icon": "🔒", + "order": 4, + "lessons": [ + { + "id": "unit-4-security-lesson-1", + "legacyId": 90010, + "title": "Authentication & Tokens", + "acronyms": [ + { + "tla": "XSUAA", + "expansion": "Extended Services for User Account and Authorization", + "blurb": "SAP BTP's OAuth 2.0 authorization service (also called \"Authorization and Trust Management Service\"). Manages roles, scopes, and JWT tokens for CAP apps.", + "category": "security" + }, + { + "tla": "UAA", + "expansion": "User Account and Authentication", + "blurb": "Cloud Foundry's open-source OAuth 2.0 / OpenID Connect server. SAP's XSUAA is a proprietary extension of UAA, adding BTP-specific scopes and role collections.", + "category": "security" + }, + { + "tla": "SSO", + "expansion": "Single Sign-On", + "blurb": "Log in once, access everything. SAP IAS federates identity to BTP, SAP S/4HANA, and third-party apps so users don't need separate passwords per system.", + "category": "security" + }, + { + "tla": "JWT", + "expansion": "JSON Web Token", + "blurb": "A compact, URL-safe token format (RFC 7519) for conveying claims between parties. BTP uses JWTs to carry XSUAA scopes and user info between services.", + "category": "security" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Security is not optional, and neither is this lesson. XSUAA, UAA, SSO, JWT — the alphabet soup that keeps your data from becoming everyone's data.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "XSUAA", + "prompt": "What does XSUAA stand for?", + "answer": "Extended Services for User Account and Authorization", + "distractors": [ + "External Single-Use Authentication API", + "eXtended Security User Application API" + ], + "kasimirRight": "XSUAA — the gatekeeper with too many letters! You cracked it. 🔑", + "kasimirWrong": "XSUAA = Extended Services for User Account and Authorization. Every CAP prod app uses it." + }, + { + "type": "drill", + "kind": "mc", + "tla": "UAA", + "prompt": "What does UAA stand for?", + "answer": "User Account and Authentication", + "distractors": [ + "Universal Access Authority", + "Unified App Authenticator" + ], + "kasimirRight": "UAA — the open-source core under XSUAA! Cloud Foundry heritage. Well done. 🦊", + "kasimirWrong": "UAA = User Account and Authentication — the CF OAuth server that XSUAA is built on top of." + }, + { + "type": "drill", + "kind": "mc", + "tla": "SSO", + "prompt": "What does SSO stand for?", + "answer": "Single Sign-On", + "distractors": [ + "Secure Session Object", + "Synchronised Sign-Out" + ], + "kasimirRight": "SSO — one password to rule them all! You didn't even need to log in twice. 🔓", + "kasimirWrong": "SSO = Single Sign-On. Sign in once, use everything — Kasimir only has to remember one paw-print." + }, + { + "type": "drill", + "kind": "mc", + "tla": "JWT", + "prompt": "What does JWT stand for?", + "answer": "JSON Web Token", + "distractors": [ + "JavaScript Web Transfer", + "JSON Workflow Trigger" + ], + "kasimirRight": "JWT — the travel passport of the cloud! Header.Payload.Signature, correct! 🛂", + "kasimirWrong": "JWT = JSON Web Token (RFC 7519). Three base64-encoded parts: header.payload.signature." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered XSUAA, UAA, SSO, JWT. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-4-security-lesson-2", + "legacyId": 90011, + "title": "Identity & Factors", + "acronyms": [ + { + "tla": "IDP", + "expansion": "Identity Provider", + "blurb": "A system that authenticates users and issues identity tokens. SAP IAS is a BTP Identity Provider. Corporate IdPs (Azure AD, Okta) can be federated into IAS.", + "category": "security" + }, + { + "tla": "MFA", + "expansion": "Multi-Factor Authentication", + "blurb": "Requiring ≥2 verification methods (password + OTP, biometric, hardware key). SAP IAS supports MFA for BTP admin and application users.", + "category": "security" + }, + { + "tla": "TLS", + "expansion": "Transport Layer Security", + "blurb": "The cryptographic protocol that secures HTTPS connections (successor to SSL). All BTP endpoints use TLS; CAP enforces it in production profiles automatically.", + "category": "security" + }, + { + "tla": "ACL", + "expansion": "Access Control List", + "blurb": "A list specifying which users or roles can perform which operations on a resource. In CAP, `@restrict` annotations compile to XSUAA role-scope checks, implementing ACL logic.", + "category": "security" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "Logging in is just the start. IDP, MFA, TLS, ACL — the layers that stop the wrong people from doing the wrong things.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "IDP", + "prompt": "What does IDP stand for?", + "answer": "Identity Provider", + "distractors": [ + "Internal Data Processor", + "Interface Definition Protocol" + ], + "kasimirRight": "IDP — the trust anchor of the auth chain. You clearly trust your acronym knowledge! 🏛️", + "kasimirWrong": "IDP = Identity Provider — the system that says \"yes, that IS Kasimir\" during login." + }, + { + "type": "drill", + "kind": "mc", + "tla": "MFA", + "prompt": "What does MFA stand for?", + "answer": "Multi-Factor Authentication", + "distractors": [ + "Managed Federation Access", + "Modular Flow Authentication" + ], + "kasimirRight": "MFA — two factors better than one! Twice as secure, half as convenient. Correct! 🔐", + "kasimirWrong": "MFA = Multi-Factor Authentication. Password + something else — a password alone is so last century." + }, + { + "type": "drill", + "kind": "mc", + "tla": "TLS", + "prompt": "What does TLS stand for?", + "answer": "Transport Layer Security", + "distractors": [ + "Token Lifecycle Service", + "Trusted Link System" + ], + "kasimirRight": "TLS — the invisible shield on every HTTPS call. Lock-tight answer! 🔒", + "kasimirWrong": "TLS = Transport Layer Security — the \"S\" in HTTPS. SSL is the deprecated predecessor." + }, + { + "type": "drill", + "kind": "mc", + "tla": "ACL", + "prompt": "What does ACL stand for?", + "answer": "Access Control List", + "distractors": [ + "Application Component Layer", + "Async Callback Loop" + ], + "kasimirRight": "ACL — the velvet rope of data security! Access granted to correct answers. 🚦", + "kasimirWrong": "ACL = Access Control List. In CAP terms, it's what `@restrict` annotations enforce via XSUAA scopes." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered IDP, MFA, TLS, ACL. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + }, + { + "id": "unit-4-security-lesson-3", + "legacyId": 90012, + "title": "Business & Governance", + "acronyms": [ + { + "tla": "ERP", + "expansion": "Enterprise Resource Planning", + "blurb": "Software that integrates core business processes (finance, HR, supply chain). SAP S/4HANA is the flagship ERP; it's why CAP and BTP exist — to extend and integrate ERP.", + "category": "business" + }, + { + "tla": "GRC", + "expansion": "Governance, Risk, and Compliance", + "blurb": "A framework (and SAP product family) for managing audit, risk, and regulatory requirements. SAP GRC integrates with S/4HANA to automate access controls and policy enforcement.", + "category": "business" + }, + { + "tla": "AMS", + "expansion": "SAP Authorization Management Service", + "blurb": "A BTP service (`@cap-js/ams`) providing policy-based authorization for CAP applications via the `@ams` CDS annotation. An alternative to pure XSUAA role checks.", + "category": "security" + }, + { + "tla": "RBAC", + "expansion": "Role-Based Access Control", + "blurb": "Assigning permissions to roles, then roles to users — rather than granting permissions directly. XSUAA role collections implement RBAC for BTP apps.", + "category": "security" + } + ], + "beats": [ + { + "type": "story", + "kasimir": "The business side of security: ERP context, GRC compliance, AMS policies, and the RBAC model that ties it all together.", + "mood": "teaching" + }, + { + "type": "drill", + "kind": "mc", + "tla": "ERP", + "prompt": "What does ERP stand for?", + "answer": "Enterprise Resource Planning", + "distractors": [ + "Enterprise Reporting Platform", + "Event-Routing Protocol" + ], + "kasimirRight": "ERP — the reason SAP exists and why your paycheck clears. Exactly right! 💼", + "kasimirWrong": "ERP = Enterprise Resource Planning — the big system that runs the business. S/4HANA is SAP's ERP." + }, + { + "type": "drill", + "kind": "mc", + "tla": "GRC", + "prompt": "What does GRC stand for?", + "answer": "Governance, Risk, and Compliance", + "distractors": [ + "General Runtime Control", + "Global Resource Catalog" + ], + "kasimirRight": "GRC — keeping the auditors happy since forever. Correctly compliant answer! 📋", + "kasimirWrong": "GRC = Governance, Risk, and Compliance. A whole SAP product family dedicated to keeping regulators at bay." + }, + { + "type": "drill", + "kind": "mc", + "tla": "AMS", + "prompt": "What does AMS stand for?", + "answer": "SAP Authorization Management Service", + "distractors": [ + "Application Monitoring Service", + "API Management Suite" + ], + "kasimirRight": "AMS — policy-based, fine-grained, and very grown-up authorization. Nailed it! 🏆", + "kasimirWrong": "AMS = SAP Authorization Management Service — the `@cap-js/ams` plugin for fine-grained policy authorization." + }, + { + "type": "drill", + "kind": "mc", + "tla": "RBAC", + "prompt": "What does RBAC stand for?", + "answer": "Role-Based Access Control", + "distractors": [ + "Resource-Bound API Control", + "Runtime Backend Access Cache" + ], + "kasimirRight": "RBAC — roles for the role, access for the few. Permission granted! 🎖️", + "kasimirWrong": "RBAC = Role-Based Access Control. XSUAA role collections ARE the RBAC mechanism in BTP." + }, + { + "type": "story", + "kasimir": "Lesson complete! You've mastered ERP, GRC, AMS, RBAC. Kasimir approves. 🐱", + "mood": "celebrate" + } + ] + } + ] + } + ] +} diff --git a/hugo/data/navigation.yaml b/hugo/data/navigation.yaml index 19fadbc90..640a4fc65 100644 --- a/hugo/data/navigation.yaml +++ b/hugo/data/navigation.yaml @@ -121,6 +121,12 @@ groups: href: /whats-new/ icon: newspaper keywords: [whats new, news, latest, updates, changelog] + - id: explore-ktt + label: Kasimir Teaches TLAs + href: /explore/ktt/ + icon: learning-assistant + paletteLabel: "KTT — Kasimir Teaches TLAs" + keywords: [acronym, tla, kasimir, quiz, learn] - id: explore-api-docs label: API href: /api-docs/ diff --git a/hugo/layouts/explore/ktt.html b/hugo/layouts/explore/ktt.html new file mode 100644 index 000000000..129bab1b3 --- /dev/null +++ b/hugo/layouts/explore/ktt.html @@ -0,0 +1,17 @@ +{{ define "main" }} +
+ +
+

{{ .Title }}

+

{{ .Description }}

+
    + {{ range site.Data.ktt_lessons.units }}
  • {{ .title }}
  • {{ end }} +
+
+
+ + +{{ end }} diff --git a/package.json b/package.json index 8a86574b8..c559f52b5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "seed-ai-quizzes": "cross-env AI_AUTHOR_BUILD_CAP=10000 npm run fetch-tutorials", "discover-repos": "tsx scripts/fetch-tutorials.ts --discover-only", "generate-dark-theme": "tsx scripts/generate-dark-theme.ts", + "generate-ktt-lessons": "npx tsx scripts/generate-ktt-lessons.ts", "validate-tutorials": "tsx scripts/validate-tutorials.ts", "lint:tutorial-markdown": "tsx scripts/lint-tutorial-markdown.ts", "scan-tutorial-markdown": "tsx scripts/scan-markdown-source.ts", @@ -102,7 +103,7 @@ "build:display": "cd app/display-app && npm install && npm run build", "copy-joule-vendor": "node scripts/copy-joule-vendor.mjs", "check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs", - "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-channels && npm run fetch-channel-atlas && npm run fetch-channel-collections && npm run fetch-channels-stats && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-sitemap-catalog && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:channel-atlas && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", + "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-channels && npm run fetch-channel-atlas && npm run fetch-channel-collections && npm run fetch-channels-stats && npm run fetch-verb-definitions && npm run fetch-tags && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-sitemap-catalog && npm run generate-ktt-lessons && npm run build:icon-subset && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run check:ui5-single-copy && npx tsx scripts/check-ui5-entry-coverage.ts && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:channel-atlas && npm run build:hugo && npm run build:page-fallback && npm run build:whats-new-snapshot && npm run retain:assets && npm run build:publish-island-manifest && npm run build:highlight && npm run build:display && npm run build:sdl", "build:deploy": "npm run check-deploy-cap-target && npm run build:all", "deploy": "node scripts/deploy-mta.cjs", "build:admin": "npm --prefix app/admin-shell run build", diff --git a/scripts/generate-ktt-lessons.ts b/scripts/generate-ktt-lessons.ts new file mode 100644 index 000000000..06966e137 --- /dev/null +++ b/scripts/generate-ktt-lessons.ts @@ -0,0 +1,661 @@ +/** + * scripts/generate-ktt-lessons.ts + * + * Generates hugo/data/ktt_lessons.json — the KTT ("Kasimir Teaches TLAs") lesson + * content file consumed by the Vue KTT island at /explore/ktt/. + * + * GROUNDING NOTE: Every TLA→expansion in SEED has been verified against: + * - CAP official documentation (cds-mcp search_docs) + * - SAP Help Portal / SAP Discovery Center official product names + * - sap-devs-server MCP context (get_context / search_resources) + * - IETF / OASIS standards (OData, JWT, SAML, TLS, REST) + * + * Run with: npx tsx scripts/generate-ktt-lessons.ts + * Optional AI draft mode: npx tsx scripts/generate-ktt-lessons.ts --draft + * (--draft is a no-op stub in v1; all kasimir/distractor lines are hand-authored) + */ +import { writeFileSync } from 'node:fs'; +import { validateLessons, KttData, KttUnit, KttLesson, KttBeat } from './lib/ktt-lessons-schema.js'; + +/** Curated + grounded TLA seed data. unitId maps to unit definitions below. */ +interface SeedEntry { + tla: string; + expansion: string; + blurb: string; + category: string; + unitId: string; + lessonIdx: number; // 0-based index within the unit's lessons + distractors: string[]; // plausible-but-wrong expansions, >=2 + kasimirRight: string; // ≤160 chars — shown when correct + kasimirWrong: string; // ≤160 chars — shown when wrong +} + +const SEED: SeedEntry[] = [ + // ─── Unit 1: Core Platform ─────────────────────────────────────────────────── + { + tla: 'SAP', expansion: 'Systems, Applications, Products in Data Processing', + blurb: 'The full name of the company behind your daily BTP adventures. Founded in Walldorf, 1972.', + category: 'company', + unitId: 'unit-1-platform', lessonIdx: 0, + distractors: ['Software Architecture Platform', 'Service Automation Protocol'], + kasimirRight: 'Correct! Even I know who signs my cat food invoices. 🐱', + kasimirWrong: 'SAP = Systems, Applications, Products in Data Processing. It\'s literally on the letterhead!', + }, + { + tla: 'BTP', expansion: 'SAP Business Technology Platform', + blurb: 'SAP\'s unified cloud platform for building, extending, and integrating business applications. The successor to SAP Cloud Platform (SCP).', + category: 'platform', + unitId: 'unit-1-platform', lessonIdx: 0, + distractors: ['SAP Big-data Transaction Platform', 'SAP Business Transaction Processing'], + kasimirRight: 'BTP — the cloud with a view from Walldorf! Nicely done. ☁️', + kasimirWrong: 'BTP = SAP Business Technology Platform. The "T" is Technology, not Transaction.', + }, + { + tla: 'SCP', expansion: 'SAP Cloud Platform', + blurb: 'The former name of SAP BTP (deprecated 2021). You\'ll still see SCP in older tutorials and Stack Overflow answers.', + category: 'platform', + unitId: 'unit-1-platform', lessonIdx: 0, + distractors: ['SAP Continuous Provisioning', 'SAP Core Protocol'], + kasimirRight: 'SCP — the old BTP. Nostalgia is allowed. 📜', + kasimirWrong: 'SCP = SAP Cloud Platform, the predecessor to BTP. History matters in enterprise software!', + }, + { + tla: 'IAS', expansion: 'SAP Identity Authentication Service', + blurb: 'SAP\'s cloud-based Identity Provider (IdP) on BTP. Handles authentication for SAP apps and custom services via SAML 2.0 and OIDC.', + category: 'security', + unitId: 'unit-1-platform', lessonIdx: 0, + distractors: ['Integrated Authorization Service', 'Internal Application Server'], + kasimirRight: 'IAS — the bouncer of the SAP cloud. Excellent! 🚪', + kasimirWrong: 'IAS = SAP Identity Authentication Service. It\'s the IdP, not just any authorization layer.', + }, + // Lesson 1-1 above; now lesson 1-2 + { + tla: 'CAP', expansion: 'SAP Cloud Application Programming Model', + blurb: 'SAP\'s framework for building cloud-native business applications using CDS, Node.js, and Java. The backbone of tutorials-ims.', + category: 'framework', + unitId: 'unit-1-platform', lessonIdx: 1, + distractors: ['Cloud API Protocol', 'Customer Application Pack'], + kasimirRight: 'CAP! You\'ve earned a treat. My whole world runs on CAP. 🐾', + kasimirWrong: 'CAP = SAP Cloud Application Programming Model. CDS, Node.js, Java — it\'s all in there.', + }, + { + tla: 'BAS', expansion: 'SAP Business Application Studio', + blurb: 'SAP\'s browser-based IDE on BTP, built on VS Code Open Source. The recommended tool for CAP, Fiori, and ABAP Cloud development.', + category: 'tooling', + unitId: 'unit-1-platform', lessonIdx: 1, + distractors: ['Backend Automation System', 'Business API Services'], + kasimirRight: 'BAS — where humans write the code that Kasimir reviews. Well played! 💻', + kasimirWrong: 'BAS = SAP Business Application Studio — the cloud IDE on BTP, not a backend system.', + }, + { + tla: 'SDK', expansion: 'Software Development Kit', + blurb: 'A bundle of tools, libraries, and documentation for building on a platform. SAP Cloud SDK adds BTP destinations, resilience, and type-safe OData clients.', + category: 'tooling', + unitId: 'unit-1-platform', lessonIdx: 1, + distractors: ['Service Deployment Kit', 'Standard Data Keyring'], + kasimirRight: 'SDK — the box of LEGO bricks every developer loves. Exactly right! 🧰', + kasimirWrong: 'SDK = Software Development Kit. Not "Service Deployment" anything.', + }, + { + tla: 'API', expansion: 'Application Programming Interface', + blurb: 'A defined contract that lets software components talk to each other. SAP publishes thousands of APIs on SAP Business Accelerator Hub (api.sap.com).', + category: 'architecture', + unitId: 'unit-1-platform', lessonIdx: 1, + distractors: ['Automated Process Integration', 'Application Persistence Index'], + kasimirRight: 'API — the handshake of the cloud world. I knew you\'d get this one! 🤝', + kasimirWrong: 'API = Application Programming Interface. The "P" is Programming, not Process.', + }, + // Lesson 1-3 + { + tla: 'MTA', expansion: 'Multi-Target Application', + blurb: 'SAP\'s deployment artefact format for BTP. A single `.mtar` archive bundles multiple modules (backend, UI, DB) and wires up service bindings automatically.', + category: 'deployment', + unitId: 'unit-1-platform', lessonIdx: 2, + distractors: ['Managed Tenant Architecture', 'Module Transfer Archive'], + kasimirRight: 'MTA — one package to rule them all! Purrfectly correct. 📦', + kasimirWrong: 'MTA = Multi-Target Application. It bundles all your BTP modules into one deployable archive.', + }, + { + tla: 'ANS', expansion: 'SAP Alert Notification Service', + blurb: 'A BTP service that sends real-time alerts about application events to email, Slack, or custom webhooks. Used in tutorials-ims for operational monitoring.', + category: 'operations', + unitId: 'unit-1-platform', lessonIdx: 2, + distractors: ['Automated Notification System', 'Application Node Service'], + kasimirRight: 'ANS — the smoke alarm of your BTP app. Alert! You\'re correct! 🚨', + kasimirWrong: 'ANS = SAP Alert Notification Service. It notifies, alerts, and generally keeps you from sleeping.', + }, + { + tla: 'SAC', expansion: 'SAP Analytics Cloud', + blurb: 'SAP\'s SaaS business intelligence platform for dashboards, planning, and predictive analytics. Lives on BTP.', + category: 'analytics', + unitId: 'unit-1-platform', lessonIdx: 2, + distractors: ['SAP Application Console', 'SAP Automation Center'], + kasimirRight: 'SAC — where data becomes pretty pictures! Right on target. 📊', + kasimirWrong: 'SAC = SAP Analytics Cloud. Dashboards, planning, predictions — all in the cloud.', + }, + { + tla: 'MDK', expansion: 'SAP Mobile Development Kit', + blurb: 'A cross-platform toolkit for building native-looking SAP mobile apps that work offline. Based on a metadata-driven approach — no separate iOS/Android code.', + category: 'mobile', + unitId: 'unit-1-platform', lessonIdx: 2, + distractors: ['Mobile Data Kit', 'Metadata Deployment Kit'], + kasimirRight: 'MDK — go mobile! Even I have a paw-friendly app. Great answer! 📱', + kasimirWrong: 'MDK = SAP Mobile Development Kit. Cross-platform, metadata-driven, and furiously offline-capable.', + }, + + // ─── Unit 2: CDS & App Development ─────────────────────────────────────────── + { + tla: 'CDS', expansion: 'Core Data Services', + blurb: 'CAP\'s universal declarative language for data models and service definitions. Also powers S/4HANA views. The backbone of everything in CAP.', + category: 'framework', + unitId: 'unit-2-cds', lessonIdx: 0, + distractors: ['Cloud Data Store', 'Connected Data Services'], + kasimirRight: 'CDS — the language I dream in! You\'ve clearly been reading the docs. 📚', + kasimirWrong: 'CDS = Core Data Services — SAP\'s universal modeling language. Not "Cloud" anything.', + }, + { + tla: 'CSN', expansion: 'Core Schema Notation', + blurb: 'CDS models compiled to a compact JSON representation (pronounced "Season"). The runtime format CAP uses internally — generated from CDL source files.', + category: 'framework', + unitId: 'unit-2-cds', lessonIdx: 0, + distractors: ['Cloud Service Network', 'Core Serialization Notation'], + kasimirRight: 'CSN — like JSON Schema but for full entity-relationship models. Season greetings! 🎄', + kasimirWrong: 'CSN = Core Schema Notation, pronounced "Season". It\'s the JSON form of your CDS models.', + }, + { + tla: 'CDL', expansion: 'Conceptual Definition Language', + blurb: 'The human-readable source syntax you write in `.cds` files — entities, services, annotations. Official CAP docs state: "We use CDS\'s Conceptual Definition Language (CDL)…". Compiles to CSN for runtime use.', + category: 'framework', + unitId: 'unit-2-cds', lessonIdx: 0, + distractors: ['Cloud Deployment Language', 'Core Data Library'], + kasimirRight: 'CDL — what you write, CSN is what the machine reads. You\'re fluent! ✍️', + kasimirWrong: 'CDL = Conceptual Definition Language (official CAP term). The .cds files you write are CDL; they compile down to CSN.', + }, + { + tla: 'CQL', expansion: 'CDS Query Language', + blurb: 'The SQL-like query language for CAP: `SELECT.from(Books).where(...)`. Type-safe, composable, and database-agnostic. Part of the `cds.ql` API.', + category: 'framework', + unitId: 'unit-2-cds', lessonIdx: 0, + distractors: ['Cloud Query Layer', 'Core Queue Language'], + kasimirRight: 'CQL — SELECT * FROM correct_answers! Top marks. 🐱‍💻', + kasimirWrong: 'CQL = CDS Query Language — the SELECT.from(...) syntax in `cds.ql`. Never write raw SQL!', + }, + // Lesson 2-2 + { + tla: 'OData', expansion: 'Open Data Protocol', + blurb: 'An OASIS standard REST-based protocol for CRUD APIs with query capabilities ($filter, $expand, $top…). All CAP services can be served as OData V4.', + category: 'protocol', + unitId: 'unit-2-cds', lessonIdx: 1, + distractors: ['Optimised Data Access', 'Object Data Architecture'], + kasimirRight: 'OData — the protocol that makes SAP APIs queryable from Excel. Impressive! 📡', + kasimirWrong: 'OData = Open Data Protocol (OASIS standard). Think REST + $filter + $expand — pure power.', + }, + { + tla: 'RAP', expansion: 'ABAP RESTful Application Programming Model', + blurb: 'SAP\'s recommended framework for building Fiori apps and OData services on ABAP. Uses CDS views, behavior definitions, and service bindings.', + category: 'framework', + unitId: 'unit-2-cds', lessonIdx: 1, + distractors: ['Remote Application Protocol', 'Resource-Aware Processing'], + kasimirRight: 'RAP — the CAP of the ABAP world! You\'re now officially bilingual. 🎓', + kasimirWrong: 'RAP = ABAP RESTful Application Programming Model. It\'s to ABAP what CAP is to Node.js/Java.', + }, + { + tla: 'REST', expansion: 'Representational State Transfer', + blurb: 'The architectural style behind most web APIs: stateless HTTP, resources as URLs, verbs as HTTP methods (GET/POST/PUT/DELETE). OData is a RESTful protocol.', + category: 'protocol', + unitId: 'unit-2-cds', lessonIdx: 1, + distractors: ['Remote Event Stream Transfer', 'Reliable Endpoint Service Technology'], + kasimirRight: 'REST — the law of the web land. Stateless, resourceful, correct! 🌐', + kasimirWrong: 'REST = Representational State Transfer (Roy Fielding, 2000). Every web API architect owes him a coffee.', + }, + { + tla: 'IDE', expansion: 'Integrated Development Environment', + blurb: 'A software tool that combines code editor, debugger, and build tools in one UI. SAP\'s cloud IDE is BAS; locally VS Code + CDS extension is the go-to.', + category: 'tooling', + unitId: 'unit-2-cds', lessonIdx: 1, + distractors: ['Internal Deployment Engine', 'Interface Design Editor'], + kasimirRight: 'IDE — where the magic happens before the bug report! You knew it. 🖥️', + kasimirWrong: 'IDE = Integrated Development Environment. VS Code + CDS extension = one happy IDE.', + }, + // Lesson 2-3 + { + tla: 'FLP', expansion: 'SAP Fiori Launchpad', + blurb: 'The web shell that hosts SAP Fiori apps as tiles in a unified homepage. Handles navigation, routing, and user personalization across all Fiori apps.', + category: 'ui', + unitId: 'unit-2-cds', lessonIdx: 2, + distractors: ['Flexible Layout Page', 'Fiori Link Provider'], + kasimirRight: 'FLP — the tile garden where Fiori apps bloom! Nailed it. 🏡', + kasimirWrong: 'FLP = SAP Fiori Launchpad — the shell that tiles up your Fiori apps in one place.', + }, + { + tla: 'UI5', expansion: 'SAPUI5', + blurb: 'SAPUI5 is a product name, not a spelled-out acronym — "5" is the generation designator for SAP\'s enterprise JavaScript UI framework. Built on OpenUI5 (the open-source base), it delivers Fiori-ready components, Horizon theme, and OData V4 model binding out of the box.', + category: 'ui', + unitId: 'unit-2-cds', lessonIdx: 2, + distractors: ['Universal Interface 5', 'SAP User Interaction 5'], + kasimirRight: 'UI5 — the framework behind every Fiori face! Stylishly correct. 🎨', + kasimirWrong: 'UI5 = SAPUI5 — SAP\'s enterprise JavaScript framework. OpenUI5 is its open-source twin.', + }, + { + tla: 'OPA', expansion: 'One Page Acceptance Test Framework', + blurb: 'SAP\'s asynchronous integration-test framework for UI5/Fiori apps. Tests page objects and UI flows without flakiness, running inside the browser.', + category: 'testing', + unitId: 'unit-2-cds', lessonIdx: 2, + distractors: ['Open Protocol Adapter', 'Object Page Action'], + kasimirRight: 'OPA — the test cat catches every bug! Acceptably correct. 🧪', + kasimirWrong: 'OPA = One Page Acceptance Test Framework — UI5\'s built-in asynchronous integration test library.', + }, + { + tla: 'CLI', expansion: 'Command-Line Interface', + blurb: 'A text-based tool for interacting with software. CAP provides `cds` CLI; BTP has `btp`; CF has `cf`. Together they\'re the developer\'s Swiss army knife.', + category: 'tooling', + unitId: 'unit-2-cds', lessonIdx: 2, + distractors: ['Cloud Layer Interface', 'Component Library Index'], + kasimirRight: 'CLI — where humans type furiously and things actually happen! Correct! ⌨️', + kasimirWrong: 'CLI = Command-Line Interface. `cds watch`, `btp login`, `cf push` — all CLI commands.', + }, + + // ─── Unit 3: HANA & Data ────────────────────────────────────────────────────── + { + tla: 'HANA', expansion: 'High-performance ANalytic Appliance', + blurb: 'SAP\'s in-memory relational database. Processes analytical and transactional workloads simultaneously ("HTAP"). The default production database for CAP apps on BTP.', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 0, + distractors: ['Hybrid Application and Network Architecture', 'Hosted Analytical Node Application'], + kasimirRight: 'HANA — in-memory, in-the-cloud, and in your heart. Right answer! 💾', + kasimirWrong: 'HANA = High-performance ANalytic Appliance. The capital letters in the middle are the clue!', + }, + { + tla: 'HDI', expansion: 'HANA Deployment Infrastructure', + blurb: 'A SAP HANA feature that manages database artefacts (tables, views, procedures) in isolated, versioned containers. CAP uses HDI containers for all HANA deployments.', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 0, + distractors: ['HANA Data Index', 'Hybrid Deployment Interface'], + kasimirRight: 'HDI — HANA Deployment Infrastructure. Container-ised perfection! 📦', + kasimirWrong: 'HDI = HANA Deployment Infrastructure. It\'s the container system that keeps your DB artefacts isolated.', + }, + { + tla: 'SDA', expansion: 'SAP HANA Smart Data Access', + blurb: 'A HANA feature for querying remote data sources (S/4HANA, Oracle, etc.) as virtual tables — without physically moving the data. Federation without ETL.', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 0, + distractors: ['Smart Database Adapter', 'Structured Data Access'], + kasimirRight: 'SDA — federation without migration! Your HANA knowledge is sharp. 🔗', + kasimirWrong: 'SDA = SAP HANA Smart Data Access — virtual tables pointing at remote sources. No data movement needed.', + }, + { + tla: 'SDI', expansion: 'SAP HANA Smart Data Integration', + blurb: 'HANA\'s data integration layer: real-time replication, batch loading, and transformation from heterogeneous sources using Data Provisioning Agents.', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 0, + distractors: ['Structured Data Index', 'Smart Deployment Interface'], + kasimirRight: 'SDI — data flows like water to a thirsty cat! Correct! 🌊', + kasimirWrong: 'SDI = SAP HANA Smart Data Integration. Real-time replication + transformations via DP Agents.', + }, + // Lesson 3-2 + { + tla: 'CPI', expansion: 'SAP Cloud Platform Integration', + blurb: 'The former name for SAP Integration Suite\'s cloud integration capability. Still widely used in docs and project names. Handles message routing and transformation between systems.', + category: 'integration', + unitId: 'unit-3-hana', lessonIdx: 1, + distractors: ['Cloud Process Interface', 'Core Protocol Integration'], + kasimirRight: 'CPI — connecting the dots since before it was called Integration Suite! 🔌', + kasimirWrong: 'CPI = SAP Cloud Platform Integration — now part of SAP Integration Suite, but the name persists.', + }, + { + tla: 'DWC', expansion: 'SAP Data Warehouse Cloud', + blurb: 'SAP\'s cloud data warehouse solution, rebranded as SAP Datasphere in 2023. Provides data modelling, integration, and analytics on a single platform.', + category: 'analytics', + unitId: 'unit-3-hana', lessonIdx: 1, + distractors: ['Distributed Warehouse Controller', 'Data Web Console'], + kasimirRight: 'DWC — now Datasphere but the acronym lives on. Historically accurate! 📐', + kasimirWrong: 'DWC = SAP Data Warehouse Cloud — rebranded to SAP Datasphere in 2023 but DWC still appears everywhere.', + }, + { + tla: 'SQL', expansion: 'Structured Query Language', + blurb: 'The standard language for relational databases. In CAP, you should use CQL (CDS Query Language) instead of raw SQL — but HANA\'s SQL dialect is what runs under the hood.', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 1, + distractors: ['Scalable Query Logic', 'Sequential Queue Language'], + kasimirRight: 'SQL — the granddaddy of data languages. CAP wraps it, but knowing it never hurts! ✅', + kasimirWrong: 'SQL = Structured Query Language. Use CQL in CAP code, but SQL is what HANA actually runs.', + }, + { + tla: 'ODP', expansion: 'Operational Data Provisioning', + blurb: 'SAP\'s framework for extracting data from S/4HANA and ECC sources into analytics or integration platforms. Exposes CDS views, BW extractors, and SLT as replication sources.', + category: 'integration', + unitId: 'unit-3-hana', lessonIdx: 1, + distractors: ['Open Data Provisioning', 'Output Data Protocol'], + kasimirRight: 'ODP — the SAP data extraction expressway! Spot on. 🚂', + kasimirWrong: 'ODP = Operational Data Provisioning — SAP\'s framework for exporting data from ERP systems to analytics.', + }, + // Lesson 3-3 + { + tla: 'ETL', expansion: 'Extract, Transform, Load', + blurb: 'The classic data pipeline pattern: extract from source, transform (cleanse/enrich), load into target. Used in SAP Data Services, BW, and Datasphere pipelines.', + category: 'integration', + unitId: 'unit-3-hana', lessonIdx: 2, + distractors: ['Enterprise Transfer Layer', 'Event Trigger Logic'], + kasimirRight: 'ETL — the cat-nap to data-warehouse pipeline of legend! Extract correct answer, +1. 📥', + kasimirWrong: 'ETL = Extract, Transform, Load — the three-step dance every data warehouse knows by heart.', + }, + { + tla: 'HDB', expansion: 'SAP HANA Database', + blurb: 'The "HDB" prefix appears across HANA tooling: `hdbsql` (CLI SQL client), `.hdbtable` artefact files, and `@sap/hana-client` (the Node.js driver).', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 2, + distractors: ['Hybrid Data Bus', 'HANA Deployment Bundle'], + kasimirRight: 'HDB — you\'ll see this prefix everywhere in HANA-land. Well spotted! 🐾', + kasimirWrong: 'HDB = SAP HANA Database — the prefix behind hdbsql, .hdbtable files, and the Node.js driver.', + }, + { + tla: 'DDL', expansion: 'Data Definition Language', + blurb: 'The SQL subset for defining database structures: CREATE TABLE, ALTER TABLE, DROP. In CAP, CDS handles DDL generation automatically from your models.', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 2, + distractors: ['Dynamic Data Loader', 'Distributed Definition Layer'], + kasimirRight: 'DDL — CREATE TABLE? More like CREATE SUCCESS. Correct! 🏗️', + kasimirWrong: 'DDL = Data Definition Language — CREATE, ALTER, DROP. CAP generates it from CDS; you rarely write it directly.', + }, + { + tla: 'LOB', expansion: 'Large Object', + blurb: 'A database column type for storing large binary (BLOB) or text (CLOB) data. SAP HANA supports LOBs natively; CAP uses raw `db.run()` to read them (locators expire on SELECT).', + category: 'database', + unitId: 'unit-3-hana', lessonIdx: 2, + distractors: ['Linked Object Buffer', 'Local Object Buffer'], + kasimirRight: 'LOB — big data in a small column. Don\'t mix it with a CDS QL query! Correct! 📦', + kasimirWrong: 'LOB = Large Object (BLOB/CLOB) — in HANA, never SELECT a LOB alongside metadata in one CDS QL query.', + }, + + // ─── Unit 4: Security & Ops ─────────────────────────────────────────────────── + { + tla: 'XSUAA', expansion: 'Extended Services for User Account and Authorization', + blurb: 'SAP BTP\'s OAuth 2.0 authorization service (also called "Authorization and Trust Management Service"). Manages roles, scopes, and JWT tokens for CAP apps.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 0, + distractors: ['External Single-Use Authentication API', 'eXtended Security User Application API'], + kasimirRight: 'XSUAA — the gatekeeper with too many letters! You cracked it. 🔑', + kasimirWrong: 'XSUAA = Extended Services for User Account and Authorization. Every CAP prod app uses it.', + }, + { + tla: 'UAA', expansion: 'User Account and Authentication', + blurb: 'Cloud Foundry\'s open-source OAuth 2.0 / OpenID Connect server. SAP\'s XSUAA is a proprietary extension of UAA, adding BTP-specific scopes and role collections.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 0, + distractors: ['Universal Access Authority', 'Unified App Authenticator'], + kasimirRight: 'UAA — the open-source core under XSUAA! Cloud Foundry heritage. Well done. 🦊', + kasimirWrong: 'UAA = User Account and Authentication — the CF OAuth server that XSUAA is built on top of.', + }, + { + tla: 'SSO', expansion: 'Single Sign-On', + blurb: 'Log in once, access everything. SAP IAS federates identity to BTP, SAP S/4HANA, and third-party apps so users don\'t need separate passwords per system.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 0, + distractors: ['Secure Session Object', 'Synchronised Sign-Out'], + kasimirRight: 'SSO — one password to rule them all! You didn\'t even need to log in twice. 🔓', + kasimirWrong: 'SSO = Single Sign-On. Sign in once, use everything — Kasimir only has to remember one paw-print.', + }, + { + tla: 'JWT', expansion: 'JSON Web Token', + blurb: 'A compact, URL-safe token format (RFC 7519) for conveying claims between parties. BTP uses JWTs to carry XSUAA scopes and user info between services.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 0, + distractors: ['JavaScript Web Transfer', 'JSON Workflow Trigger'], + kasimirRight: 'JWT — the travel passport of the cloud! Header.Payload.Signature, correct! 🛂', + kasimirWrong: 'JWT = JSON Web Token (RFC 7519). Three base64-encoded parts: header.payload.signature.', + }, + // Lesson 4-2 + { + tla: 'IDP', expansion: 'Identity Provider', + blurb: 'A system that authenticates users and issues identity tokens. SAP IAS is a BTP Identity Provider. Corporate IdPs (Azure AD, Okta) can be federated into IAS.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 1, + distractors: ['Internal Data Processor', 'Interface Definition Protocol'], + kasimirRight: 'IDP — the trust anchor of the auth chain. You clearly trust your acronym knowledge! 🏛️', + kasimirWrong: 'IDP = Identity Provider — the system that says "yes, that IS Kasimir" during login.', + }, + { + tla: 'MFA', expansion: 'Multi-Factor Authentication', + blurb: 'Requiring ≥2 verification methods (password + OTP, biometric, hardware key). SAP IAS supports MFA for BTP admin and application users.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 1, + distractors: ['Managed Federation Access', 'Modular Flow Authentication'], + kasimirRight: 'MFA — two factors better than one! Twice as secure, half as convenient. Correct! 🔐', + kasimirWrong: 'MFA = Multi-Factor Authentication. Password + something else — a password alone is so last century.', + }, + { + tla: 'TLS', expansion: 'Transport Layer Security', + blurb: 'The cryptographic protocol that secures HTTPS connections (successor to SSL). All BTP endpoints use TLS; CAP enforces it in production profiles automatically.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 1, + distractors: ['Token Lifecycle Service', 'Trusted Link System'], + kasimirRight: 'TLS — the invisible shield on every HTTPS call. Lock-tight answer! 🔒', + kasimirWrong: 'TLS = Transport Layer Security — the "S" in HTTPS. SSL is the deprecated predecessor.', + }, + { + tla: 'ACL', expansion: 'Access Control List', + blurb: 'A list specifying which users or roles can perform which operations on a resource. In CAP, `@restrict` annotations compile to XSUAA role-scope checks, implementing ACL logic.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 1, + distractors: ['Application Component Layer', 'Async Callback Loop'], + kasimirRight: 'ACL — the velvet rope of data security! Access granted to correct answers. 🚦', + kasimirWrong: 'ACL = Access Control List. In CAP terms, it\'s what `@restrict` annotations enforce via XSUAA scopes.', + }, + // Lesson 4-3 + { + tla: 'ERP', expansion: 'Enterprise Resource Planning', + blurb: 'Software that integrates core business processes (finance, HR, supply chain). SAP S/4HANA is the flagship ERP; it\'s why CAP and BTP exist — to extend and integrate ERP.', + category: 'business', + unitId: 'unit-4-security', lessonIdx: 2, + distractors: ['Enterprise Reporting Platform', 'Event-Routing Protocol'], + kasimirRight: 'ERP — the reason SAP exists and why your paycheck clears. Exactly right! 💼', + kasimirWrong: 'ERP = Enterprise Resource Planning — the big system that runs the business. S/4HANA is SAP\'s ERP.', + }, + { + tla: 'GRC', expansion: 'Governance, Risk, and Compliance', + blurb: 'A framework (and SAP product family) for managing audit, risk, and regulatory requirements. SAP GRC integrates with S/4HANA to automate access controls and policy enforcement.', + category: 'business', + unitId: 'unit-4-security', lessonIdx: 2, + distractors: ['General Runtime Control', 'Global Resource Catalog'], + kasimirRight: 'GRC — keeping the auditors happy since forever. Correctly compliant answer! 📋', + kasimirWrong: 'GRC = Governance, Risk, and Compliance. A whole SAP product family dedicated to keeping regulators at bay.', + }, + { + tla: 'AMS', expansion: 'SAP Authorization Management Service', + blurb: 'A BTP service (`@cap-js/ams`) providing policy-based authorization for CAP applications via the `@ams` CDS annotation. An alternative to pure XSUAA role checks.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 2, + distractors: ['Application Monitoring Service', 'API Management Suite'], + kasimirRight: 'AMS — policy-based, fine-grained, and very grown-up authorization. Nailed it! 🏆', + kasimirWrong: 'AMS = SAP Authorization Management Service — the `@cap-js/ams` plugin for fine-grained policy authorization.', + }, + { + tla: 'RBAC', expansion: 'Role-Based Access Control', + blurb: 'Assigning permissions to roles, then roles to users — rather than granting permissions directly. XSUAA role collections implement RBAC for BTP apps.', + category: 'security', + unitId: 'unit-4-security', lessonIdx: 2, + distractors: ['Resource-Bound API Control', 'Runtime Backend Access Cache'], + kasimirRight: 'RBAC — roles for the role, access for the few. Permission granted! 🎖️', + kasimirWrong: 'RBAC = Role-Based Access Control. XSUAA role collections ARE the RBAC mechanism in BTP.', + }, +]; + +/** Unit metadata (title, icon, order). */ +interface UnitMeta { id: string; title: string; icon: string; order: number; lessonTitles: string[]; lessonIntros: string[]; } + +const UNITS: UnitMeta[] = [ + { + id: 'unit-1-platform', + title: 'SAP Core Platform', + icon: '🏗️', + order: 1, + lessonTitles: ['Who\'s SAP, and what\'s BTP?', 'The CAP Stack', 'Deploying & Operating'], + lessonIntros: [ + 'Ah, a fresh student! Before we write a single line of CDS, let\'s settle the most fundamental questions: who runs this show, and what platform are we on?', + 'Now we\'re cooking! CAP is where the magic happens. Let me introduce you to my favourite framework — and no, I\'m not biased just because it\'s named after something a professor wears.', + 'Congratulations — you\'ve built a CAP app! Now how do you actually ship it? MTA archives, alert services, analytics clouds — the operational layer awaits.', + ], + }, + { + id: 'unit-2-cds', + title: 'CDS & App Development', + icon: '🛠️', + order: 2, + lessonTitles: ['The CDS Language Family', 'Protocols & Standards', 'Tools of the Trade'], + lessonIntros: [ + 'CDS is not one thing — it\'s a whole language family. CSN, CDL, CQL… I collect acronyms the way others collect stamps. Let\'s unpack them.', + 'Every API speaks a language. OData, REST, RAP — the protocols that let your app talk to the world. Put on your headphones; it\'s time for a multilingual lesson.', + 'A developer without tools is just someone staring at a blank screen. FLP, UI5, OPA, CLI — the toolkit that turns ideas into shipped features.', + ], + }, + { + id: 'unit-3-hana', + title: 'HANA & Data', + icon: '🗄️', + order: 3, + lessonTitles: ['The HANA Universe', 'Integration & Migration', 'Data Engineering Basics'], + lessonIntros: [ + 'SAP HANA — high-performance, in-memory, and living in its own acronym universe. HDI, SDA, SDI… even I need a map sometimes.', + 'Data doesn\'t just sit still. CPI, DWC, SQL, ODP — the connectors and extractors that keep data flowing where it needs to go.', + 'Time to get into the weeds: ETL pipelines, HDB artefacts, DDL, and the dreaded LOB column. Grab a coffee — or a warm saucer of milk.', + ], + }, + { + id: 'unit-4-security', + title: 'Security & Ops', + icon: '🔒', + order: 4, + lessonTitles: ['Authentication & Tokens', 'Identity & Factors', 'Business & Governance'], + lessonIntros: [ + 'Security is not optional, and neither is this lesson. XSUAA, UAA, SSO, JWT — the alphabet soup that keeps your data from becoming everyone\'s data.', + 'Logging in is just the start. IDP, MFA, TLS, ACL — the layers that stop the wrong people from doing the wrong things.', + 'The business side of security: ERP context, GRC compliance, AMS policies, and the RBAC model that ties it all together.', + ], + }, +]; + +const LESSON_ID_START = 90001; + +function assemble(): KttData { + let legacyId = LESSON_ID_START; + + const units: KttUnit[] = UNITS.map((uMeta) => { + const unitSeed = SEED.filter(s => s.unitId === uMeta.id); + // Group by lessonIdx + const maxLessonIdx = Math.max(...unitSeed.map(s => s.lessonIdx)); + const lessons: KttLesson[] = []; + + for (let li = 0; li <= maxLessonIdx; li++) { + const lSeed = unitSeed.filter(s => s.lessonIdx === li); + if (lSeed.length === 0) continue; + + const lessonId = `${uMeta.id}-lesson-${li + 1}`; + const thisLegacyId = legacyId++; + + const beats: KttBeat[] = [ + { + type: 'story', + kasimir: uMeta.lessonIntros[li] ?? `Welcome to lesson ${li + 1} of ${uMeta.title}!`, + mood: 'teaching', + }, + ...lSeed.map(s => ({ + type: 'drill', + kind: 'mc', + tla: s.tla, + prompt: `What does ${s.tla} stand for?`, + answer: s.expansion, + distractors: s.distractors, + kasimirRight: s.kasimirRight, + kasimirWrong: s.kasimirWrong, + })), + { + type: 'story', + kasimir: `Lesson complete! You've mastered ${lSeed.map(s => s.tla).join(', ')}. Kasimir approves. 🐱`, + mood: 'celebrate', + }, + ]; + + lessons.push({ + id: lessonId, + legacyId: thisLegacyId, + title: uMeta.lessonTitles[li] ?? `Lesson ${li + 1}`, + acronyms: lSeed.map(s => ({ + tla: s.tla, + expansion: s.expansion, + blurb: s.blurb, + category: s.category, + })), + beats, + }); + } + + return { + id: uMeta.id, + title: uMeta.title, + icon: uMeta.icon, + order: uMeta.order, + lessons, + }; + }); + + return { units }; +} + +const data = assemble(); +const errs = validateLessons(data); +if (errs.length) { + console.error('KTT lesson validation failed:\n' + errs.join('\n')); + process.exit(1); +} +writeFileSync('hugo/data/ktt_lessons.json', JSON.stringify(data, null, 2) + '\n'); + +// --------------------------------------------------------------------------- +// CAP seed CSV for the KttLessons catalog (db/data/…-KttLessons.csv). +// +// Loaded automatically into HANA (and the in-memory SQLite used by cds.test) +// so srv/lib/user-progress.js can join a KTT_LESSON completion's legacyId → +// title/slug in MyCompletions, and syncProgress can reconstruct mastery. +// Without this seed the table ships EMPTY and every KTT completion is dropped. +// +// `slug` intentionally equals the lesson `id` (e.g. "unit-1-platform-lesson-1") +// because that is the exact key the island stores in `progress.mastered` and +// sends as `lessonSlug` — see hugo-apps/src/ktt/App.vue. +// +// `ID` is DETERMINISTIC (derived from legacyId, never random) so regenerating +// this file is byte-identical — no churn in git. Scheme: a fixed structured +// UUID template with the legacyId zero-padded into the final node segment. +// --------------------------------------------------------------------------- +const CSV_DELIM = ';'; + +/** RFC-4180-style field escaping against the ';' delimiter. */ +function csvField(value: string): string { + const s = String(value ?? ''); + if (s.includes(CSV_DELIM) || s.includes('"') || s.includes('\n') || s.includes('\r')) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; +} + +/** Deterministic, valid v4-shaped UUID derived solely from the legacyId. */ +function deterministicLessonId(legacyId: number): string { + return `9077e000-0000-4000-8000-${String(legacyId).padStart(12, '0')}`; +} + +const csvLines: string[] = ['ID;legacyId;slug;unitId;title']; +for (const unit of data.units) { + for (const lesson of unit.lessons) { + csvLines.push([ + csvField(deterministicLessonId(lesson.legacyId)), + csvField(String(lesson.legacyId)), + csvField(lesson.id), + csvField(unit.id), + csvField(lesson.title), + ].join(CSV_DELIM)); + } +} +writeFileSync('db/data/com.sap.developers.ims-KttLessons.csv', csvLines.join('\n') + '\n'); + +const totalLessons = data.units.reduce((a, u) => a + u.lessons.length, 0); +const totalAcronyms = data.units.reduce((a, u) => a + u.lessons.reduce((b, l) => b + l.acronyms.length, 0), 0); +console.log(`Wrote ${data.units.length} units, ${totalLessons} lessons, ${totalAcronyms} acronyms.`); +console.log(`Wrote db/data/com.sap.developers.ims-KttLessons.csv (${csvLines.length - 1} data rows).`); diff --git a/scripts/lib/ktt-lessons-schema.ts b/scripts/lib/ktt-lessons-schema.ts new file mode 100644 index 000000000..9f3ecaf33 --- /dev/null +++ b/scripts/lib/ktt-lessons-schema.ts @@ -0,0 +1,31 @@ +export interface KttAcronym { tla: string; expansion: string; blurb: string; category: string; } +export interface KttDrill { + type: 'drill'; kind: 'mc' | 'match' | 'type'; tla: string; prompt: string; + answer: string; distractors: string[]; kasimirRight: string; kasimirWrong: string; +} +export interface KttStory { type: 'story'; kasimir: string; mood: 'idle'|'teaching'|'thinking'|'correct'|'wrong'|'celebrate'; } +export type KttBeat = KttStory | KttDrill; +export interface KttLesson { id: string; legacyId: number; title: string; acronyms: KttAcronym[]; beats: KttBeat[]; } +export interface KttUnit { id: string; title: string; icon: string; order: number; lessons: KttLesson[]; } +export interface KttData { units: KttUnit[]; } + +export function validateLessons(data: KttData): string[] { + const errs: string[] = []; + const legacyIds = new Set(); + for (const u of data.units || []) { + for (const l of u.lessons || []) { + if (typeof l.legacyId !== 'number') errs.push(`${l.id}: missing numeric legacyId`); + if (legacyIds.has(l.legacyId)) errs.push(`${l.id}: duplicate legacyId ${l.legacyId}`); + legacyIds.add(l.legacyId); + for (const a of l.acronyms || []) if (!a.expansion) errs.push(`${l.id}/${a.tla}: missing expansion`); + for (const b of l.beats || []) { + if (b.type === 'drill') { + if (!b.answer) errs.push(`${l.id}: drill for ${b.tla} missing answer`); + if (!b.distractors || b.distractors.length < 2) errs.push(`${l.id}: drill for ${b.tla} needs >=2 distractors`); + if (b.answer && b.distractors?.includes(b.answer)) errs.push(`${l.id}: drill for ${b.tla} has answer in distractors`); + } + } + } + } + return errs; +} diff --git a/srv/ktt-service.cds b/srv/ktt-service.cds new file mode 100644 index 000000000..7de5abf7c --- /dev/null +++ b/srv/ktt-service.cds @@ -0,0 +1,18 @@ +using { com.sap.developers.ims as ims } from '../db/ktt'; + +@path: '/ktt' +@requires: 'any' +service KttService { + + @readonly entity Lessons as projection on ims.KttLessons; + + @(requires: 'authenticated-user') + action completeLesson(lessonSlug: String, legacyId: Integer, title: String) + returns { ok: Boolean; alreadyDone: Boolean }; + + @(requires: 'authenticated-user') + action syncProgress(localJson: LargeString) + returns { xp: Integer; streak: Integer; mastered: array of String }; + + function banter(context: String) returns String; +} diff --git a/srv/ktt-service.js b/srv/ktt-service.js new file mode 100644 index 000000000..48fa3943c --- /dev/null +++ b/srv/ktt-service.js @@ -0,0 +1,87 @@ +// srv/ktt-service.js +// KttService handler — Task 2: completeLesson action (idempotent KTT_LESSON TaskRecord). +// Task 3: syncProgress action (merge local + HANA progress on login). +// Task 5: banter action (fail-open AI quip backed by @cap-js/ai). + +import cds from '@sap/cds'; +import { getNextLegacyId } from './lib/legacy-id.js'; +import { buildKttCompletionEntry, findExistingKttRecord } from './lib/ktt/completion.js'; +import { mergeProgress } from './lib/ktt/merge.js'; +import { computeBanter } from './lib/ktt/banter.js'; +import { isFlagEnabled } from './lib/feature-flags/db-flags.js'; + +export default class KttService extends cds.ApplicationService { + async init() { + const db = await cds.connect.to('db'); + const { TaskRecords, Users, KttLessons } = cds.entities('com.sap.developers.ims'); + + // Fail closed when the flag is OFF (spec §7.7): the readonly Lessons + // projection is what the island probes on mount to decide whether to run. + // Gating it here — alongside the two write actions — makes the whole page + // fail closed via a runtime DB flag with no redeploy. + this.before('READ', 'Lessons', (req) => { + if (!isFlagEnabled('KTT_ENABLED')) return req.reject(503, 'KTT is not enabled'); + }); + + this.on('completeLesson', async (req) => { + if (!isFlagEnabled('KTT_ENABLED')) return req.reject(503, 'KTT is not enabled'); + const { legacyId, title } = req.data; + const dbUser = await SELECT.one.from(Users).where({ sapId: req.user.id }); + if (!dbUser) return req.reject(403, 'Unknown user'); + const existing = await findExistingKttRecord(TaskRecords, dbUser.ID, legacyId); + if (existing) return { ok: true, alreadyDone: true }; + const entry = buildKttCompletionEntry({ + userId: dbUser.ID, legacyId, title, + attemptNumber: 1, nextLegacyId: await getNextLegacyId('TaskRecords', db), + }); + await INSERT.into(TaskRecords).entries(entry); + return { ok: true, alreadyDone: false }; + }); + + this.on('syncProgress', async (req) => { + if (!isFlagEnabled('KTT_ENABLED')) return req.reject(503, 'KTT is not enabled'); + const local = JSON.parse(req.data.localJson || '{}'); + const dbUser = await SELECT.one.from(Users).where({ sapId: req.user.id }); + if (!dbUser) return req.reject(403, 'Unknown user'); + + // Reconstruct remote state from HANA KTT_LESSON TaskRecords. + const done = await SELECT.from(TaskRecords) + .columns('taskLegacyId') + .where({ user_ID: dbUser.ID, taskType: 'KTT_LESSON', status: { '!=': 'SUPERSEDED' } }); + const doneIds = done.map((r) => r.taskLegacyId); + + const catalog = await SELECT.from(KttLessons).columns('legacyId', 'slug'); + const slugByLegacy = new Map(catalog.map((c) => [c.legacyId, c.slug])); + const remote = { + xp: 0, + streak: 0, + mastered: doneIds.map((id) => slugByLegacy.get(id)).filter(Boolean), + }; + + const merged = mergeProgress(local, remote); + + // Record any locally-mastered lessons not yet in HANA (idempotent guard reused from completeLesson). + const legacyBySlug = new Map(catalog.map((c) => [c.slug, c])); + for (const slug of merged.mastered) { + const cat = legacyBySlug.get(slug); + if (!cat || doneIds.includes(cat.legacyId)) continue; + const existing = await findExistingKttRecord(TaskRecords, dbUser.ID, cat.legacyId); + if (existing) continue; + await INSERT.into(TaskRecords).entries(buildKttCompletionEntry({ + userId: dbUser.ID, legacyId: cat.legacyId, title: cat.slug, + attemptNumber: 1, nextLegacyId: await getNextLegacyId('TaskRecords', db), + })); + } + + return merged; + }); + + // banter: fail-open AI quip; AICore connect failure degrades silently to null. + let ai = null; + try { ai = await cds.connect.to('AICore'); } catch { ai = null; } + const aiAdapter = ai ? { chat: async (p) => (await ai.send('POST', '/chat', { prompt: p }))?.text } : null; + this.on('banter', async (req) => computeBanter(aiAdapter, { event: req.data.context })); + + return super.init(); + } +} diff --git a/srv/lib/feature-flags/registry.js b/srv/lib/feature-flags/registry.js index 49b783d95..3982310d2 100644 --- a/srv/lib/feature-flags/registry.js +++ b/srv/lib/feature-flags/registry.js @@ -355,4 +355,12 @@ export const FEATURE_FLAGS = [ description: 'When true, the daily feedback-owner-digest job emails each tutorial owner a summary of new commented feedback. Second gate: only fires when the CF space is prod, and requires the SMTP secrets in Credential Store — so this toggle is inert on dev/qa. Toggling takes effect within the job\'s 60s flag cache. DB-driven config (ImsConfig key feedback.email.enabled); no env var. Default OFF (#2188).', howToChange: featureFlagUpsert('FEEDBACK_EMAIL_ENABLED', 'feedback.email.enabled'), }, + // ---- KTT (Kasimir Teaches TLAs) ---- + { + key: 'KTT_ENABLED', label: 'KTT — Kasimir Teaches TLAs', category: 'Content', + kind: 'db', imsConfigKey: 'flag.ktt.enabled', + valueType: 'boolean', default: false, status: 'beta', + description: 'Enables the /explore/ktt/ acronym trainer and its /ktt CAP endpoints. Off → completeLesson/syncProgress reject 503.', + howToChange: featureFlagUpsert('KTT_ENABLED', 'flag.ktt.enabled'), + }, ]; diff --git a/srv/lib/ktt/banter.js b/srv/lib/ktt/banter.js new file mode 100644 index 000000000..6ad81b15f --- /dev/null +++ b/srv/lib/ktt/banter.js @@ -0,0 +1,29 @@ +// srv/lib/ktt/banter.js +// Fail-open Kasimir banter helper. Pure, no DB, no direct AI import — the +// caller (ktt-service.js init()) passes in an `ai` adapter so this module +// remains testable without any CAP service binding. + +const FALLBACKS = { + streak: "Purr-fect. Another one mastered — my nap is well earned.", + wrong: "A miss! Even I cough up the occasional hairball. Try again.", + correct: "Correct. You may pet me. Once.", + default: "Acronyms: SAP's favorite indoor sport.", +}; +const MAX_LEN = 160; + +/** ai: object with async chat(prompt) → string, or null. Never throws. */ +export async function computeBanter(ai, ctx = {}) { + const fallback = FALLBACKS[ctx.event] || FALLBACKS.default; + if (!ai || typeof ai.chat !== 'function') return fallback; + try { + const prompt = `You are Professor Kasimir, a witty SAP cat. One short, tongue-in-cheek line (<160 chars) for event "${ctx.event}". No emojis.`; + const out = await Promise.race([ + ai.chat(prompt), + new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 2500)), + ]); + const line = String(out || '').trim().slice(0, MAX_LEN); + return line || fallback; + } catch { + return fallback; + } +} diff --git a/srv/lib/ktt/completion.js b/srv/lib/ktt/completion.js new file mode 100644 index 000000000..920b32901 --- /dev/null +++ b/srv/lib/ktt/completion.js @@ -0,0 +1,29 @@ +import { stampSubmissionId } from '../task-record-submission-id.js'; + +/** + * Build a KTT_LESSON TaskRecords entry. + * Does NOT set ID — CAP's cuid aspect auto-fills it on INSERT. + */ +export function buildKttCompletionEntry({ userId, legacyId, title, attemptNumber = 1, nextLegacyId }) { + return stampSubmissionId({ + user_ID: userId, + taskLegacyId: legacyId, + taskType: 'KTT_LESSON', + status: 'COMPLETED', + progress: 100, + completionDate: new Date().toISOString(), + titleSnapshot: (title || '').slice(0, 255), + legacyId: nextLegacyId, + attemptNumber, + }); +} + +/** + * Idempotency guard: returns an existing non-SUPERSEDED KTT_LESSON record + * for the given (user_ID, taskLegacyId) tuple, or null if none exists. + */ +export async function findExistingKttRecord(TaskRecords, userId, legacyId) { + return SELECT.one.from(TaskRecords).where({ + user_ID: userId, taskLegacyId: legacyId, taskType: 'KTT_LESSON', status: { '!=': 'SUPERSEDED' }, + }); +} diff --git a/srv/lib/ktt/merge.js b/srv/lib/ktt/merge.js new file mode 100644 index 000000000..58cc59b99 --- /dev/null +++ b/srv/lib/ktt/merge.js @@ -0,0 +1,13 @@ +/** + * mergeProgress — pure function, no CAP dependency. + * Merges local (localStorage) KTT progress with remote (HANA) progress. + * xp/streak: take the numeric max; mastered: sorted union of slugs. + */ +export function mergeProgress(local = {}, remote = {}) { + const mastered = Array.from(new Set([...(local.mastered || []), ...(remote.mastered || [])])).sort(); + return { + xp: Math.max(Number(local.xp) || 0, Number(remote.xp) || 0), + streak: Math.max(Number(local.streak) || 0, Number(remote.streak) || 0), + mastered, + }; +} diff --git a/srv/lib/user-progress.js b/srv/lib/user-progress.js index 1482bfd58..f68135d34 100644 --- a/srv/lib/user-progress.js +++ b/srv/lib/user-progress.js @@ -166,13 +166,13 @@ export async function getMyCompletedTutorials(user) { const dbUserId = await resolveDbUserId(user); if (!dbUserId) return []; - const { TaskRecords, Tutorials, Puzzles, Petoberfests } = cds.entities('com.sap.developers.ims'); + const { TaskRecords, Tutorials, Puzzles, Petoberfests, KttLessons } = cds.entities('com.sap.developers.ims'); const records = await SELECT.from(TaskRecords) .columns('taskLegacyId', 'taskType', 'completionDate', 'modifiedAt', 'titleSnapshot', 'attemptNumber') .where({ user_ID: dbUserId, - taskType: { in: ['TUTORIAL', 'PUZZLE', 'PETOBERFEST'] }, + taskType: { in: ['TUTORIAL', 'PUZZLE', 'PETOBERFEST', 'KTT_LESSON'] }, status: { in: ['COMPLETED', 'SUPERSEDED'] } }); if (records.length === 0) return []; @@ -180,13 +180,15 @@ export async function getMyCompletedTutorials(user) { const tutorialIds = []; const puzzleIds = []; const petoberfestIds = []; + const kttIds = []; for (const r of records) { if (r.taskType === 'TUTORIAL') tutorialIds.push(r.taskLegacyId); else if (r.taskType === 'PUZZLE') puzzleIds.push(r.taskLegacyId); else if (r.taskType === 'PETOBERFEST') petoberfestIds.push(r.taskLegacyId); + else if (r.taskType === 'KTT_LESSON') kttIds.push(r.taskLegacyId); } - const [tutorials, puzzles, petoberfests] = await Promise.all([ + const [tutorials, puzzles, petoberfests, kttLessons] = await Promise.all([ tutorialIds.length ? SELECT.from(Tutorials) .columns('legacyId', 'slug', 'title', 'primaryTag', 'experienceTag', 'averageTimeToComplete') @@ -201,22 +203,31 @@ export async function getMyCompletedTutorials(user) { ? SELECT.from(Petoberfests) .columns('legacyId', 'slug', 'title') .where({ legacyId: { in: petoberfestIds } }) + : [], + kttIds.length + ? SELECT.from(KttLessons) + .columns('legacyId', 'slug', 'title') + .where({ legacyId: { in: kttIds } }) : [] ]); const tutorialMeta = new Map(tutorials.map(t => [t.legacyId, t])); const puzzleMeta = new Map(puzzles.map(p => [p.legacyId, p])); const petoberfestMeta = new Map(petoberfests.map(p => [p.legacyId, p])); + const kttMeta = new Map(kttLessons.map(k => [k.legacyId, k])); const rows = []; for (const r of records) { const kind = r.taskType === 'PUZZLE' ? 'puzzle' : r.taskType === 'PETOBERFEST' ? 'petoberfest' + : r.taskType === 'KTT_LESSON' ? 'ktt' : 'tutorial'; const meta = kind === 'puzzle' ? puzzleMeta.get(r.taskLegacyId) : kind === 'petoberfest' ? petoberfestMeta.get(r.taskLegacyId) + : kind === 'ktt' + ? kttMeta.get(r.taskLegacyId) : tutorialMeta.get(r.taskLegacyId); if (!meta?.slug) continue; rows.push({ diff --git a/test/e2e/ktt.spec.ts b/test/e2e/ktt.spec.ts new file mode 100644 index 000000000..4e887e383 --- /dev/null +++ b/test/e2e/ktt.spec.ts @@ -0,0 +1,84 @@ +// test/e2e/ktt.spec.ts +// +// e2e: Kasimir Teaches TLAs island at /explore/ktt/ +// +// Two post-deploy checks: +// 1. SSR shell renders (h1 title + unit list visible before JS hydrates) +// 2. Interactive flow: Start → skill-tree map → first unlocked lesson → answer +// a drill → feedback/Next button renders (client-side engine is self-contained; +// backend calls are best-effort/fail-open and are not asserted here) +// +// Self-skips when PLAYWRIGHT_BASE_URL / SMOKE_BASE_URL is absent +// (repo e2e convention, #1338). `npm test` (unit suite) is unaffected. +// +// Run post-deploy only: +// npx vitest run --project e2e test/e2e/ktt.spec.ts +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { hasBaseUrl } from './e2e.config.js'; +import { launchBrowser, newPage } from './_browser.js'; + +describe.skipIf(!hasBaseUrl())('e2e: /explore/ktt/ (Kasimir Teaches TLAs)', () => { + let browser: Awaited>; + + beforeAll(async () => { + browser = await launchBrowser(); + }); + + afterAll(async () => { + await browser?.close(); + }); + + it('SSR shell renders h1 and unit list before JS hydrates', async () => { + const { context, page } = await newPage(browser, { authenticated: false }); + try { + const response = await page.goto('/explore/ktt/', { waitUntil: 'domcontentloaded' }); + expect(response, 'no response received').not.toBeNull(); + expect(response!.status(), 'unexpected status for /explore/ktt/').toBe(200); + // SSR shell: h1 page title and unit
  • items baked by Hugo into ktt.html layout + await expect(page.locator('h1')).toBeVisible(); + await expect(page.locator('ul li').first()).toBeVisible(); + } finally { + await context.close(); + } + }); + + it('Start → skill-tree map → first lesson → answer drill → feedback renders', async () => { + const { context, page } = await newPage(browser, { authenticated: false }); + try { + await page.goto('/explore/ktt/', { waitUntil: 'domcontentloaded' }); + + // Wait for Vue island to hydrate: Start button must appear in the landing screen + const startBtn = page.locator('[data-testid="ktt-start"]'); + await startBtn.waitFor({ state: 'visible', timeout: 20_000 }); + await startBtn.click(); + + // Skill-tree map: the first lesson node (always unlocked) must be visible + const firstNode = page.locator('[data-testid^="ktt-node-"]').first(); + await firstNode.waitFor({ state: 'visible', timeout: 10_000 }); + await firstNode.click(); + + // Lesson opened — advance through any leading story beats to reach the first drill. + // Story beats show [data-testid="ktt-next"]; drill beats show [data-testid="ktt-choice"]. + const choiceBtn = page.locator('[data-testid="ktt-choice"]').first(); + const nextBtn = page.locator('[data-testid="ktt-next"]'); + + for (let i = 0; i < 8; i++) { + if (await choiceBtn.isVisible().catch(() => false)) break; + const hasNext = await nextBtn.isVisible().catch(() => false); + if (hasNext) await nextBtn.click(); + // Allow Vue reactivity to settle between story beat advances + await page.waitForTimeout(300); + } + + // Now on a drill beat — click the first choice (correct or wrong: doesn't matter) + await choiceBtn.waitFor({ state: 'visible', timeout: 10_000 }); + await choiceBtn.click(); + + // After answering, Kasimir feedback renders and the Next / Try Again button appears + await nextBtn.waitFor({ state: 'visible', timeout: 10_000 }); + await expect(nextBtn).toBeVisible(); + } finally { + await context.close(); + } + }); +}); diff --git a/test/unit/ktt-banter.test.js b/test/unit/ktt-banter.test.js new file mode 100644 index 000000000..fb2a6d896 --- /dev/null +++ b/test/unit/ktt-banter.test.js @@ -0,0 +1,25 @@ +// test/unit/ktt-banter.test.js +// Unit tests for computeBanter — fail-open Kasimir banter helper. +// Pure ESM, no cds.test bootstrap needed (no DB interaction). + +import { expect, test, vi } from 'vitest'; +import { computeBanter } from '../../srv/lib/ktt/banter.js'; + +test('banter returns a fallback line when AICore fails', async () => { + const brokenAi = { chat: vi.fn(async () => { throw new Error('AICore down'); }) }; + const line = await computeBanter(brokenAi, { event: 'streak', streak: 5 }); + expect(typeof line).toBe('string'); + expect(line.length).toBeGreaterThan(0); +}); + +test('banter returns trimmed AI response when chat resolves', async () => { + const goodAi = { chat: vi.fn(async () => ' Nice one! ') }; + const line = await computeBanter(goodAi, { event: 'correct' }); + expect(line).toBe('Nice one!'); + expect(line.length).toBeLessThanOrEqual(160); +}); + +test('banter returns wrong fallback when ai is null', async () => { + const line = await computeBanter(null, { event: 'wrong' }); + expect(line).toBe('A miss! Even I cough up the occasional hairball. Try again.'); +}); diff --git a/test/unit/ktt-complete.test.js b/test/unit/ktt-complete.test.js new file mode 100644 index 000000000..264bc8c2a --- /dev/null +++ b/test/unit/ktt-complete.test.js @@ -0,0 +1,58 @@ +// test/unit/ktt-complete.test.js +// Unit test for KttService.completeLesson — writes a KTT_LESSON TaskRecord (idempotent). +// Also covers syncProgress — merges local JSON progress with HANA state. +// Bootstrap: same ESM + cds.test pattern used across test/unit/ served-mode tests. + +import cds from '@sap/cds'; +import { expect, test, beforeAll, afterAll } from 'vitest'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; + +const { POST } = cds.test('serve', '--project', '.', '--in-memory'); + +let authed; +beforeAll(async () => { + // Force KTT_ENABLED ON for all tests in this file so the success-path + // assertions are not blocked by the flag gate introduced in Task 6. + __setFlagForTest('KTT_ENABLED', true); + + const { Users, KttLessons } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Users).entries({ ID: cds.utils.uuid(), uuid: 'alice', sapId: 'alice', legacyId: 1 }); + // The KttLessons catalog is now seeded from db/data/…-KttLessons.csv, so clear + // it first to avoid an @assert.unique(legacyId) collision on this controlled + // fixture (seed legacyId 90001 slug 'unit-1-platform-lesson-1'). + await DELETE.from(KttLessons); + await INSERT.into(KttLessons).entries({ ID: cds.utils.uuid(), legacyId: 90001, slug: 'core-1' }); + authed = { auth: { username: 'alice', password: 'alice' } }; +}); + +afterAll(() => { + __resetFlagsForTest(); +}); + +test('completeLesson writes one KTT_LESSON TaskRecord and is idempotent', async () => { + const body = { lessonSlug: 'core-1', legacyId: 90001, title: 'Meet the Platform' }; + const r1 = await POST('/ktt/completeLesson', body, authed); + expect(r1.data.ok).toBe(true); + expect(r1.data.alreadyDone).toBe(false); + const r2 = await POST('/ktt/completeLesson', body, authed); + expect(r2.data.alreadyDone).toBe(true); + const { TaskRecords } = cds.entities('com.sap.developers.ims'); + const rows = await SELECT.from(TaskRecords).where({ taskLegacyId: 90001, taskType: 'KTT_LESSON' }); + expect(rows.length).toBe(1); +}); + +test('syncProgress merges local JSON with HANA TaskRecords and returns merged totals', async () => { + // alice already has a KTT_LESSON TaskRecord for legacyId 90001 (slug 'core-1') + // from the completeLesson test above. syncProgress should reflect it in mastered + // without double-inserting. + const localJson = JSON.stringify({ xp: 50, streak: 2, mastered: ['core-1'] }); + const r = await POST('/ktt/syncProgress', { localJson }, authed); + expect(r.data.mastered).toContain('core-1'); + expect(r.data.xp).toBe(50); + expect(r.data.streak).toBe(2); + + // Confirm no double-insert occurred — still exactly one KTT_LESSON for 90001. + const { TaskRecords } = cds.entities('com.sap.developers.ims'); + const rows = await SELECT.from(TaskRecords).where({ taskLegacyId: 90001, taskType: 'KTT_LESSON' }); + expect(rows.length).toBe(1); +}); diff --git a/test/unit/ktt-flag-gate.test.js b/test/unit/ktt-flag-gate.test.js new file mode 100644 index 000000000..1557bf0b1 --- /dev/null +++ b/test/unit/ktt-flag-gate.test.js @@ -0,0 +1,89 @@ +// test/unit/ktt-flag-gate.test.js +// Tests that completeLesson and syncProgress reject 503 when KTT_ENABLED is OFF, +// and succeed when it is forced ON. +// Uses __setFlagForTest / __resetFlagsForTest (globalThis-backed cache helpers +// from db-flags.js) — the same pattern as kg-path-v2-handler-flag.test.js. + +import cds from '@sap/cds'; +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; + +const { GET, POST } = cds.test('serve', '--project', '.', '--in-memory'); + +let authed; +beforeAll(async () => { + const { Users, KttLessons } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Users).entries({ ID: cds.utils.uuid(), uuid: 'bob', sapId: 'bob', legacyId: 2 }); + // KttLessons is now CSV-seeded (db/data/…-KttLessons.csv). Clear it before this + // controlled fixture so legacyId 90002 does not collide with the seed's row. + await DELETE.from(KttLessons); + await INSERT.into(KttLessons).entries({ ID: cds.utils.uuid(), legacyId: 90002, slug: 'core-2' }); + authed = { auth: { username: 'bob', password: 'bob' } }; +}); + +beforeEach(() => { + // Each test controls the flag explicitly — reset to cold/default (OFF) first. + __resetFlagsForTest(); +}); + +afterEach(() => { + __resetFlagsForTest(); +}); + +describe('KTT_ENABLED flag gate', () => { + describe('flag OFF (default)', () => { + it('completeLesson rejects 503 when KTT_ENABLED is off', async () => { + // Flag is OFF (cold cache → declared default false). + const body = { lessonSlug: 'core-2', legacyId: 90002, title: 'Meet TLAs' }; + try { + await POST('/ktt/completeLesson', body, authed); + expect.fail('expected a 503 rejection'); + } catch (err) { + expect(err.response?.status ?? err.status).toBe(503); + } + }); + + it('syncProgress rejects 503 when KTT_ENABLED is off', async () => { + const body = { localJson: JSON.stringify({ xp: 0, streak: 0, mastered: [] }) }; + try { + await POST('/ktt/syncProgress', body, authed); + expect.fail('expected a 503 rejection'); + } catch (err) { + expect(err.response?.status ?? err.status).toBe(503); + } + }); + + it('READ Lessons rejects 503 when KTT_ENABLED is off (page fails closed)', async () => { + try { + await GET('/ktt/Lessons?$top=1'); + expect.fail('expected a 503 rejection'); + } catch (err) { + expect(err.response?.status ?? err.status).toBe(503); + } + }); + }); + + describe('flag ON', () => { + beforeEach(() => { + __setFlagForTest('KTT_ENABLED', true); + }); + + it('completeLesson succeeds when KTT_ENABLED is on', async () => { + const body = { lessonSlug: 'core-2', legacyId: 90002, title: 'Meet TLAs' }; + const r = await POST('/ktt/completeLesson', body, authed); + expect(r.data.ok).toBe(true); + }); + + it('syncProgress succeeds when KTT_ENABLED is on', async () => { + const body = { localJson: JSON.stringify({ xp: 10, streak: 1, mastered: ['core-2'] }) }; + const r = await POST('/ktt/syncProgress', body, authed); + expect(r.data.mastered).toContain('core-2'); + }); + + it('READ Lessons succeeds when KTT_ENABLED is on', async () => { + const r = await GET('/ktt/Lessons?$top=1'); + expect(r.status).toBe(200); + expect(Array.isArray(r.data.value)).toBe(true); + }); + }); +}); diff --git a/test/unit/ktt-lessons-data.test.ts b/test/unit/ktt-lessons-data.test.ts new file mode 100644 index 000000000..26de0bc0c --- /dev/null +++ b/test/unit/ktt-lessons-data.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { validateLessons } from '../../scripts/lib/ktt-lessons-schema'; + +describe('ktt_lessons.json', () => { + const data = JSON.parse(readFileSync('hugo/data/ktt_lessons.json', 'utf8')); + it('has 3-4 units and passes structural validation', () => { + expect(data.units.length).toBeGreaterThanOrEqual(3); + expect(data.units.length).toBeLessThanOrEqual(4); + expect(validateLessons(data)).toEqual([]); + }); + it('every drill has an answer and >=2 distractors; legacyIds unique', () => { + const errs = validateLessons(data); + expect(errs).toEqual([]); + }); +}); diff --git a/test/unit/ktt-merge.test.js b/test/unit/ktt-merge.test.js new file mode 100644 index 000000000..f92ddc5fc --- /dev/null +++ b/test/unit/ktt-merge.test.js @@ -0,0 +1,13 @@ +// test/unit/ktt-merge.test.js +// Unit test for the pure mergeProgress helper (no CAP bootstrap needed). + +import { expect, test } from 'vitest'; +import { mergeProgress } from '../../srv/lib/ktt/merge.js'; + +test('mergeProgress unions mastered and takes max xp/streak', () => { + const local = { xp: 120, streak: 3, mastered: ['core-1', 'core-2'] }; + const remote = { xp: 90, streak: 5, mastered: ['core-2', 'sec-1'] }; + expect(mergeProgress(local, remote)).toEqual({ + xp: 120, streak: 5, mastered: ['core-1', 'core-2', 'sec-1'], + }); +}); diff --git a/test/unit/ktt-model.test.js b/test/unit/ktt-model.test.js new file mode 100644 index 000000000..2a5ec1ec8 --- /dev/null +++ b/test/unit/ktt-model.test.js @@ -0,0 +1,17 @@ +import { test, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; + +let model; + +beforeAll(async () => { + model = await cds.load(['db', 'srv']); +}); + +test('TaskRecords accepts KTT_LESSON and KttLessons entity exists', async () => { + const csn = cds.linked(model); + const tr = csn.definitions['com.sap.developers.ims.TaskRecords']; + const enumVals = Object.keys(tr.elements.taskType.enum); + expect(enumVals).toContain('KTT_LESSON'); + expect(csn.definitions['com.sap.developers.ims.KttLessons']).toBeDefined(); + expect(csn.definitions['com.sap.developers.ims.KttLessons'].elements.legacyId).toBeDefined(); +}); diff --git a/test/unit/ktt-mycompletions.test.js b/test/unit/ktt-mycompletions.test.js new file mode 100644 index 000000000..534f63ea7 --- /dev/null +++ b/test/unit/ktt-mycompletions.test.js @@ -0,0 +1,86 @@ +// test/unit/ktt-mycompletions.test.js +// Verifies that completed KTT_LESSON TaskRecords surface in getMyCompletedTutorials +// with kind:'ktt' — the function powering the /me MyCompletions endpoint. +// +// Uses direct-function style (mirrors user-progress.test.js): cds.deploy to +// in-memory SQLite, seed data, call the exported function. No HTTP round-trip +// needed — the function is pure data logic, HTTP-endpoint testing is covered by +// the served-mode smoke suite. + +import path from 'node:path'; +import cds from '@sap/cds'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { getMyCompletedTutorials } from '../../srv/lib/user-progress.js'; + +const schemaPath = path.join(process.cwd(), 'db', 'schema.cds'); + +const USER_UUID = 'ktt-test-user-uuid-001'; +const USER_ID = 'bbbbbbbb-0000-0000-0000-000000000001'; + +async function seed() { + const { Users, KttLessons, TaskRecords } = cds.entities('com.sap.developers.ims'); + + await DELETE.from(TaskRecords); + await DELETE.from(KttLessons); + await DELETE.from(Users); + + await INSERT.into(Users).entries({ + ID: USER_ID, + uuid: USER_UUID, + sapId: USER_UUID, // resolveDbUserId looks up WHERE sapId = user.id (issue #343) + legacyId: 9999 + }); + + await INSERT.into(KttLessons).entries({ + ID: cds.utils.uuid(), + legacyId: 90001, + slug: 'core-1', + unitId: 'core', + title: 'Meet the Platform', + order: 1 + }); + + await INSERT.into(TaskRecords).entries({ + ID: cds.utils.uuid(), + user_ID: USER_ID, + taskLegacyId: 90001, + taskType: 'KTT_LESSON', + status: 'COMPLETED', + completionDate: '2026-09-01T10:00:00Z', + modifiedAt: '2026-09-01T10:00:00Z', + titleSnapshot: 'Meet the Platform', + attemptNumber: 1 + }); +} + +describe('getMyCompletedTutorials — KTT lessons', () => { + beforeEach(async () => { + await cds.deploy(schemaPath).to('sqlite::memory:'); + await seed(); + }); + + it('surfaces completed KTT_LESSON records as kind:"ktt" entries', async () => { + const result = await getMyCompletedTutorials({ id: USER_UUID }); + expect(result.length).toBeGreaterThan(0); + const kttEntry = result.find(e => e.kind === 'ktt'); + expect(kttEntry).toBeDefined(); + expect(kttEntry).toMatchObject({ + kind: 'ktt', + slug: 'core-1', + title: 'Meet the Platform' + }); + expect(kttEntry.completionDate).toBeTruthy(); + expect(typeof kttEntry.attemptNumber).toBe('number'); + }); + + it('does NOT surface KTT_LESSON records as kind:"tutorial", "puzzle", or "petoberfest"', async () => { + const result = await getMyCompletedTutorials({ id: USER_UUID }); + const wrongKind = result.find(e => e.slug === 'core-1' && e.kind !== 'ktt'); + expect(wrongKind).toBeUndefined(); + }); + + it('returns empty array for user with no completions', async () => { + const result = await getMyCompletedTutorials({ id: 'nobody' }); + expect(result).toEqual([]); + }); +}); diff --git a/test/unit/ktt-seed.test.js b/test/unit/ktt-seed.test.js new file mode 100644 index 000000000..79dfecc37 --- /dev/null +++ b/test/unit/ktt-seed.test.js @@ -0,0 +1,58 @@ +// test/unit/ktt-seed.test.js +// Regression for spec §7.1/§7.2: the KttLessons catalog is now SEEDED from +// db/data/com.sap.developers.ims-KttLessons.csv. This test deliberately does +// NOT insert any KttLessons row — it relies solely on the CSV seed. It would +// have caught the original gap where the table shipped EMPTY and every KTT +// completion was silently dropped from MyCompletions. +// +// Flow: force KTT_ENABLED on → POST completeLesson for a REAL seeded lesson → +// assert getMyCompletedTutorials surfaces it with the SEEDED title + slug. + +import cds from '@sap/cds'; +import { expect, test, beforeAll, afterAll } from 'vitest'; +import { getMyCompletedTutorials } from '../../srv/lib/user-progress.js'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; + +const { POST } = cds.test('serve', '--project', '.', '--in-memory'); + +// Real seeded lesson (unit 1, lesson 1) — see db/data/…-KttLessons.csv. +const SEEDED_LEGACY_ID = 90001; +const SEEDED_SLUG = 'unit-1-platform-lesson-1'; +const SEEDED_TITLE = "Who's SAP, and what's BTP?"; + +let authed; +beforeAll(async () => { + __setFlagForTest('KTT_ENABLED', true); + const { Users } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Users).entries({ ID: cds.utils.uuid(), uuid: 'seeduser', sapId: 'seeduser', legacyId: 42 }); + authed = { auth: { username: 'seeduser', password: 'seeduser' } }; +}); + +afterAll(() => { + __resetFlagsForTest(); +}); + +test('KttLessons catalog is seeded from the generated CSV (no manual insert)', async () => { + const { KttLessons } = cds.entities('com.sap.developers.ims'); + const seeded = await SELECT.one.from(KttLessons).where({ legacyId: SEEDED_LEGACY_ID }); + expect(seeded).toBeDefined(); + expect(seeded.slug).toBe(SEEDED_SLUG); + expect(seeded.title).toBe(SEEDED_TITLE); + const count = await SELECT.from(KttLessons); + expect(count.length).toBe(12); +}); + +test('a completed lesson surfaces in MyCompletions with the SEEDED title/slug', async () => { + // The island sends the lesson id as lessonSlug; the server keys off legacyId. + const body = { lessonSlug: SEEDED_SLUG, legacyId: SEEDED_LEGACY_ID, title: SEEDED_TITLE }; + const r = await POST('/ktt/completeLesson', body, authed); + expect(r.data.ok).toBe(true); + + const completions = await getMyCompletedTutorials({ id: 'seeduser' }); + const kttEntry = completions.find((e) => e.kind === 'ktt' && e.slug === SEEDED_SLUG); + expect(kttEntry).toBeDefined(); + // title/slug come from the SEEDED catalog row via the legacyId join, not from + // the client payload — proving the seed is what makes MyCompletions work. + expect(kttEntry.title).toBe(SEEDED_TITLE); + expect(kttEntry.slug).toBe(SEEDED_SLUG); +}); diff --git a/test/unit/provenance-envelope.test.js b/test/unit/provenance-envelope.test.js index 389c331c2..60fd7488d 100644 --- a/test/unit/provenance-envelope.test.js +++ b/test/unit/provenance-envelope.test.js @@ -27,7 +27,7 @@ describe('buildEnvelope', () => { __setKeyForTest(pem); const { jws, claims } = await buildEnvelope(base); const pub = await importJWK((await getJwks()).keys[0], 'EdDSA'); - const { payload } = await jwtVerify(jws, pub, { issuer: 'https://developers.sap.com' }); + const { payload } = await jwtVerify(jws, pub, { issuer: 'https://developers.sap.com', currentDate: new Date(base.now) }); expect(payload.sub).toBe('my-tutorial'); expect(payload.contentHash).toBe(base.contentHash); expect(payload.provenance).toMatchObject({ sourceRepo: 'sap-tutorials/Tutorials', sourceCommit: base.sourceCommit, builtAt: base.builtAt });