Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: code running panel #64

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 5 additions & 100 deletions src/components/game-screen/CodeRunning.tsx
Original file line number Diff line number Diff line change
@@ -1,99 +1,12 @@
import type { ReactNode } from "react"
import React from "react"
import { CheckCircle2, Loader2, MinusCircleIcon, XCircle } from "lucide-react"

import type { PlayerGameSession } from "~/lib/games/types"
import { type ProgrammingQuestionWithTestCases } from "~/lib/games/types"
import type { ClientQuestionStrategy } from "~/lib/games/question-types/base"
import { api } from "~/lib/trpc/react"
import { Skeleton } from "../ui/skeleton"
import { useGameManager } from "./GameManagerProvider"

function QuestionTestCaseResults(props: {
question: ProgrammingQuestionWithTestCases
testState: PlayerGameSession["testState"]
}) {
const testResultByTestCaseId = Object.fromEntries(
props.testState?.programmingResults.map((result) => [
`${result.programming_question_test_case_id}`,
result,
]) ?? [],
)

const correctTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
const result = testResultByTestCaseId[tc.id]
return result?.status === "success" && result.is_correct
})

const incorrectTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
const result = testResultByTestCaseId[tc.id]
return result && (result.status === "error" || !result.is_correct)
})

const unsubmittedTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
return !testResultByTestCaseId[tc.id]
})

const sortedTestCases = [...unsubmittedTestCases, ...incorrectTestCases, ...correctTestCases]

return (
<div className="flex flex-col space-y-6 text-sm">
{sortedTestCases.map((testCase, i) => {
const result = props.testState?.programmingResults.find(
(result) => result.programming_question_test_case_id === testCase.id,
)

let testEmoji: ReactNode
if (props.testState?.status === "running") {
testEmoji = (
<span title="Running test">
<Loader2 className="animate-spin sq-6" />
</span>
)
} else if (result) {
testEmoji = (
<span title={result.status === "success" ? "Test passed" : "Test failed"}>
{result.status === "success" && result.is_correct ? (
<CheckCircle2 className="s q-6 rounded-full bg-primary text-black" />
) : (
<XCircle className="rounded-full bg-red-500 text-black sq-6" />
)}
</span>
)
} else {
testEmoji = (
<span className="grayscale" title="No tests run">
<MinusCircleIcon className="rounded-full bg-primary text-black sq-6" />
</span>
)
}
return (
<div key={i} className="whitespace-pre-wrap font-mono">
<div className="flex items-center gap-2">
<span className="justify-self-center">{testEmoji}</span>
<span>
solution({testCase.args.map((a) => JSON.stringify(a)).join(", ")}) =={" "}
{JSON.stringify(testCase.expectedOutput)}
</span>
</div>
{result?.status === "success" && !result.is_correct && (
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
Output: {JSON.stringify(result.result)}
</div>
)}
{result?.status === "error" && (
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
{result.reason}
</div>
)}
</div>
)
})}
</div>
)
}

