feat: refactor AttemptResultComponent to calculate percentage based on question statistics instead of attempt scores. Update integration tests to validate maxScore calculation across multiple sections. Ensure proper type handling for question counts in attempt router, enhancing data integrity and performance.
Browse files
apps/web/src/routes/attempt.$id.tsx
CHANGED
|
@@ -60,10 +60,6 @@ function AttemptResultComponent() {
|
|
| 60 |
onSuccess: () => packageRatingQuery.refetch(),
|
| 61 |
});
|
| 62 |
|
| 63 |
-
const percentage = attempt?.maxScore && attempt.maxScore > 0
|
| 64 |
-
? Math.round(((attempt.totalScore ?? 0) / attempt.maxScore) * 100)
|
| 65 |
-
: 0;
|
| 66 |
-
|
| 67 |
const durationSec = attempt?.finishedAt && attempt.startedAt
|
| 68 |
? Math.round((new Date(attempt.finishedAt).getTime() - new Date(attempt.startedAt).getTime()) / 1000)
|
| 69 |
: 0;
|
|
@@ -87,6 +83,17 @@ function AttemptResultComponent() {
|
|
| 87 |
);
|
| 88 |
}, [attempt]);
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
// ── Filtered questions ──
|
| 91 |
const partialCount = useMemo(
|
| 92 |
() => allQuestions.filter(({ ans }) => ans?.partialScore != null && ans?.partialScore < 100).length,
|
|
@@ -186,7 +193,7 @@ function AttemptResultComponent() {
|
|
| 186 |
{percentage}%
|
| 187 |
</span>
|
| 188 |
<span className="text-xs text-[var(--matcha-700)] font-medium">
|
| 189 |
-
{
|
| 190 |
</span>
|
| 191 |
</div>
|
| 192 |
|
|
@@ -207,15 +214,15 @@ function AttemptResultComponent() {
|
|
| 207 |
</span>
|
| 208 |
<span className="flex items-center gap-1">
|
| 209 |
<MaterialIcon name="quiz" className="text-sm" />
|
| 210 |
-
{
|
| 211 |
</span>
|
| 212 |
<span className="flex items-center gap-1">
|
| 213 |
<MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
|
| 214 |
-
{
|
| 215 |
</span>
|
| 216 |
<span className="flex items-center gap-1">
|
| 217 |
<MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
|
| 218 |
-
{
|
| 219 |
</span>
|
| 220 |
</div>
|
| 221 |
|
|
|
|
| 60 |
onSuccess: () => packageRatingQuery.refetch(),
|
| 61 |
});
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
const durationSec = attempt?.finishedAt && attempt.startedAt
|
| 64 |
? Math.round((new Date(attempt.finishedAt).getTime() - new Date(attempt.startedAt).getTime()) / 1000)
|
| 65 |
: 0;
|
|
|
|
| 83 |
);
|
| 84 |
}, [attempt]);
|
| 85 |
|
| 86 |
+
const questionStats = useMemo(() => {
|
| 87 |
+
const total = allQuestions.length;
|
| 88 |
+
const correct = allQuestions.filter(({ ans }) => ans?.isCorrect === true).length;
|
| 89 |
+
const wrong = allQuestions.filter(({ ans }) => ans?.isCorrect === false).length;
|
| 90 |
+
return { total, correct, wrong };
|
| 91 |
+
}, [allQuestions]);
|
| 92 |
+
|
| 93 |
+
const percentage = questionStats.total > 0
|
| 94 |
+
? Math.round((questionStats.correct / questionStats.total) * 100)
|
| 95 |
+
: 0;
|
| 96 |
+
|
| 97 |
// ── Filtered questions ──
|
| 98 |
const partialCount = useMemo(
|
| 99 |
() => allQuestions.filter(({ ans }) => ans?.partialScore != null && ans?.partialScore < 100).length,
|
|
|
|
| 193 |
{percentage}%
|
| 194 |
</span>
|
| 195 |
<span className="text-xs text-[var(--matcha-700)] font-medium">
|
| 196 |
+
{questionStats.correct}/{questionStats.total}
|
| 197 |
</span>
|
| 198 |
</div>
|
| 199 |
|
|
|
|
| 214 |
</span>
|
| 215 |
<span className="flex items-center gap-1">
|
| 216 |
<MaterialIcon name="quiz" className="text-sm" />
|
| 217 |
+
{questionStats.total} soal
|
| 218 |
</span>
|
| 219 |
<span className="flex items-center gap-1">
|
| 220 |
<MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
|
| 221 |
+
{questionStats.correct} benar
|
| 222 |
</span>
|
| 223 |
<span className="flex items-center gap-1">
|
| 224 |
<MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
|
| 225 |
+
{questionStats.wrong} salah
|
| 226 |
</span>
|
| 227 |
</div>
|
| 228 |
|
packages/api/src/__tests__/attempt.integration.test.ts
CHANGED
|
@@ -175,6 +175,85 @@ describe("attempt router", () => {
|
|
| 175 |
expect(result.percentage).toBe(33);
|
| 176 |
});
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
it("finish rejects attempts under 5 seconds", async () => {
|
| 179 |
const { caller } = await createUserAndCaller("fastfinish");
|
| 180 |
const { attemptId } = await caller.start({ packageId });
|
|
|
|
| 175 |
expect(result.percentage).toBe(33);
|
| 176 |
});
|
| 177 |
|
| 178 |
+
it("finish sums maxScore across multiple sections", { timeout: 30000 }, async () => {
|
| 179 |
+
const ownerId = "multi-section-owner";
|
| 180 |
+
await testDb.insert(schema.user).values({
|
| 181 |
+
id: ownerId,
|
| 182 |
+
name: "Multi Section Owner",
|
| 183 |
+
email: `multi-section-${Date.now()}@test.com`,
|
| 184 |
+
}).onConflictDoNothing();
|
| 185 |
+
|
| 186 |
+
const [pkg] = await testDb.insert(schema.testPackage).values({
|
| 187 |
+
title: "Multi Section Test",
|
| 188 |
+
examTypeId: "IELTS",
|
| 189 |
+
creatorUserId: ownerId,
|
| 190 |
+
isPublic: true,
|
| 191 |
+
}).returning();
|
| 192 |
+
|
| 193 |
+
const sections = await testDb.insert(schema.packageSection).values([
|
| 194 |
+
{
|
| 195 |
+
packageId: pkg.id,
|
| 196 |
+
sectionTypeId: "READING",
|
| 197 |
+
title: "Reading Part 1",
|
| 198 |
+
orderIndex: 0,
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
packageId: pkg.id,
|
| 202 |
+
sectionTypeId: "READING",
|
| 203 |
+
title: "Reading Part 2",
|
| 204 |
+
orderIndex: 1,
|
| 205 |
+
},
|
| 206 |
+
]).returning();
|
| 207 |
+
|
| 208 |
+
const makeQuestion = (idx: number) => ({
|
| 209 |
+
examTypeId: "IELTS",
|
| 210 |
+
sectionTypeId: "READING",
|
| 211 |
+
format: "multiple_choice" as const,
|
| 212 |
+
passageText: `Passage ${idx} `.repeat(10),
|
| 213 |
+
questionText: `Question ${idx}?`,
|
| 214 |
+
options: [
|
| 215 |
+
{ key: "A", text: "Option A" },
|
| 216 |
+
{ key: "B", text: "Option B" },
|
| 217 |
+
{ key: "C", text: "Option C" },
|
| 218 |
+
{ key: "D", text: "Option D" },
|
| 219 |
+
],
|
| 220 |
+
correctAnswer: "A",
|
| 221 |
+
explanation: "Because A is correct",
|
| 222 |
+
difficulty: 2,
|
| 223 |
+
skillTags: ["main_idea"],
|
| 224 |
+
creatorUserId: ownerId,
|
| 225 |
+
isPublic: true,
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
const sectionOneQuestions = await testDb.insert(schema.question).values(
|
| 229 |
+
Array.from({ length: 10 }, (_, idx) => makeQuestion(idx + 1)),
|
| 230 |
+
).returning();
|
| 231 |
+
const sectionTwoQuestions = await testDb.insert(schema.question).values(
|
| 232 |
+
Array.from({ length: 10 }, (_, idx) => makeQuestion(idx + 11)),
|
| 233 |
+
).returning();
|
| 234 |
+
|
| 235 |
+
await testDb.insert(schema.sectionQuestion).values([
|
| 236 |
+
...sectionOneQuestions.map((q, orderIndex) => ({
|
| 237 |
+
sectionId: sections[0]!.id,
|
| 238 |
+
questionId: q.id,
|
| 239 |
+
orderIndex,
|
| 240 |
+
})),
|
| 241 |
+
...sectionTwoQuestions.map((q, orderIndex) => ({
|
| 242 |
+
sectionId: sections[1]!.id,
|
| 243 |
+
questionId: q.id,
|
| 244 |
+
orderIndex,
|
| 245 |
+
})),
|
| 246 |
+
]);
|
| 247 |
+
|
| 248 |
+
const { caller } = await createUserAndCaller("multisection");
|
| 249 |
+
const { attemptId } = await caller.start({ packageId: pkg.id });
|
| 250 |
+
await Bun.sleep(4500);
|
| 251 |
+
|
| 252 |
+
const result = await caller.finish({ attemptId });
|
| 253 |
+
expect(result.maxScore).toBe(20);
|
| 254 |
+
expect(typeof result.maxScore).toBe("number");
|
| 255 |
+
});
|
| 256 |
+
|
| 257 |
it("finish rejects attempts under 5 seconds", async () => {
|
| 258 |
const { caller } = await createUserAndCaller("fastfinish");
|
| 259 |
const { attemptId } = await caller.start({ packageId });
|
packages/api/src/routers/attempt.ts
CHANGED
|
@@ -473,14 +473,14 @@ export const attemptRouter = router({
|
|
| 473 |
const counts = await db
|
| 474 |
.select({
|
| 475 |
sectionId: sectionQuestion.sectionId,
|
| 476 |
-
count: sql<number>`count(*)`,
|
| 477 |
})
|
| 478 |
.from(sectionQuestion)
|
| 479 |
.where(inArray(sectionQuestion.sectionId, psIds))
|
| 480 |
.groupBy(sectionQuestion.sectionId);
|
| 481 |
|
| 482 |
for (const c of counts) {
|
| 483 |
-
questionCounts.set(c.sectionId, c.count);
|
| 484 |
}
|
| 485 |
}
|
| 486 |
|
|
@@ -528,7 +528,7 @@ export const attemptRouter = router({
|
|
| 528 |
.where(eq(answer.id, a.answerId));
|
| 529 |
}
|
| 530 |
|
| 531 |
-
const sectionMax = questionCounts.get(pkgSec.id) ?? secAnswers.length;
|
| 532 |
const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0);
|
| 533 |
|
| 534 |
await db
|
|
@@ -554,7 +554,7 @@ export const attemptRouter = router({
|
|
| 554 |
// Total questions in package
|
| 555 |
let totalQuestions = 0;
|
| 556 |
for (const count of questionCounts.values()) {
|
| 557 |
-
totalQuestions += count;
|
| 558 |
}
|
| 559 |
|
| 560 |
await db
|
|
|
|
| 473 |
const counts = await db
|
| 474 |
.select({
|
| 475 |
sectionId: sectionQuestion.sectionId,
|
| 476 |
+
count: sql<number>`count(*)::int`,
|
| 477 |
})
|
| 478 |
.from(sectionQuestion)
|
| 479 |
.where(inArray(sectionQuestion.sectionId, psIds))
|
| 480 |
.groupBy(sectionQuestion.sectionId);
|
| 481 |
|
| 482 |
for (const c of counts) {
|
| 483 |
+
questionCounts.set(c.sectionId, Number(c.count));
|
| 484 |
}
|
| 485 |
}
|
| 486 |
|
|
|
|
| 528 |
.where(eq(answer.id, a.answerId));
|
| 529 |
}
|
| 530 |
|
| 531 |
+
const sectionMax = Number(questionCounts.get(pkgSec.id) ?? secAnswers.length);
|
| 532 |
const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0);
|
| 533 |
|
| 534 |
await db
|
|
|
|
| 554 |
// Total questions in package
|
| 555 |
let totalQuestions = 0;
|
| 556 |
for (const count of questionCounts.values()) {
|
| 557 |
+
totalQuestions += Number(count);
|
| 558 |
}
|
| 559 |
|
| 560 |
await db
|