Ship Novya PWA with backend for deployable production use.
Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Prepare local dev: free backend port and ensure MySQL is reachable.
|
||||
* Runs automatically via npm "predev" before `npm run dev`.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import { platform } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MYSQL_PORT = 3306;
|
||||
const BACKEND_LISTEN_PORT = 3001;
|
||||
const FRONTEND_PORT = 5173;
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
const backendDir = join(rootDir, 'backend');
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[prepare-dev] ${msg}`);
|
||||
}
|
||||
|
||||
function isPortOpen(port, host = '127.0.0.1') {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ port, host }, () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.on('error', () => resolve(false));
|
||||
socket.setTimeout(1500, () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function freePort(port) {
|
||||
if (platform() === 'win32') {
|
||||
try {
|
||||
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8' });
|
||||
const pids = new Set();
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line.includes('LISTENING')) continue;
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[parts.length - 1];
|
||||
if (pid && pid !== '0') pids.add(pid);
|
||||
}
|
||||
for (const pid of pids) {
|
||||
log(`Stopping process ${pid} on port ${port}`);
|
||||
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// nothing listening
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: 'ignore', shell: true });
|
||||
} catch {
|
||||
// nothing listening
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMysql() {
|
||||
if (await isPortOpen(MYSQL_PORT)) {
|
||||
log(`MySQL is reachable on port ${MYSQL_PORT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`MySQL not reachable on port ${MYSQL_PORT}; starting Docker Compose...`);
|
||||
try {
|
||||
execSync('docker compose -f docker-compose.dev.yml up -d', {
|
||||
cwd: backendDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch {
|
||||
console.error(
|
||||
'[prepare-dev] ERROR: MySQL is not running. Start it with:\n' +
|
||||
' cd backend && docker compose -f docker-compose.dev.yml up -d',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
if (await isPortOpen(MYSQL_PORT)) {
|
||||
log('MySQL is ready');
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
console.error('[prepare-dev] ERROR: MySQL did not become ready in time.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
freePort(BACKEND_LISTEN_PORT);
|
||||
freePort(FRONTEND_PORT);
|
||||
await ensureMysql();
|
||||
log(`Ready — backend will listen on port ${BACKEND_LISTEN_PORT}`);
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Offline tests: AI Coach session completion schema + validation.
|
||||
* Run: npx tsx --tsconfig tsconfig.json scripts/verify-coach-completion.ts
|
||||
*/
|
||||
import { getSessionByOrder } from '../src/data/program/sessions';
|
||||
import { buildAiCoachSessionContext } from '../src/lib/aiCoach/sessionContext';
|
||||
import {
|
||||
COACH_SESSION_COMPLETE_CLOSE,
|
||||
COACH_SESSION_COMPLETE_OPEN,
|
||||
exampleCompletionSignal,
|
||||
extractSessionExerciseLabels,
|
||||
extractSessionHeadings,
|
||||
parseCoachCompletionFromAssistantText,
|
||||
validateCoachSessionCompletionSignal,
|
||||
type CoachSessionCompletionSignal
|
||||
} from '../src/lib/aiCoach/sessionCompletion';
|
||||
import { buildCoachSystemPrompt } from '../src/lib/aiCoach/buildCoachSystemPrompt';
|
||||
|
||||
const USER_ID = 'verify-coach-user';
|
||||
|
||||
const session1 = getSessionByOrder(1);
|
||||
if (!session1) throw new Error('Session 1 missing');
|
||||
|
||||
const ctx = buildAiCoachSessionContext(session1);
|
||||
const prompt = buildCoachSystemPrompt(ctx);
|
||||
|
||||
const validSignal = exampleCompletionSignal(ctx);
|
||||
|
||||
const wrap = (signal: CoachSessionCompletionSignal, visible = 'جلسه به پایان رسید.') =>
|
||||
`${visible}\n${COACH_SESSION_COMPLETE_OPEN}\n${JSON.stringify(signal)}\n${COACH_SESSION_COMPLETE_CLOSE}`;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const assert = (name: string, condition: boolean) => {
|
||||
if (condition) {
|
||||
passed += 1;
|
||||
return;
|
||||
}
|
||||
failed += 1;
|
||||
console.error(`FAIL: ${name}`);
|
||||
};
|
||||
|
||||
// --- parse ---
|
||||
const parsed = parseCoachCompletionFromAssistantText(wrap(validSignal));
|
||||
assert('parse extracts signal', parsed.signal?.sessionComplete === true);
|
||||
assert('parse strips marker from display', !parsed.displayContent.includes(COACH_SESSION_COMPLETE_OPEN));
|
||||
assert('parse keeps visible Persian text', parsed.displayContent.includes('جلسه به پایان رسید'));
|
||||
|
||||
// --- validate valid ---
|
||||
const ok = validateCoachSessionCompletionSignal(validSignal, ctx, {
|
||||
userId: USER_ID,
|
||||
userTurnCount: 3
|
||||
});
|
||||
assert('valid signal passes', ok.valid === true);
|
||||
|
||||
// --- reject: single user turn ---
|
||||
const lowTurns = validateCoachSessionCompletionSignal(validSignal, ctx, {
|
||||
userId: USER_ID,
|
||||
userTurnCount: 1
|
||||
});
|
||||
assert('rejects single user turn', lowTurns.valid === false);
|
||||
|
||||
// --- reject: session id mismatch ---
|
||||
const badId = validateCoachSessionCompletionSignal(
|
||||
{ ...validSignal, sessionId: 'session-99' },
|
||||
ctx,
|
||||
{ userId: USER_ID, userTurnCount: 3 }
|
||||
);
|
||||
assert('rejects sessionId mismatch', badId.valid === false);
|
||||
|
||||
// --- reject: user not confirmed ---
|
||||
const noConfirm = validateCoachSessionCompletionSignal(
|
||||
{ ...validSignal, userConfirmed: false },
|
||||
ctx,
|
||||
{ userId: USER_ID, userTurnCount: 3 }
|
||||
);
|
||||
assert('rejects missing userConfirmed', noConfirm.valid === false);
|
||||
|
||||
// --- reject: missing exercise ---
|
||||
const missingExercise = validateCoachSessionCompletionSignal(
|
||||
{ ...validSignal, completedExercises: [] },
|
||||
ctx,
|
||||
{ userId: USER_ID, userTurnCount: 3 }
|
||||
);
|
||||
assert('rejects missing completed exercises', missingExercise.valid === false);
|
||||
|
||||
// --- reject: insufficient covered topics ---
|
||||
const thinTopics = validateCoachSessionCompletionSignal(
|
||||
{ ...validSignal, coveredTopics: ['نامرتبط'] },
|
||||
ctx,
|
||||
{ userId: USER_ID, userTurnCount: 3 }
|
||||
);
|
||||
assert('rejects insufficient coveredTopics', thinTopics.valid === false);
|
||||
|
||||
// --- prompt includes completion instructions ---
|
||||
assert('prompt includes COACH_SESSION_COMPLETE marker', prompt.includes('COACH_SESSION_COMPLETE'));
|
||||
assert('prompt includes sessionComplete field', prompt.includes('"sessionComplete":true'));
|
||||
assert('prompt includes userConfirmed rule', prompt.includes('userConfirmed'));
|
||||
|
||||
// --- session 1 has pretest exercise in labels ---
|
||||
const exerciseLabels = extractSessionExerciseLabels(ctx.blocks);
|
||||
assert('session 1 has pretest exercise label', exerciseLabels.some((l) => l.includes('پیشآزمون')));
|
||||
|
||||
const headings = extractSessionHeadings(ctx.blocks);
|
||||
assert('session 1 has headings', headings.length > 0);
|
||||
|
||||
console.log(JSON.stringify({ passed, failed, exerciseLabels, headingCount: headings.length }, null, 2));
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`Verification failed: ${failed} check(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Coach completion schema + validation OK');
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Dev check: Session 1 coach system prompt contains course context and teaching-style rules.
|
||||
* Run: npx tsx --tsconfig tsconfig.json scripts/verify-coach-prompt.ts
|
||||
*/
|
||||
import { getSessionByOrder, PROGRAM_SESSION_COUNT } from '../src/data/program/sessions';
|
||||
import {
|
||||
buildCoachSystemPrompt,
|
||||
buildCoachSessionStartCue,
|
||||
COACH_SESSION_START_CUE
|
||||
} from '../src/lib/aiCoach/buildCoachSystemPrompt';
|
||||
|
||||
const s = getSessionByOrder(1);
|
||||
if (!s) throw new Error('Session 1 missing');
|
||||
|
||||
const s11 = getSessionByOrder(11);
|
||||
if (!s11) throw new Error('Session 11 missing');
|
||||
|
||||
const ctx = {
|
||||
sessionId: s.id,
|
||||
order: s.order,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
estimatedDurationMinutes: s.estimatedDurationMinutes,
|
||||
objectivesHint: s.description,
|
||||
blocks: s.blocks,
|
||||
totalSessions: PROGRAM_SESSION_COUNT,
|
||||
isFinalSession: s.order >= PROGRAM_SESSION_COUNT,
|
||||
nextSessionUnlocksAt: null,
|
||||
};
|
||||
const prompt = buildCoachSystemPrompt(ctx);
|
||||
|
||||
const ctx11 = {
|
||||
sessionId: s11.id,
|
||||
order: s11.order,
|
||||
title: s11.title,
|
||||
description: s11.description,
|
||||
estimatedDurationMinutes: s11.estimatedDurationMinutes,
|
||||
objectivesHint: s11.description,
|
||||
blocks: s11.blocks,
|
||||
totalSessions: PROGRAM_SESSION_COUNT,
|
||||
isFinalSession: true,
|
||||
nextSessionUnlocksAt: null,
|
||||
};
|
||||
const prompt11 = buildCoachSystemPrompt(ctx11);
|
||||
const startCue11 = buildCoachSessionStartCue(11);
|
||||
|
||||
const checks = {
|
||||
// Existing course context & grounding
|
||||
title: prompt.includes(s.title),
|
||||
objective: prompt.includes(s.description),
|
||||
exercise: prompt.includes('پیشآزمون'),
|
||||
bansGenericHelp: prompt.includes('چه کمکی میتونم بکنم؟'),
|
||||
hasOpeningRules: prompt.includes('SESSION OPENING'),
|
||||
pretestNote: prompt.includes('NEVER MENTION PRE-TEST IN USER-FACING CHAT'),
|
||||
groundingSection: prompt.includes('STRICT COURSE GROUNDING'),
|
||||
assessmentBoundary: prompt.includes('ASSESSMENT BOUNDARY'),
|
||||
noNewAssessment: prompt.includes('administer another psychological assessment'),
|
||||
noInventQuestionnaire: prompt.includes('invent questionnaire items'),
|
||||
forbiddenSymptomExamples: prompt.includes('در هفته گذشته چه میزان استرس'),
|
||||
noInventRoles: prompt.includes('Do NOT invent role lists'),
|
||||
noGuessGap: prompt.includes('Do not guess'),
|
||||
noGenericPsychFill: prompt.includes('general psychological knowledge'),
|
||||
thinContentRule: prompt.includes('CRITICAL THIN-CONTENT RULE'),
|
||||
noSubstitutePretest: prompt.includes('Do NOT retake, recreate, simulate, or substitute'),
|
||||
noRepeatPretestInChat: prompt.includes('Do NOT repeat the pre-test inside Chat'),
|
||||
silentSkipPretest: prompt.includes('silent skip'),
|
||||
omitPretestWhenSpeaking: prompt.includes('OMIT that phrase when speaking'),
|
||||
neverMentionPretestToUser: prompt.includes('NEVER mention «پیشآزمون»'),
|
||||
assessmentResultsRule: prompt.includes('do NOT invent interpretation'),
|
||||
completionSignal: prompt.includes('SESSION COMPLETION SIGNAL'),
|
||||
completionMarker: prompt.includes('COACH_SESSION_COMPLETE'),
|
||||
|
||||
// Teaching style — educational package introduction
|
||||
educationalPackageIntro: prompt.includes('introduce the overall educational package'),
|
||||
packageMultipleSessions: prompt.includes('multiple guided sessions'),
|
||||
packageConversationLearning: prompt.includes('learning happens through conversation and interaction'),
|
||||
|
||||
// Teaching style — tone & language
|
||||
informalColloquialTone: prompt.includes('informal, colloquial Persian'),
|
||||
addressLearnerAsTo: prompt.includes('Address the learner with «تو»'),
|
||||
teenFriendlyLanguage: prompt.includes('teenager'),
|
||||
allLearnersTeenagers: prompt.includes('ALL learners are teenagers'),
|
||||
teenAudienceSection: prompt.includes('TEEN AUDIENCE'),
|
||||
noSpouseQuestion: prompt.includes('با همسر خود یک فعالیت نشاطآور'),
|
||||
teenAdaptFriends: prompt.includes('friends, family, or classmates'),
|
||||
simpleLanguageGuidance: prompt.includes('explain it simply'),
|
||||
noChildishTone: prompt.includes('Do NOT be childish'),
|
||||
|
||||
// Teaching style — anti-over-questioning
|
||||
antiOverQuestioning: prompt.includes('NOT an interviewer'),
|
||||
forbiddenRepeatedQuestions: prompt.includes('نظرت چیست؟'),
|
||||
noTopicChoice: prompt.includes('Do NOT ask the user to choose the next topic'),
|
||||
noPermissionToContinue: prompt.includes('Do NOT ask permission to continue'),
|
||||
|
||||
// Teaching style — negative-answer handling
|
||||
negativeAnswerHandling: prompt.includes('NEGATIVE / UNKNOWN ANSWERS'),
|
||||
noInterrogateFurther: prompt.includes('Do NOT interrogate further'),
|
||||
unknownAnswerExample: prompt.includes('نمیدانم'),
|
||||
|
||||
// Teaching style — section-end questions
|
||||
sectionEndQuestion: prompt.includes('در مورد مطالبی که تا اینجا گفتیم، سؤالی دارید؟'),
|
||||
sectionEndNaturalBoundaries: prompt.includes('natural section boundaries'),
|
||||
|
||||
// Session 1 — no previous session
|
||||
session1NoPreviousSession: prompt.includes('NO previous educational session'),
|
||||
noReviewPreviousSession: prompt.includes('مرور جلسه قبل'),
|
||||
|
||||
// Teaching style — package identity & 11 sessions
|
||||
iranianIslamicPackage: prompt.includes('بسته آموزشی مثبتنگر ایرانی ـ اسلامی'),
|
||||
programOverviewSection: prompt.includes('PROGRAM OVERVIEW'),
|
||||
elevenSessionsListed: prompt.includes('1. توجیه و آشناسازی') && prompt.includes('11. مرور و جمعبندی مطالب'),
|
||||
|
||||
// Teaching style — session outline then teach one by one
|
||||
sessionOutlineSection: prompt.includes('SESSION OUTLINE'),
|
||||
listThenTeach: prompt.includes('list these at session start as a short roadmap'),
|
||||
|
||||
// Teaching style — no identity repetition
|
||||
noIdentityRepetition: prompt.includes('NO IDENTITY REPETITION'),
|
||||
noRepeatNovyaHelp: prompt.includes('اینجام تا کمک کنم'),
|
||||
|
||||
// Teaching style — stay on path / other model
|
||||
stayOnEducationalPath: prompt.includes('STAY ON THE EDUCATIONAL PATH'),
|
||||
otherModelRedirect: prompt.includes('another model/assistant'),
|
||||
|
||||
// Teaching style — package overview only in Session 1
|
||||
packageOverviewSession1Only: prompt.includes('Session 1 ONLY'),
|
||||
startCueSession1Package: COACH_SESSION_START_CUE.includes('list the ۱۱ session titles ONCE'),
|
||||
|
||||
// Teaching style — message formatting
|
||||
messageFormatting: prompt.includes('MESSAGE FORMATTING'),
|
||||
occasionalEmoji: prompt.includes('EMOJI / STICKER-LIKE EXPRESSIONS'),
|
||||
emojiHardLimit: prompt.includes('0–1 emoji per message'),
|
||||
numberedListFormatting: prompt.includes('numbered list'),
|
||||
boldKeyPhrases: prompt.includes('double asterisks'),
|
||||
headingOwnLine: prompt.includes('OWN separate line'),
|
||||
headingNumberedFirstTime: prompt.includes('FIRST time you introduce a section title'),
|
||||
plainTextHeadings: prompt.includes('NEVER use Markdown # / ## / ###'),
|
||||
noVisibleHash: prompt.includes('NEVER show # ## ###'),
|
||||
noVisibleAt: prompt.includes('NEVER show @ mentions'),
|
||||
standardPersianPunctuation: prompt.includes('STANDARD PERSIAN PUNCTUATION'),
|
||||
justifyFriendlyParagraphs: prompt.includes('justified'),
|
||||
neverGenerateImages: prompt.includes('NEVER generate, attach, draw, or request images'),
|
||||
formattingDoesNotChangeContent: prompt.includes('MUST NOT add, remove, or change educational content'),
|
||||
formattingDoesNotChangeOrder: prompt.includes('MUST NOT change the teaching order'),
|
||||
tablesOccasionalVariety: prompt.includes('TABLES (occasional variety)'),
|
||||
|
||||
// Teaching style — creative / lived elaboration
|
||||
creativeElaboration: prompt.includes('CREATIVE ELABORATION'),
|
||||
perfumeExample: prompt.includes('استفاده از عطر'),
|
||||
scientificColor: prompt.includes('simple scientific'),
|
||||
experientialThenQuote: prompt.includes('THEN present the course quote'),
|
||||
|
||||
// Session 11 final review (Session 11 prompt only)
|
||||
session11ThreePartReview: prompt11.includes('THREE-PART GRADUAL REVIEW'),
|
||||
session11NoDuplicatePhrases: prompt11.includes('NO DUPLICATE PHRASES'),
|
||||
session11PriorSessionsReference: prompt11.includes('PRIOR SESSIONS REFERENCE'),
|
||||
session11StartCueThreeParts: startCue11.includes('three parts'),
|
||||
session11StartCueNoDuplicate: startCue11.includes('NEVER duplicate the session title'),
|
||||
noRepetitionRule: prompt.includes('NO REPETITION (all sessions)'),
|
||||
|
||||
// Session start cue
|
||||
startCue: COACH_SESSION_START_CUE.includes('[SESSION_START]'),
|
||||
startCueEducationalPackage: COACH_SESSION_START_CUE.includes('introduce the overall educational package'),
|
||||
startCueNoPreviousSession: COACH_SESSION_START_CUE.includes('Do NOT review a previous session'),
|
||||
startCueGrounding: COACH_SESSION_START_CUE.includes('Do not invent missing details'),
|
||||
startCueNoAssessment: COACH_SESSION_START_CUE.includes('Do NOT administer or invent any psychological assessment'),
|
||||
startCueNoTopicChoice: COACH_SESSION_START_CUE.includes('Do not ask the user to choose topics'),
|
||||
startCueNoOverQuestion: COACH_SESSION_START_CUE.includes('Do not over-question'),
|
||||
startCueIranianIslamic: COACH_SESSION_START_CUE.includes('بسته آموزشی مثبتنگر ایرانی ـ اسلامی'),
|
||||
startCueLivedExamples: COACH_SESSION_START_CUE.includes('lived examples'),
|
||||
startCueStayOnPath: COACH_SESSION_START_CUE.includes('Stay on the educational path'),
|
||||
startCueNeverPretest: COACH_SESSION_START_CUE.includes('NEVER mention پیشآزمون'),
|
||||
startCueNoSpouse: COACH_SESSION_START_CUE.includes('NEVER ask about همسر'),
|
||||
|
||||
chars: prompt.length
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(checks, null, 2));
|
||||
|
||||
const failed = Object.entries(checks).filter(([k, v]) => k !== 'chars' && v !== true);
|
||||
if (failed.length) {
|
||||
console.error('Failed checks:', failed);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Session 1 coach prompt OK');
|
||||
console.log('Session 11 coach prompt OK');
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Offline verification: end-of-course flow (Session 11 → training done → post → finished).
|
||||
* Run: npx tsx --tsconfig tsconfig.json scripts/verify-end-to-end-course-flow.ts
|
||||
*
|
||||
* Does NOT call AI. Uses programService + journey permission rules + storage helpers.
|
||||
*/
|
||||
import { PROGRAM_SESSION_COUNT } from '../src/data/program/sessions';
|
||||
import {
|
||||
completeSession,
|
||||
getProgramProgress,
|
||||
isProgramCompleted,
|
||||
resetProgramProgress
|
||||
} from '../src/services/programService';
|
||||
import {
|
||||
getLatestByPhase,
|
||||
isPhaseCompleted,
|
||||
saveAssessment,
|
||||
type AssessmentSubmission
|
||||
} from '../src/services/assessmentService';
|
||||
import { PCQ24_QUESTIONS } from '../src/config/pcq24';
|
||||
|
||||
// Minimal localStorage for Node
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
get length() {
|
||||
return store.size;
|
||||
}
|
||||
};
|
||||
|
||||
type LearningProgress = {
|
||||
user_id: string;
|
||||
current_step: string;
|
||||
pre_test_completed_at: string | null;
|
||||
training_started_at: string | null;
|
||||
training_completed_at: string | null;
|
||||
post_test_completed_at: string | null;
|
||||
};
|
||||
|
||||
const journeyKey = (userId: string) => `learning-progress:${userId}`;
|
||||
|
||||
const readJourney = (userId: string): LearningProgress => {
|
||||
const raw = localStorage.getItem(journeyKey(userId));
|
||||
if (!raw) {
|
||||
return {
|
||||
user_id: userId,
|
||||
current_step: 'registered',
|
||||
pre_test_completed_at: null,
|
||||
training_started_at: null,
|
||||
training_completed_at: null,
|
||||
post_test_completed_at: null
|
||||
};
|
||||
}
|
||||
return JSON.parse(raw) as LearningProgress;
|
||||
};
|
||||
|
||||
const writeJourney = (progress: LearningProgress) => {
|
||||
localStorage.setItem(journeyKey(progress.user_id), JSON.stringify(progress));
|
||||
};
|
||||
|
||||
/** Mirrors useLearningJourney permissions */
|
||||
const permissionsOf = (progress: LearningProgress) => ({
|
||||
canTakePreTest: !progress.pre_test_completed_at,
|
||||
canOpenTraining: Boolean(progress.pre_test_completed_at),
|
||||
canTakePostTest: Boolean(
|
||||
progress.pre_test_completed_at &&
|
||||
progress.training_completed_at &&
|
||||
!progress.post_test_completed_at
|
||||
),
|
||||
isCompleted: Boolean(progress.post_test_completed_at)
|
||||
});
|
||||
|
||||
/** Mirrors HomeRedirect */
|
||||
const resolveRoot = (progress: LearningProgress): string => {
|
||||
if (!progress.pre_test_completed_at) return '/assessment/pre';
|
||||
if (!progress.training_completed_at) return '/chat';
|
||||
if (!progress.post_test_completed_at) return '/assessment/post';
|
||||
return '/finished';
|
||||
};
|
||||
|
||||
/** Mirrors ChatPage Session 11 completion side-effects (without React) */
|
||||
const applyFinalSessionCompletion = (userId: string, sessionOrder: number) => {
|
||||
const updated = completeSession(userId, sessionOrder);
|
||||
const isFinal =
|
||||
sessionOrder >= PROGRAM_SESSION_COUNT || Boolean(updated.completedAt);
|
||||
if (isFinal) {
|
||||
const now = new Date().toISOString();
|
||||
const base = readJourney(userId);
|
||||
if (!base.training_completed_at) {
|
||||
writeJourney({
|
||||
...base,
|
||||
training_started_at: base.training_started_at || now,
|
||||
training_completed_at: now
|
||||
});
|
||||
}
|
||||
}
|
||||
return { updated, isFinal };
|
||||
};
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const assert = (name: string, condition: boolean) => {
|
||||
if (condition) {
|
||||
passed += 1;
|
||||
console.log(`PASS: ${name}`);
|
||||
return;
|
||||
}
|
||||
failed += 1;
|
||||
console.error(`FAIL: ${name}`);
|
||||
};
|
||||
|
||||
const USER = 'e2e-flow-user';
|
||||
store.clear();
|
||||
resetProgramProgress(USER);
|
||||
writeJourney({
|
||||
user_id: USER,
|
||||
current_step: 'pre_test_done',
|
||||
pre_test_completed_at: new Date().toISOString(),
|
||||
training_started_at: new Date().toISOString(),
|
||||
training_completed_at: null,
|
||||
post_test_completed_at: null
|
||||
});
|
||||
|
||||
// --- Root routing before training complete ---
|
||||
assert('root → /chat when training incomplete', resolveRoot(readJourney(USER)) === '/chat');
|
||||
assert(
|
||||
'post locked before training_completed_at',
|
||||
permissionsOf(readJourney(USER)).canTakePostTest === false
|
||||
);
|
||||
|
||||
// --- Complete sessions 1..10 (sequential), then 11 ---
|
||||
for (let order = 1; order <= 10; order++) {
|
||||
completeSession(USER, order);
|
||||
}
|
||||
assert('after 10 sessions program not complete', isProgramCompleted(USER) === false);
|
||||
assert(
|
||||
'training still incomplete after 10',
|
||||
readJourney(USER).training_completed_at === null
|
||||
);
|
||||
|
||||
const final = applyFinalSessionCompletion(USER, 11);
|
||||
assert('session 11 marks program completedAt', Boolean(final.updated.completedAt));
|
||||
assert('session 11 isFinal', final.isFinal === true);
|
||||
assert('training_completed_at set after session 11', Boolean(readJourney(USER).training_completed_at));
|
||||
assert(
|
||||
'post unlocked after training_completed_at',
|
||||
permissionsOf(readJourney(USER)).canTakePostTest === true
|
||||
);
|
||||
assert('root → /assessment/post after training', resolveRoot(readJourney(USER)) === '/assessment/post');
|
||||
assert(
|
||||
'all 11 sessions listed completed',
|
||||
getProgramProgress(USER).completedSessions.length === PROGRAM_SESSION_COUNT
|
||||
);
|
||||
|
||||
// --- Refresh persistence (re-read storage) ---
|
||||
const journeyAfterReload = readJourney(USER);
|
||||
const programAfterReload = getProgramProgress(USER);
|
||||
assert('persist training_completed_at', Boolean(journeyAfterReload.training_completed_at));
|
||||
assert('persist program completedAt', Boolean(programAfterReload.completedAt));
|
||||
assert(
|
||||
'persist post unlock',
|
||||
permissionsOf(journeyAfterReload).canTakePostTest === true
|
||||
);
|
||||
|
||||
// --- Post-test submission (scoring storage) + journey mark ---
|
||||
const answers = PCQ24_QUESTIONS.map((q) => ({
|
||||
questionId: q.id,
|
||||
selectedValue: '4'
|
||||
}));
|
||||
await saveAssessment({ userId: USER, phase: 'post', answers });
|
||||
assert('post assessment stored', isPhaseCompleted(USER, 'post') === true);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
writeJourney({
|
||||
...readJourney(USER),
|
||||
current_step: 'completed',
|
||||
post_test_completed_at: now
|
||||
});
|
||||
assert('root → /finished after post', resolveRoot(readJourney(USER)) === '/finished');
|
||||
assert('permissions.isCompleted', permissionsOf(readJourney(USER)).isCompleted === true);
|
||||
assert('canTakePostTest false after post', permissionsOf(readJourney(USER)).canTakePostTest === false);
|
||||
|
||||
// --- Duplicate post guard (existing storage + journey) ---
|
||||
const beforeDup = getLatestByPhase(USER, 'post') as AssessmentSubmission;
|
||||
assert('existing post submission present', beforeDup !== null);
|
||||
assert(
|
||||
'duplicate blocked by journey permission',
|
||||
permissionsOf(readJourney(USER)).canTakePostTest === false
|
||||
);
|
||||
assert(
|
||||
'duplicate blocked by isPhaseCompleted',
|
||||
isPhaseCompleted(USER, 'post') === true
|
||||
);
|
||||
|
||||
// --- Finished refresh ---
|
||||
assert('finished still available after reload', resolveRoot(readJourney(USER)) === '/finished');
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user