export default function CodeRunning() {
const { gameInfo, gameSessionInfo, submitCodeMutation } = useGameManager()
export default function CodeRunning(props: { questionStrategy: ClientQuestionStrategy }) {
const { gameSessionInfo, submitCodeMutation } = useGameManager()
const submisisonMetricsQuery = api.games.getSubmissionMetrics.useQuery({
game_id: gameSessionInfo.game_id,
})
Expand All @@ -107,10 +20,7 @@ export default function CodeRunning() {
<div className="relative flex flex-col">
<div className="flex-1">
<div className="mb-4 flex items-center pb-2">
<h3 className="flex-1 text-left text-lg font-bold">
Test
<br /> Cases
</h3>
<h3 className="flex-1 text-left text-lg font-bold">Results</h3>
<p className="text-right text-xs opacity-50">
{gameSessionInfo.submission_state_id ? (
<>
Expand Down Expand Up @@ -146,12 +56,7 @@ export default function CodeRunning() {
)}
</p>
</div>
{gameInfo.question?.programmingQuestion ? (
<QuestionTestCaseResults
question={gameInfo.question}
testState={gameSessionInfo.testState}
/>
) : null}
{props.questionStrategy.results(gameSessionInfo.testState)}
</div>
</div>
)
Expand Down
4 changes: 2 additions & 2 deletions src/components/game-screen/InProgressGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import { createClientQuestionStrategy } from "~/lib/games/question-types/client_
import { type NotWaitingForPlayersGameState } from "~/lib/games/types"
import { createDefaultLayout, createDefaultMobileLayout } from "~/lib/surfaces/panels/layouts"
import PanelSkeleton from "~/lib/surfaces/panels/PanelSkeleton"
import CodeRunning from "./CodeRunning"
import CodeRunningFooter from "./CodeRunningFooter"
import { CodeViewImpl } from "./CodeView"
import MultiSelectPanel from "./MultiSelectPanel"
import RunCodePanel from "./RunCodePanel"

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

Expand All @@ -37,7 +37,7 @@ function useViews(props: {
const CodeRunningViewImpl = {
key: "run-code",
className: "p-4",
component: <RunCodePanel />,
component: <CodeRunning questionStrategy={props.questionStrategy} />,
footer: <CodeRunningFooter />,
footerClassName: "px-4 pb-4 pt-2",
}
Expand Down
16 changes: 16 additions & 0 deletions src/components/game-screen/PictureQuestionResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { PictureQuestion, QuestionTestState } from "~/lib/games/types"

export function PictureQuestionResults(props: {
question: PictureQuestion
testState: QuestionTestState | null
}) {
return (
<div>
Picture question results coming soon!
<br />
{props.question.pictureQuestion.id}
<br />
Match percentage: {props.testState?.pictureResult.match_percentage}
</div>
)
}
91 changes: 91 additions & 0 deletions src/components/game-screen/ProgrammingQuestionResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ReactNode } from "react"
import React from "react"
import { CheckCircle2, Loader2, MinusCircleIcon, XCircle } from "lucide-react"

import type { PlayerGameSession } from "~/lib/games/types"
import { type ProgrammingQuestionWithTestCases } from "~/lib/games/types"

export function ProgrammingQuestionResults(props: {
question: ProgrammingQuestionWithTestCases
testState: PlayerGameSession["testState"] | undefined
}) {
const testResultByTestCaseId = Object.fromEntries(
props.testState?.programmingResults.map((result) => [
`${result.programming_question_test_case_id}`,
result,
]) ?? [],
)

const correctTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
const result = testResultByTestCaseId[tc.id]
return result?.status === "success" && result.is_correct
})

const incorrectTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
const result = testResultByTestCaseId[tc.id]
return result && (result.status === "error" || !result.is_correct)
})

const unsubmittedTestCases = props.question.programmingQuestion.testCases.filter((tc) => {
return !testResultByTestCaseId[tc.id]
})

const sortedTestCases = [...unsubmittedTestCases, ...incorrectTestCases, ...correctTestCases]

console.log("sortedTestCases", sortedTestCases)
return (
<div className="flex flex-col space-y-6 text-sm">
{sortedTestCases.map((testCase, i) => {
const result = props.testState?.programmingResults.find(
(result) => result.programming_question_test_case_id === testCase.id,
)

let testEmoji: ReactNode
if (props.testState?.status === "running") {
testEmoji = (
<span title="Running test">
<Loader2 className="animate-spin sq-6" />
</span>
)
} else if (result) {
testEmoji = (
<span title={result.status === "success" ? "Test passed" : "Test failed"}>
{result.status === "success" && result.is_correct ? (
<CheckCircle2 className="s q-6 rounded-full bg-primary text-black" />
) : (
<XCircle className="rounded-full bg-red-500 text-black sq-6" />
)}
</span>
)
} else {
testEmoji = (
<span className="grayscale" title="No tests run">
<MinusCircleIcon className="rounded-full bg-primary text-black sq-6" />
</span>
)
}
return (
<div key={i} className="whitespace-pre-wrap font-mono">
<div className="flex items-center gap-2">
<span className="justify-self-center">{testEmoji}</span>
<span>
solution({testCase.args.map((a) => JSON.stringify(a)).join(", ")}) =={" "}
{JSON.stringify(testCase.expectedOutput)}
</span>
</div>
{result?.status === "success" && !result.is_correct && (
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
Output: {JSON.stringify(result.result)}
</div>
)}
{result?.status === "error" && (
<div className="mt-3 whitespace-pre-wrap rounded-xl bg-red-500/20 p-4 text-red-500 bg-blend-color-burn">
{result.reason}
</div>
)}
</div>
)
})}
</div>
)
}
44 changes: 44 additions & 0 deletions src/components/game-screen/ProgrammingSubmissionStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ProgrammingSubmissionState } from "~/lib/games/types"
import { Skeleton } from "../ui/skeleton"

