tessera-learn 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugin/cli.d.ts +1 -0
- package/dist/plugin/cli.js +18 -0
- package/dist/plugin/cli.js.map +1 -0
- package/dist/plugin/index.js +2 -727
- package/dist/plugin/index.js.map +1 -1
- package/dist/validation-B4UhCY5y.js +911 -0
- package/dist/validation-B4UhCY5y.js.map +1 -0
- package/package.json +4 -2
- package/src/plugin/cli.ts +30 -0
- package/src/plugin/validation.ts +336 -62
- package/src/runtime/adapters/index.ts +1 -1
- package/src/runtime/hooks.svelte.ts +22 -1
- package/AGENTS.md +0 -1376
package/src/plugin/validation.ts
CHANGED
|
@@ -64,7 +64,15 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
64
64
|
errors.push(...pageResults.errors);
|
|
65
65
|
warnings.push(...pageResults.warnings);
|
|
66
66
|
|
|
67
|
-
// 4.
|
|
67
|
+
// 4. Contract-bypass checks on project-root shell files
|
|
68
|
+
for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
|
|
69
|
+
const shellPath = resolve(projectRoot, shellFile);
|
|
70
|
+
if (existsSync(shellPath)) {
|
|
71
|
+
validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 5. Cross-cutting validations
|
|
68
76
|
if (config) {
|
|
69
77
|
crossValidate(config, pageResults, errors, warnings);
|
|
70
78
|
}
|
|
@@ -498,6 +506,57 @@ interface PagesValidationResult extends ValidationResult {
|
|
|
498
506
|
pages: PageInfo[];
|
|
499
507
|
}
|
|
500
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Validate a single page .svelte file. Used for both section-level (flat) and
|
|
511
|
+
* lesson-level pages — the validation is identical, only the containing
|
|
512
|
+
* directory differs.
|
|
513
|
+
*/
|
|
514
|
+
function validatePageFile(
|
|
515
|
+
filePath: string,
|
|
516
|
+
projectRoot: string,
|
|
517
|
+
assetsDir: string,
|
|
518
|
+
navIndex: number,
|
|
519
|
+
errors: string[],
|
|
520
|
+
warnings: string[],
|
|
521
|
+
assetExistsCache: Map<string, boolean>
|
|
522
|
+
): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
|
|
523
|
+
const fileRel = relative(projectRoot, filePath);
|
|
524
|
+
const content = readSourceFileCached(filePath);
|
|
525
|
+
|
|
526
|
+
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
527
|
+
|
|
528
|
+
const isQuiz = !!pageConfig?.quiz;
|
|
529
|
+
let isGradedQuiz = false;
|
|
530
|
+
if (pageConfig?.quiz) {
|
|
531
|
+
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
532
|
+
if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
|
|
533
|
+
isGradedQuiz = true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
538
|
+
|
|
539
|
+
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
540
|
+
validateQuestionComponents(content, fileRel, errors);
|
|
541
|
+
validateContractBypass(content, fileRel, errors);
|
|
542
|
+
if (
|
|
543
|
+
pageConfig?.quiz &&
|
|
544
|
+
!HAS_USE_QUESTION_RE.test(content) &&
|
|
545
|
+
!HAS_QUESTION_TAG_RE.test(content)
|
|
546
|
+
) {
|
|
547
|
+
warnings.push(
|
|
548
|
+
`${fileRel}: quiz page has no question components or useQuestion() calls — ` +
|
|
549
|
+
`the quiz will have nothing to score`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
page: { fileRel, navIndex, hasGradedQuiz: isGradedQuiz, hasQuiz: isQuiz, completesOnView },
|
|
555
|
+
isQuiz,
|
|
556
|
+
isGradedQuiz,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
501
560
|
function validatePages(
|
|
502
561
|
pagesDir: string,
|
|
503
562
|
assetsDir: string,
|
|
@@ -581,34 +640,19 @@ function validatePages(
|
|
|
581
640
|
}
|
|
582
641
|
|
|
583
642
|
for (const fileName of sectionSvelteFiles) {
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
643
|
+
const result = validatePageFile(
|
|
644
|
+
resolve(sectionPath, fileName),
|
|
645
|
+
projectRoot,
|
|
646
|
+
assetsDir,
|
|
647
|
+
totalPages,
|
|
648
|
+
errors,
|
|
649
|
+
warnings,
|
|
650
|
+
assetExistsCache
|
|
651
|
+
);
|
|
590
652
|
totalPages++;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
totalQuizzes++;
|
|
595
|
-
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
596
|
-
if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
|
|
597
|
-
hasGradedQuiz = true;
|
|
598
|
-
pageHasGradedQuiz = true;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
603
|
-
pages.push({
|
|
604
|
-
fileRel,
|
|
605
|
-
navIndex,
|
|
606
|
-
hasGradedQuiz: pageHasGradedQuiz,
|
|
607
|
-
hasQuiz: !!pageConfig?.quiz,
|
|
608
|
-
completesOnView,
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
653
|
+
if (result.isQuiz) totalQuizzes++;
|
|
654
|
+
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
655
|
+
pages.push(result.page);
|
|
612
656
|
}
|
|
613
657
|
|
|
614
658
|
// Get lesson directories
|
|
@@ -663,38 +707,19 @@ function validatePages(
|
|
|
663
707
|
|
|
664
708
|
// Validate each .svelte file
|
|
665
709
|
for (const fileName of svelteFiles) {
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
710
|
+
const result = validatePageFile(
|
|
711
|
+
resolve(lessonPath, fileName),
|
|
712
|
+
projectRoot,
|
|
713
|
+
assetsDir,
|
|
714
|
+
totalPages,
|
|
715
|
+
errors,
|
|
716
|
+
warnings,
|
|
717
|
+
assetExistsCache
|
|
718
|
+
);
|
|
672
719
|
totalPages++;
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
totalQuizzes++;
|
|
677
|
-
|
|
678
|
-
// Validate quiz config
|
|
679
|
-
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
680
|
-
|
|
681
|
-
if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
|
|
682
|
-
hasGradedQuiz = true;
|
|
683
|
-
pageHasGradedQuiz = true;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
688
|
-
pages.push({
|
|
689
|
-
fileRel,
|
|
690
|
-
navIndex,
|
|
691
|
-
hasGradedQuiz: pageHasGradedQuiz,
|
|
692
|
-
hasQuiz: !!pageConfig?.quiz,
|
|
693
|
-
completesOnView,
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
// Check $assets references
|
|
697
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
720
|
+
if (result.isQuiz) totalQuizzes++;
|
|
721
|
+
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
722
|
+
pages.push(result.page);
|
|
698
723
|
}
|
|
699
724
|
}
|
|
700
725
|
}
|
|
@@ -774,7 +799,7 @@ function validateCompletesOn(
|
|
|
774
799
|
|
|
775
800
|
function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
|
|
776
801
|
if (!quiz || typeof quiz !== 'object') return;
|
|
777
|
-
const cfg = quiz as
|
|
802
|
+
const cfg = quiz as Record<string, unknown>;
|
|
778
803
|
|
|
779
804
|
if (cfg.maxAttempts !== undefined) {
|
|
780
805
|
const val = cfg.maxAttempts;
|
|
@@ -785,9 +810,258 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
|
|
|
785
810
|
}
|
|
786
811
|
}
|
|
787
812
|
|
|
788
|
-
|
|
813
|
+
for (const field of ['graded', 'gatesProgress', 'showFeedback']) {
|
|
814
|
+
if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
|
|
815
|
+
errors.push(
|
|
816
|
+
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ---------- Question Component Validation ----------
|
|
823
|
+
|
|
824
|
+
const QUESTION_COMPONENT_REQUIRED: Record<string, string[]> = {
|
|
825
|
+
MultipleChoice: ['question', 'options', 'correct'],
|
|
826
|
+
FillInTheBlank: ['question', 'answers'],
|
|
827
|
+
Matching: ['question', 'pairs'],
|
|
828
|
+
Sorting: ['question', 'items', 'targets', 'correct'],
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
type PropValue =
|
|
832
|
+
| { kind: 'string'; value: string }
|
|
833
|
+
| { kind: 'expr'; raw: string }
|
|
834
|
+
| { kind: 'bool' };
|
|
835
|
+
|
|
836
|
+
/** Extract a balanced {...} or [...] span starting at startIndex, or null. */
|
|
837
|
+
function extractBalanced(source: string, startIndex: number): string | null {
|
|
838
|
+
const open = source[startIndex];
|
|
839
|
+
if (open !== '{' && open !== '[') return null;
|
|
840
|
+
let depth = 0;
|
|
841
|
+
let inString: string | null = null;
|
|
842
|
+
let escaped = false;
|
|
843
|
+
for (let i = startIndex; i < source.length; i++) {
|
|
844
|
+
const char = source[i];
|
|
845
|
+
if (escaped) {
|
|
846
|
+
escaped = false;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (char === '\\' && inString) {
|
|
850
|
+
escaped = true;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (inString) {
|
|
854
|
+
if (char === inString) inString = null;
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
858
|
+
inString = char;
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
if (char === '{' || char === '[') depth++;
|
|
862
|
+
if (char === '}' || char === ']') {
|
|
863
|
+
depth--;
|
|
864
|
+
if (depth === 0) return source.slice(startIndex, i + 1);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Parse the props of an opening tag starting just after the component name.
|
|
872
|
+
* Returns null if the tag can't be parsed cleanly — callers then skip it
|
|
873
|
+
* rather than risk a false positive.
|
|
874
|
+
*/
|
|
875
|
+
function parseTagProps(content: string, start: number): Map<string, PropValue> | null {
|
|
876
|
+
const props = new Map<string, PropValue>();
|
|
877
|
+
let i = start;
|
|
878
|
+
while (i < content.length) {
|
|
879
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
880
|
+
if (i >= content.length) return null;
|
|
881
|
+
const c = content[i];
|
|
882
|
+
if (c === '>') return props;
|
|
883
|
+
if (c === '/' && content[i + 1] === '>') return props;
|
|
884
|
+
// Spread / shorthand expression — skip the whole {...} block.
|
|
885
|
+
if (c === '{') {
|
|
886
|
+
const block = extractBalanced(content, i);
|
|
887
|
+
if (!block) return null;
|
|
888
|
+
i += block.length;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const nameMatch = /^[A-Za-z_][\w-]*/.exec(content.slice(i));
|
|
892
|
+
if (!nameMatch) return null;
|
|
893
|
+
const propName = nameMatch[0];
|
|
894
|
+
i += propName.length;
|
|
895
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
896
|
+
if (content[i] !== '=') {
|
|
897
|
+
props.set(propName, { kind: 'bool' });
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
i++;
|
|
901
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
902
|
+
const v = content[i];
|
|
903
|
+
if (v === '"' || v === "'") {
|
|
904
|
+
const end = content.indexOf(v, i + 1);
|
|
905
|
+
if (end === -1) return null;
|
|
906
|
+
props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
|
|
907
|
+
i = end + 1;
|
|
908
|
+
} else if (v === '{') {
|
|
909
|
+
const block = extractBalanced(content, i);
|
|
910
|
+
if (!block) return null;
|
|
911
|
+
props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
|
|
912
|
+
i += block.length;
|
|
913
|
+
} else {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function staticArray(prop: PropValue | undefined): unknown[] | null {
|
|
921
|
+
if (prop?.kind !== 'expr' || !prop.raw.startsWith('[')) return null;
|
|
922
|
+
try {
|
|
923
|
+
const parsed = JSON5.parse(prop.raw);
|
|
924
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function staticNumber(prop: PropValue | undefined): number | null {
|
|
931
|
+
if (prop?.kind !== 'expr') return null;
|
|
932
|
+
try {
|
|
933
|
+
const parsed = JSON5.parse(prop.raw);
|
|
934
|
+
return typeof parsed === 'number' ? parsed : null;
|
|
935
|
+
} catch {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function validateQuestionComponents(
|
|
941
|
+
content: string,
|
|
942
|
+
fileRel: string,
|
|
943
|
+
errors: string[]
|
|
944
|
+
): void {
|
|
945
|
+
const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
|
|
946
|
+
const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
|
|
947
|
+
const seenIds = new Set<string>();
|
|
948
|
+
let m: RegExpExecArray | null;
|
|
949
|
+
while ((m = tagStartRe.exec(content)) !== null) {
|
|
950
|
+
const name = m[1];
|
|
951
|
+
const props = parseTagProps(content, m.index + m[0].length);
|
|
952
|
+
if (!props) continue;
|
|
953
|
+
|
|
954
|
+
for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
|
|
955
|
+
if (!props.has(req)) {
|
|
956
|
+
errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const idProp = props.get('id');
|
|
961
|
+
if (idProp?.kind === 'string') {
|
|
962
|
+
if (seenIds.has(idProp.value)) {
|
|
963
|
+
errors.push(
|
|
964
|
+
`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
seenIds.add(idProp.value);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (name === 'MultipleChoice') {
|
|
971
|
+
const options = staticArray(props.get('options'));
|
|
972
|
+
const correct = staticNumber(props.get('correct'));
|
|
973
|
+
if (options && correct !== null) {
|
|
974
|
+
if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) {
|
|
975
|
+
errors.push(
|
|
976
|
+
`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
} else if (name === 'Sorting') {
|
|
981
|
+
const items = staticArray(props.get('items'));
|
|
982
|
+
const targets = staticArray(props.get('targets'));
|
|
983
|
+
const correct = staticArray(props.get('correct'));
|
|
984
|
+
if (items && correct && correct.length !== items.length) {
|
|
985
|
+
errors.push(
|
|
986
|
+
`${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
if (targets && correct) {
|
|
990
|
+
for (const idx of correct) {
|
|
991
|
+
if (
|
|
992
|
+
typeof idx !== 'number' ||
|
|
993
|
+
!Number.isInteger(idx) ||
|
|
994
|
+
idx < 0 ||
|
|
995
|
+
idx >= targets.length
|
|
996
|
+
) {
|
|
997
|
+
errors.push(
|
|
998
|
+
`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`
|
|
999
|
+
);
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
} else if (name === 'Matching') {
|
|
1005
|
+
const pairs = staticArray(props.get('pairs'));
|
|
1006
|
+
if (pairs) {
|
|
1007
|
+
const bad = pairs.some(
|
|
1008
|
+
(p) =>
|
|
1009
|
+
typeof p !== 'object' ||
|
|
1010
|
+
p === null ||
|
|
1011
|
+
typeof (p as { left?: unknown }).left !== 'string' ||
|
|
1012
|
+
typeof (p as { right?: unknown }).right !== 'string'
|
|
1013
|
+
);
|
|
1014
|
+
if (bad) {
|
|
1015
|
+
errors.push(
|
|
1016
|
+
`${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
} else if (name === 'FillInTheBlank') {
|
|
1021
|
+
const answers = staticArray(props.get('answers'));
|
|
1022
|
+
if (answers) {
|
|
1023
|
+
if (answers.length === 0) {
|
|
1024
|
+
errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
|
|
1025
|
+
} else if (answers.some((a) => typeof a !== 'string')) {
|
|
1026
|
+
errors.push(
|
|
1027
|
+
`${fileRel}: <FillInTheBlank> answers must be an array of strings`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ---------- Contract Bypass Detection ----------
|
|
1036
|
+
|
|
1037
|
+
const QUIZ_COMPLETE_DISPATCH_RE =
|
|
1038
|
+
/(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-complete['"]|dispatchEvent\s*\([\s\S]{0,120}tessera-quiz-complete)/;
|
|
1039
|
+
const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
|
|
1040
|
+
const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
1041
|
+
const HAS_QUESTION_TAG_RE = new RegExp(
|
|
1042
|
+
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Detect ways an author file can bypass the LMS data contract. These check
|
|
1047
|
+
* source text for known escape hatches — they never inspect course content,
|
|
1048
|
+
* so they constrain how you wire things up, not what you build.
|
|
1049
|
+
*/
|
|
1050
|
+
function validateContractBypass(
|
|
1051
|
+
content: string,
|
|
1052
|
+
fileRel: string,
|
|
1053
|
+
errors: string[]
|
|
1054
|
+
): void {
|
|
1055
|
+
if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
|
|
1056
|
+
errors.push(
|
|
1057
|
+
`${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
|
|
1058
|
+
`useQuiz().submit() so the result reaches the LMS`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
|
|
789
1062
|
errors.push(
|
|
790
|
-
`${fileRel}:
|
|
1063
|
+
`${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
|
|
1064
|
+
`(useQuiz, useQuestion, useNavigation, …) instead`
|
|
791
1065
|
);
|
|
792
1066
|
}
|
|
793
1067
|
}
|
|
@@ -36,7 +36,7 @@ function missingApiError(
|
|
|
36
36
|
standard,
|
|
37
37
|
`Tessera: this course is configured for ${label} but ${detail} ` +
|
|
38
38
|
`The course must be launched from an LMS that provides the ${label} runtime. ` +
|
|
39
|
-
`If you are testing locally, run \`npm run
|
|
39
|
+
`If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getContext, setContext, onDestroy } from 'svelte';
|
|
1
|
+
import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
|
|
2
2
|
import type { Interaction } from './interaction.js';
|
|
3
3
|
import { isCorrect as isCorrectInteraction } from './interaction.js';
|
|
4
4
|
import {
|
|
@@ -311,6 +311,20 @@ export function __warnUnsubmittedQuiz(stats: {
|
|
|
311
311
|
);
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Dev warning helper for a quiz host that mounts with no questions registered
|
|
316
|
+
* through useQuestion(). Such a page has a quiz wrapper but nothing the runtime
|
|
317
|
+
* can score or report to the LMS. Exported so tests can exercise the warning
|
|
318
|
+
* without depending on jsdom mount timing under vitest.
|
|
319
|
+
*/
|
|
320
|
+
export function __warnEmptyQuiz(questionsCount: number): void {
|
|
321
|
+
if (questionsCount > 0) return;
|
|
322
|
+
console.warn(
|
|
323
|
+
'[tessera] useQuiz: quiz mounted with no registered questions. Question widgets ' +
|
|
324
|
+
'must call useQuestion() to be scored and reported to the LMS.'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
314
328
|
/**
|
|
315
329
|
* Programmatic quiz orchestration for custom quiz shells. Returns a handle
|
|
316
330
|
* exposing the same state machine `<Quiz>` runs internally — register
|
|
@@ -585,6 +599,13 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
|
|
|
585
599
|
get isLockedCorrect() { return (i: number) => lockedCorrect.has(i); },
|
|
586
600
|
});
|
|
587
601
|
|
|
602
|
+
onMount(() => {
|
|
603
|
+
if (!import.meta.env?.DEV) return;
|
|
604
|
+
// Questions register synchronously as child widgets initialise; a tick()
|
|
605
|
+
// also covers any effect-driven registration before we check.
|
|
606
|
+
void tick().then(() => __warnEmptyQuiz(questions.length));
|
|
607
|
+
});
|
|
608
|
+
|
|
588
609
|
onDestroy(() => {
|
|
589
610
|
__warnUnsubmittedQuiz({
|
|
590
611
|
questionsCount: questions.length,
|