Skip to content

Commit 41c81cc

Browse files
refactor: code running panel (#64)
inti
1 parent 1bce62e commit 41c81cc

11 files changed

+222
-113
lines changed

src/components/game-screen/CodeRunning.tsx

+5-100
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,12 @@
1-
import type { ReactNode } from "react"
21
import React from "react"
3-
import { CheckCircle2, Loader2, MinusCircleIcon, XCircle } from "lucide-react"
42

5-
import type { PlayerGameSession } from "~/lib/games/types"
6-
import { type ProgrammingQuestionWithTestCases } from "~/lib/games/types"
3+
import type { ClientQuestionStrategy } from "~/lib/games/question-types/base"
74
import { api } from "~/lib/trpc/react"
85
import { Skeleton } from "../ui/skeleton"
96
import { useGameManager } from "./GameManagerProvider"
107

11-
function QuestionTestCaseResults(props: {
12-
question: ProgrammingQuestionWithTestCases
13-
testState: PlayerGameSession["testState"]
14-
}) {
15-
const testResultByTestCaseId = Object.fromEntries(
16-
props.testState?.programmingResults.map((result) => [
17-
`${result.programming_question_test_case_id}`,
18-
result,
19-
]) ?? [],
20-
)
21-
22-
const correctTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
23-
const result = testResultByTestCaseId[tc.id]
24-
return result?.status === "success" && result.is_correct
25-
})
26-
27-
const incorrectTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
28-
const result = testResultByTestCaseId[tc.id]
29-
return result && (result.status === "error" || !result.is_correct)
30-
})
31-
32-
const unsubmittedTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
33-
return !testResultByTestCaseId[tc.id]
34-
})
35-
36-
const sortedTestCases = [...unsubmittedTestCases, ...incorrectTestCases, ...correctTestCases]
37-
38-
return (
39-
<div className="flex flex-col space-y-6 text-sm">
40-
{sortedTestCases.map((testCase, i) => {
41-
const result = props.testState?.programmingResults.find(
42-
(result) => result.programming_question_test_case_id === testCase.id,
43-
)
44-
45-
let testEmoji: ReactNode
46-
if (props.testState?.status === "running") {
47-
testEmoji = (
48-
<span title="Running test">
49-
<Loader2 className="animate-spin sq-6" />
50-
</span>
51-
)
52-
} else if (result) {
53-
testEmoji = (
54-
<span title={result.status === "success" ? "Test passed" : "Test failed"}>
55-
{result.status === "success" && result.is_correct ? (
56-
<CheckCircle2 className="s q-6 rounded-full bg-primary text-black" />
57-
) : (
58-
<XCircle className="rounded-full bg-red-500 text-black sq-6" />
59-
)}
60-
</span>
61-
)
62-
} else {
63-
testEmoji = (
64-
<span className="grayscale" title="No tests run">
65-
<MinusCircleIcon className="rounded-full bg-primary text-black sq-6" />
66-
</span>
67-
)
68-
}
69-
return (
70-
<div key={i} className="whitespace-pre-wrap font-mono">
71-
<div className="flex items-center gap-2">
72-
<span className="justify-self-center">{testEmoji}</span>
73-
<span>
74-
solution({testCase.args.map((a) => JSON.stringify(a)).join(", ")}) =={" "}
75-
{JSON.stringify(testCase.expectedOutput)}
76-
</span>
77-
</div>
78-
{result?.status === "success" && !result.is_correct && (
79-
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
80-
Output: {JSON.stringify(result.result)}
81-
</div>
82-
)}
83-
{result?.status === "error" && (
84-
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
85-
{result.reason}
86-
</div>
87-
)}
88-
</div>
89-
)
90-
})}
91-
</div>
92-
)
93-
}
94-
95-
export default function CodeRunning() {
96-
const { gameInfo, gameSessionInfo, submitCodeMutation } = useGameManager()
8+
export default function CodeRunning(props: { questionStrategy: ClientQuestionStrategy }) {
9+
const { gameSessionInfo, submitCodeMutation } = useGameManager()
9710
const submisisonMetricsQuery = api.games.getSubmissionMetrics.useQuery({
9811
game_id: gameSessionInfo.game_id,
9912
})
@@ -107,10 +20,7 @@ export default function CodeRunning() {
10720
<div className="relative flex flex-col">
10821
<div className="flex-1">
10922
<div className="mb-4 flex items-center pb-2">
110-
<h3 className="flex-1 text-left text-lg font-bold">
111-
Test
112-
<br /> Cases
113-
</h3>
23+
<h3 className="flex-1 text-left text-lg font-bold">Results</h3>
11424
<p className="text-right text-xs opacity-50">
11525
{gameSessionInfo.submission_state_id ? (
11626
<>
@@ -146,12 +56,7 @@ export default function CodeRunning() {
14656
)}
14757
</p>
14858
</div>
149-
{gameInfo.question?.programmingQuestion ? (
150-
<QuestionTestCaseResults
151-
question={gameInfo.question}
152-
testState={gameSessionInfo.testState}
153-
/>
154-
) : null}
59+
{props.questionStrategy.results(gameSessionInfo.testState)}
15560
</div>
15661
</div>
15762
)

src/components/game-screen/InProgressGame.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import { createClientQuestionStrategy } from "~/lib/games/question-types/client_
1212
import { type NotWaitingForPlayersGameState } from "~/lib/games/types"
1313
import { createDefaultLayout, createDefaultMobileLayout } from "~/lib/surfaces/panels/layouts"
1414
import PanelSkeleton from "~/lib/surfaces/panels/PanelSkeleton"
15+
import CodeRunning from "./CodeRunning"
1516
import CodeRunningFooter from "./CodeRunningFooter"
1617
import { CodeViewImpl } from "./CodeView"
1718
import MultiSelectPanel from "./MultiSelectPanel"
18-
import RunCodePanel from "./RunCodePanel"
1919

2020
export const MOBILE_VIEWPORT = "(max-width: 640px)"
2121

@@ -37,7 +37,7 @@ function useViews(props: {
3737
const CodeRunningViewImpl = {
3838
key: "run-code",
3939
className: "p-4",
40-
component: <RunCodePanel />,
40+
component: <CodeRunning questionStrategy={props.questionStrategy} />,
4141
footer: <CodeRunningFooter />,
4242
footerClassName: "px-4 pb-4 pt-2",
4343
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { PictureQuestion, QuestionTestState } from "~/lib/games/types"
2+
3+
export function PictureQuestionResults(props: {
4+
question: PictureQuestion
5+
testState: QuestionTestState | null
6+
}) {
7+
return (
8+
<div>
9+
Picture question results coming soon!
10+
<br />
11+
{props.question.pictureQuestion.id}
12+
<br />
13+
Match percentage: {props.testState?.pictureResult.match_percentage}
14+
</div>
15+
)
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { ReactNode } from "react"
2+
import React from "react"
3+
import { CheckCircle2, Loader2, MinusCircleIcon, XCircle } from "lucide-react"
4+
5+
import type { PlayerGameSession } from "~/lib/games/types"
6+
import { type ProgrammingQuestionWithTestCases } from "~/lib/games/types"
7+
8+
export function ProgrammingQuestionResults(props: {
9+
question: ProgrammingQuestionWithTestCases
10+
testState: PlayerGameSession["testState"] | undefined
11+
}) {
12+
const testResultByTestCaseId = Object.fromEntries(
13+
props.testState?.programmingResults.map((result) => [
14+
`${result.programming_question_test_case_id}`,
15+
result,
16+
]) ?? [],
17+
)
18+
19+
const correctTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
20+
const result = testResultByTestCaseId[tc.id]
21+
return result?.status === "success" && result.is_correct
22+
})
23+
24+
const incorrectTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
25+
const result = testResultByTestCaseId[tc.id]
26+
return result && (result.status === "error" || !result.is_correct)
27+
})
28+
29+
const unsubmittedTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
30+
return !testResultByTestCaseId[tc.id]
31+
})
32+
33+
const sortedTestCases = [...unsubmittedTestCases, ...incorrectTestCases, ...correctTestCases]
34+
35+
console.log("sortedTestCases", sortedTestCases)
36+
return (
37+
<div className="flex flex-col space-y-6 text-sm">
38+
{sortedTestCases.map((testCase, i) => {
39+
const result = props.testState?.programmingResults.find(
40+
(result) => result.programming_question_test_case_id === testCase.id,
41+
)
42+
43+
let testEmoji: ReactNode
44+
if (props.testState?.status === "running") {
45+
testEmoji = (
46+
<span title="Running test">
47+
<Loader2 className="animate-spin sq-6" />
48+
</span>
49+
)
50+
} else if (result) {
51+
testEmoji = (
52+
<span title={result.status === "success" ? "Test passed" : "Test failed"}>
53+
{result.status === "success" && result.is_correct ? (
54+
<CheckCircle2 className="s q-6 rounded-full bg-primary text-black" />
55+
) : (
56+
<XCircle className="rounded-full bg-red-500 text-black sq-6" />
57+
)}
58+
</span>
59+
)
60+
} else {
61+
testEmoji = (
62+
<span className="grayscale" title="No tests run">
63+
<MinusCircleIcon className="rounded-full bg-primary text-black sq-6" />
64+
</span>
65+
)
66+
}
67+
return (
68+
<div key={i} className="whitespace-pre-wrap font-mono">
69+
<div className="flex items-center gap-2">
70+
<span className="justify-self-center">{testEmoji}</span>
71+
<span>
72+
solution({testCase.args.map((a) => JSON.stringify(a)).join(", ")}) =={" "}
73+
{JSON.stringify(testCase.expectedOutput)}
74+
</span>
75+
</div>
76+
{result?.status === "success" && !result.is_correct && (
77+
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
78+
Output: {JSON.stringify(result.result)}
79+
</div>
80+
)}
81+
{result?.status === "error" && (
82+
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
83+
{result.reason}
84+
</div>
85+
)}
86+
</div>
87+
)
88+
})}
89+
</div>
90+
)
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ProgrammingSubmissionState } from "~/lib/games/types"
2+
import { Skeleton } from "../ui/skeleton"
3+
4+
export function ProgrammingSubmissionStatus(props: {
5+
submissionState: ProgrammingSubmissionState
6+
}) {
7+
const hasMetricsToShow = true
8+
return (
9+
<p className="text-right text-xs opacity-50">
10+
{props.submissionState.submission_state_id ? (
11+
<>
12+
<span>Last submssion</span>
13+
<br />
14+
<span className="inline-flex items-center gap-1 font-medium">
15+
{hasMetricsToShow ? (
16+
`${props.submissionState.metrics.numPassingSubmissionsTestCases}/${props.submissionState.metrics.numTestCases} `
17+
) : (
18+
<Skeleton className="h-2 w-10 rounded-full" />
19+
)}
20+
testcases passed
21+
</span>
22+
{hasMetricsToShow ? (
23+
<div className="mt-1 h-2 w-full overflow-hidden rounded-full bg-red-500">
24+
<div
25+
className="h-full bg-primary"
26+
style={{
27+
width: `${(props.submissionState.metrics.numPassingSubmissionsTestCases / props.submissionState.metrics.numTestCases) * 100}%`,
28+
}}
29+
/>
30+
</div>
31+
) : (
32+
<Skeleton className="mt-1 h-2 w-full rounded-full" />
33+
)}
34+
</>
35+
) : (
36+
<>
37+
<span>No submissions</span>
38+
<br />
39+
<span>recorded yet</span>
40+
</>
41+
)}
42+
</p>
43+
)
44+
}

src/components/game-screen/RunCodePanel.tsx

-7
This file was deleted.

src/lib/games/queries.ts

+2
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ export async function getSessionInfoForPlayer(tx: DBOrTransaction, userId: strin
278278
with: {
279279
submissionState: {
280280
with: {
281+
pictureResult: true,
281282
programmingResults: {
282283
columns: {
283284
is_correct: true,
@@ -287,6 +288,7 @@ export async function getSessionInfoForPlayer(tx: DBOrTransaction, userId: strin
287288
},
288289
testState: {
289290
with: {
291+
pictureResult: true,
290292
programmingResults: true,
291293
},
292294
},

src/lib/games/question-types/base.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { invariant } from "@epic-web/invariant"
22

33
import type { GameMode, QuestionDifficultyLevels, QuestionType } from "../constants"
4+
import type { QuestionTestState, SubmissionState } from "../types"
45
import { entries } from "~/lib/utils/object"
56
import { randomElement } from "~/lib/utils/random"
67
import { GAME_MODE_DETAILS } from "../constants"
@@ -14,6 +15,8 @@ export interface ClientQuestionStrategy extends BaseQuestionStrategy {
1415
readonly title: string
1516
readonly description: string
1617
readonly preview: JSX.Element
18+
results: (testState: QuestionTestState | null) => JSX.Element
19+
submissionMetrics: (submissionState: SubmissionState) => JSX.Element
1720
}
1821

1922
export abstract class QuestionTypeConfig {

src/lib/games/question-types/picture/client.tsx

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { invariant } from "@epic-web/invariant"
22

3-
import { type FullQuestion } from "../../types"
3+
import type { FullQuestion, SubmissionState } from "../../types"
4+
import type { ClientQuestionStrategy } from "../base"
5+
import { PictureQuestionResults } from "~/components/game-screen/PictureQuestionResults"
6+
import { type QuestionTestState } from "../../types"
47
import { BaseQuestionStrategy } from "../base"
58
import { PictureQuestionConfig } from "./config"
69

7-
export class PictureQuestionStrategy extends BaseQuestionStrategy {
10+
export class PictureQuestionStrategy
11+
extends BaseQuestionStrategy
12+
implements ClientQuestionStrategy
13+
{
814
private readonly question: FullQuestion & {
915
pictureQuestion: NonNullable<FullQuestion["pictureQuestion"]>
1016
}
@@ -26,4 +32,16 @@ export class PictureQuestionStrategy extends BaseQuestionStrategy {
2632
get preview() {
2733
return <div>Picture question</div>
2834
}
35+
36+
results(testState: QuestionTestState | null) {
37+
return <PictureQuestionResults question={this.question} testState={testState} />
38+
}
39+
40+
submissionMetrics(submissionState: SubmissionState) {
41+
if (submissionState.type === "picture") {
42+
throw new Error("Picture submission metrics not implemented")
43+
return <></>
44+
}
45+
throw new Error("Picture submission state not found")
46+
}
2947
}

0 commit comments

Comments
 (0)