export function ProgrammingSubmissionStatus(props: {
submissionState: ProgrammingSubmissionState
}) {
const hasMetricsToShow = true
return (
<p className="text-right text-xs opacity-50">
{props.submissionState.submission_state_id ? (
<>
<span>Last submssion</span>
<br />
<span className="inline-flex items-center gap-1 font-medium">
{hasMetricsToShow ? (
`${props.submissionState.metrics.numPassingSubmissionsTestCases}/${props.submissionState.metrics.numTestCases} `
) : (
<Skeleton className="h-2 w-10 rounded-full" />
)}
testcases passed
</span>
{hasMetricsToShow ? (
<div className="mt-1 h-2 w-full overflow-hidden rounded-full bg-red-500">
<div
className="h-full bg-primary"
style={{
width: `${(props.submissionState.metrics.numPassingSubmissionsTestCases / props.submissionState.metrics.numTestCases) * 100}%`,
}}
/>
</div>
) : (
<Skeleton className="mt-1 h-2 w-full rounded-full" />
)}
</>
) : (
<>
<span>No submissions</span>
<br />
<span>recorded yet</span>
</>
)}
</p>
)
}
7 changes: 0 additions & 7 deletions src/components/game-screen/RunCodePanel.tsx

This file was deleted.

2 changes: 2 additions & 0 deletions src/lib/games/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export async function getSessionInfoForPlayer(tx: DBOrTransaction, userId: strin
with: {
submissionState: {
with: {
pictureResult: true,
programmingResults: {
columns: {
is_correct: true,
Expand All @@ -287,6 +288,7 @@ export async function getSessionInfoForPlayer(tx: DBOrTransaction, userId: strin
},
testState: {
with: {
pictureResult: true,
programmingResults: true,
},
},
Expand Down
3 changes: 3 additions & 0 deletions src/lib/games/question-types/base.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { invariant } from "@epic-web/invariant"

import type { GameMode, QuestionDifficultyLevels, QuestionType } from "../constants"
import type { QuestionTestState, SubmissionState } from "../types"
import { entries } from "~/lib/utils/object"
import { randomElement } from "~/lib/utils/random"
import { GAME_MODE_DETAILS } from "../constants"
Expand All @@ -14,6 +15,8 @@ export interface ClientQuestionStrategy extends BaseQuestionStrategy {
readonly title: string
readonly description: string
readonly preview: JSX.Element
results: (testState: QuestionTestState | null) => JSX.Element
submissionMetrics: (submissionState: SubmissionState) => JSX.Element
}

export abstract class QuestionTypeConfig {
Expand Down
22 changes: 20 additions & 2 deletions src/lib/games/question-types/picture/client.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { invariant } from "@epic-web/invariant"

import { type FullQuestion } from "../../types"
import type { FullQuestion, SubmissionState } from "../../types"
import type { ClientQuestionStrategy } from "../base"
import { PictureQuestionResults } from "~/components/game-screen/PictureQuestionResults"
import { type QuestionTestState } from "../../types"
import { BaseQuestionStrategy } from "../base"
import { PictureQuestionConfig } from "./config"

export class PictureQuestionStrategy extends BaseQuestionStrategy {
export class PictureQuestionStrategy
extends BaseQuestionStrategy
implements ClientQuestionStrategy
{
private readonly question: FullQuestion & {
pictureQuestion: NonNullable<FullQuestion["pictureQuestion"]>
}
Expand All @@ -26,4 +32,16 @@ export class PictureQuestionStrategy extends BaseQuestionStrategy {
get preview() {
return <div>Picture question</div>
}

results(testState: QuestionTestState | null) {
return <PictureQuestionResults question={this.question} testState={testState} />
}

submissionMetrics(submissionState: SubmissionState) {
if (submissionState.type === "picture") {
throw new Error("Picture submission metrics not implemented")
return <></>
}
throw new Error("Picture submission state not found")
}
}
Loading