Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Icon } from "@/components/ui/Icon";
import styles from "../Grader.module.css";

import { StudentMultiSelect } from "./StudentMultiSelect";
import { CompactAnswerView } from "./questionTypes";

interface MultiGradeDialogProps {
visible: boolean;
Expand Down Expand Up @@ -150,7 +151,17 @@ const QuestionGradeSection: React.FC<SectionProps> = ({
<Table.Tr key={r.sid}>
<Table.Td>{studentLabel(r)}</Table.Td>
<Table.Td>
<div className={styles.multiGradeAnswer}>{r.answer || "—"}</div>
<div className={styles.multiGradeAnswer}>
<CompactAnswerView
questionType={
r.selected_question_type ||
data?.question.question_type ||
question.question_type
}
answer={r.answer}
htmlsrc={r.selected_htmlsrc || data?.question.htmlsrc || question.htmlsrc}
/>
</div>
</Table.Td>
<Table.Td>
<NumberInput
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import styles from "../Grader.module.css";
import { useGraderTourContext } from "../tour/GraderTourContext";
import { getDemoHistoryFor } from "../tour/graderDemoData";

import { AnswerRenderer } from "./questionTypes/AnswerRenderer";
import { AnswerRenderer, formatCompactAnswer } from "./questionTypes";
import { CorrectChipKind, correctChipKind, formatAnswer } from "./submissionPaneHelpers";

interface Props {
Expand Down Expand Up @@ -169,6 +169,7 @@ export const SubmissionPane = forwardRef<SubmissionPaneHandle, Props>(function S
const c = correctChip(h);
const active = idx === activeAttempt;
const raw = formatAnswer(h.answer);
const summary = formatCompactAnswer(questionType, raw, htmlsrc);

return (
<button
Expand All @@ -187,8 +188,8 @@ export const SubmissionPane = forwardRef<SubmissionPaneHandle, Props>(function S
)}
</div>
<div className={styles.historyAnswer}>
{raw.slice(0, 200) || "(empty)"}
{raw.length > 200 ? "…" : ""}
{summary.slice(0, 200) || "(empty)"}
{summary.length > 200 ? "…" : ""}
</div>
</button>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";

import { renderWithMantine, screen } from "@/test/renderWithMantine";

import { AnswerRendererProps } from "../types";

import { ActiveCodeAnswerView } from "./ActiveCodeAnswerView";

const baseProps = (answer: string): AnswerRendererProps => ({
answer,
history: [],
questionName: "q-div-1",
questionId: 7,
sid: "student1"
});

describe("ActiveCodeAnswerView", () => {
it("renders the submitted source", () => {
renderWithMantine(<ActiveCodeAnswerView {...baseProps("x = 1")} />);

expect(screen.getByText("Submitted source")).toBeInTheDocument();
expect(screen.getByText("x = 1")).toBeInTheDocument();
});

it("renders an empty marker when no source was submitted", () => {
renderWithMantine(<ActiveCodeAnswerView {...baseProps("")} />);

expect(screen.getByText("(empty)")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react";

import styles from "./AnswerViews.module.css";
import { QuestionPreviewHeader } from "./RunestonePreview";
import { AnswerRendererProps } from "./types";
import styles from "../AnswerViews.module.css";
import { QuestionPreviewHeader } from "../RunestonePreview";
import { AnswerRendererProps } from "../types";

export const ActiveCodeAnswerView: React.FC<AnswerRendererProps> = (props) => {
const { answer } = props;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ActiveCodeAnswerView } from "./ActiveCodeAnswerView";
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { GraderAnswerHistoryItem } from "@store/grader/grader.logic.api";
import React from "react";
import { describe, expect, it, vi } from "vitest";

import { renderWithMantine, screen } from "@/test/renderWithMantine";

import { AnswerRendererProps } from "../types";

import { AnswerRenderer } from "./AnswerRenderer";
import { AnswerRendererProps } from "./types";

vi.mock("./McqAnswerView", () => ({ McqAnswerView: () => <div>VIEW:MCQ</div> }));
vi.mock("./FitbAnswerView", () => ({ FitbAnswerView: () => <div>VIEW:FITB</div> }));
vi.mock("./ShortAnswerView", () => ({ ShortAnswerView: () => <div>VIEW:SHORT</div> }));
vi.mock("./ParsonsAnswerView", () => ({ ParsonsAnswerView: () => <div>VIEW:PARSONS</div> }));
vi.mock("./ActiveCodeAnswerView", () => ({
vi.mock("../McqAnswerView", () => ({ McqAnswerView: () => <div>VIEW:MCQ</div> }));
vi.mock("../AssociationAnswerView", () => ({
AssociationAnswerView: ({ kind }: { kind: string }) => <div>VIEW:{kind.toUpperCase()}</div>
}));
vi.mock("../FitbAnswerView", () => ({ FitbAnswerView: () => <div>VIEW:FITB</div> }));
vi.mock("../ShortAnswerView", () => ({ ShortAnswerView: () => <div>VIEW:SHORT</div> }));
vi.mock("../ParsonsAnswerView", () => ({ ParsonsAnswerView: () => <div>VIEW:PARSONS</div> }));
vi.mock("../ActiveCodeAnswerView", () => ({
ActiveCodeAnswerView: () => <div>VIEW:ACTIVECODE</div>
}));
vi.mock("./DefaultAnswerView", () => ({ DefaultAnswerView: () => <div>VIEW:DEFAULT</div> }));
vi.mock("./IframeAnswerView", () => ({ IframeAnswerView: () => <div>VIEW:IFRAME</div> }));
vi.mock("./RunestoneGraderPreview", () => ({
vi.mock("../DefaultAnswerView", () => ({ DefaultAnswerView: () => <div>VIEW:DEFAULT</div> }));
vi.mock("../IframeAnswerView", () => ({ IframeAnswerView: () => <div>VIEW:IFRAME</div> }));
vi.mock("../RunestoneGraderPreview", () => ({
RunestoneGraderPreview: ({ attemptId }: { attemptId?: string | number }) => (
<div>PREVIEW:{String(attemptId)}</div>
)
Expand All @@ -34,13 +38,20 @@ const renderFor = (questionType: string, overrides: Partial<AnswerRendererProps>
renderWithMantine(<AnswerRenderer questionType={questionType} {...baseProps(overrides)} />);

describe("AnswerRenderer dispatch (no interactive htmlsrc)", () => {
it.each(["mchoice", "clickablearea", "dragndrop"])(
"routes %s to the MCQ view",
(questionType) => {
renderFor(questionType);
expect(screen.getByText("VIEW:MCQ")).toBeInTheDocument();
}
);
it.each(["mchoice", "clickablearea"])("routes %s to the MCQ view", (questionType) => {
renderFor(questionType);
expect(screen.getByText("VIEW:MCQ")).toBeInTheDocument();
});

it("routes drag-and-drop to the structured placement view", () => {
renderFor("dragndrop");
expect(screen.getByText("VIEW:DRAGNDROP")).toBeInTheDocument();
});

it("routes matching to the structured matching view", () => {
renderFor("matching");
expect(screen.getByText("VIEW:MATCHING")).toBeInTheDocument();
});

it("routes fillintheblank to the FITB view", () => {
renderFor("fillintheblank");
Expand Down Expand Up @@ -125,4 +136,14 @@ describe("AnswerRenderer interactive Runestone preview", () => {

expect(screen.getByText("PREVIEW:latest")).toBeInTheDocument();
});

it.each(["matching", "dragndrop"])(
"keeps a readable %s summary alongside the interactive preview",
(questionType) => {
renderFor(questionType, { htmlsrc: "<div>q</div>" });

expect(screen.getByText("PREVIEW:latest")).toBeInTheDocument();
expect(screen.getByText(`VIEW:${questionType.toUpperCase()}`)).toBeInTheDocument();
}
);
});
Original file line number Diff line number Diff line change
@@ -1,39 +1,18 @@
import React from "react";

import { ActiveCodeAnswerView } from "./ActiveCodeAnswerView";
import styles from "./AnswerViews.module.css";
import { DefaultAnswerView } from "./DefaultAnswerView";
import { FitbAnswerView } from "./FitbAnswerView";
import { IframeAnswerView } from "./IframeAnswerView";
import { McqAnswerView } from "./McqAnswerView";
import { ParsonsAnswerView } from "./ParsonsAnswerView";
import { RunestoneGraderPreview } from "./RunestoneGraderPreview";
import { ShortAnswerView } from "./ShortAnswerView";
import { AnswerRendererProps } from "./types";
import { ActiveCodeAnswerView } from "../ActiveCodeAnswerView";
import styles from "../AnswerViews.module.css";
import { AssociationAnswerView } from "../AssociationAnswerView";
import { DefaultAnswerView } from "../DefaultAnswerView";
import { FitbAnswerView } from "../FitbAnswerView";
import { IframeAnswerView } from "../IframeAnswerView";
import { McqAnswerView } from "../McqAnswerView";
import { ParsonsAnswerView } from "../ParsonsAnswerView";
import { RunestoneGraderPreview } from "../RunestoneGraderPreview";
import { ShortAnswerView } from "../ShortAnswerView";
import { AnswerRendererProps } from "../types";

const RUNESTONE_GRADER_TYPES = new Set([
"mchoice",
"clickablearea",
"dragndrop",
"fillintheblank",
"shortanswer",
"parsonsprob",
"matching",
"activecode",
"actex",
"codelens",
"hparsons",
"lp",
"webwork",
"selectquestion"
]);

/**
* Question types that are a third-party activity embedded in an iframe. They
* have no Runestone component to re-render, so they get their own view rather
* than the component preview or a dump of their opaque state blob.
*/
const IFRAME_TYPES = new Set(["splice", "doenet", "iframe"]);
import { IFRAME_TYPES, RUNESTONE_GRADER_TYPES } from "./constants";

export const AnswerRenderer: React.FC<AnswerRendererProps & { questionType: string }> = (props) => {
const { questionType, htmlsrc, questionName, sid, history, activeAttemptIndex } = props;
Expand All @@ -57,20 +36,35 @@ export const AnswerRenderer: React.FC<AnswerRendererProps & { questionType: stri
key={`${sid}-${attempt?.id ?? "latest"}`}
htmlsrc={htmlsrc}
divId={questionName}
questionType={questionType}
sid={sid}
attempt={attempt}
attemptId={attempt?.id ?? "latest"}
/>
</section>
) : null;

if (interactive) return <>{interactive}</>;
if (interactive) {
if (questionType === "matching" || questionType === "dragndrop") {
return (
<>
{interactive}
<AssociationAnswerView {...props} kind={questionType} showQuestionHeader={false} />
</>
);
}

return <>{interactive}</>;
}

switch (questionType) {
case "mchoice":
case "clickablearea":
case "dragndrop":
return <McqAnswerView {...props} />;
case "dragndrop":
return <AssociationAnswerView {...props} kind="dragndrop" />;
case "matching":
return <AssociationAnswerView {...props} kind="matching" />;
case "fillintheblank":
return <FitbAnswerView {...props} />;
case "shortanswer":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const RUNESTONE_GRADER_TYPES = new Set([
"mchoice",
"clickablearea",
"dragndrop",
"fillintheblank",
"shortanswer",
"parsonsprob",
"matching",
"activecode",
"actex",
"codelens",
"hparsons",
"lp",
"webwork",
"selectquestion"
]);

/** Third-party activities embedded in an iframe rather than Runestone components. */
export const IFRAME_TYPES = new Set(["splice", "doenet", "iframe"]);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AnswerRenderer } from "./AnswerRenderer";
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";

import { renderWithMantine, screen } from "@/test/renderWithMantine";

import { AnswerRendererProps } from "../types";

import { AssociationAnswerView } from "./AssociationAnswerView";
import { formatAssociationAnswer, parseAssociations } from "./utils";

const baseProps = (overrides: Partial<AnswerRendererProps> = {}): AnswerRendererProps => ({
answer: "",
history: [],
questionName: "q-div-1",
questionId: 7,
sid: "student1",
...overrides
});

describe("AssociationAnswerView", () => {
it("renders matching connections as readable pairs", () => {
renderWithMantine(
<AssociationAnswerView
{...baseProps({ answer: '{"connections":[{"from":"left-1","to":"right-2"}]}' })}
kind="matching"
/>
);

expect(screen.getByText("Submitted matches")).toBeInTheDocument();
expect(screen.getByText("left-1 → right-2")).toBeInTheDocument();
});

it("renders drag-and-drop placements grouped by dropzone", () => {
renderWithMantine(
<AssociationAnswerView
{...baseProps({ answer: '{"zone-1":["item-a","item-b"]}' })}
kind="dragndrop"
/>
);

expect(screen.getByText("Submitted placements")).toBeInTheDocument();
expect(screen.getByText("item-a → zone-1")).toBeInTheDocument();
expect(screen.getByText("item-b → zone-1")).toBeInTheDocument();
});

it("replaces matching ids with labels from the rendered question", () => {
const htmlsrc = `<div data-component="matching"><script type="application/json">{
"left":[{"id":"p1","label":"Loop"}],
"right":[{"id":"r1","label":"Repeats <strong>statements</strong>"}]
}</script></div>`;

renderWithMantine(
<AssociationAnswerView
{...baseProps({
answer: '{"connections":[{"from":"p1","to":"r1"}]}',
htmlsrc
})}
kind="matching"
/>
);

expect(screen.getByText("Loop → Repeats statements")).toBeInTheDocument();
});

it("formats drag-and-drop history without exposing its JSON", () => {
const htmlsrc = `<ul data-component="dragndrop"><script type="application/json">{
"left":[{"id":"p1","label":"Apple"}],
"right":[{"id":"r1","label":"Red"}]
}</script></ul>`;

expect(formatAssociationAnswer("dragndrop", '{"r1":["p1"]}', htmlsrc)).toBe("Apple → Red");
});

it("rejects malformed stored mappings without throwing", () => {
expect(parseAssociations("matching", "not-json")).toEqual([]);
renderWithMantine(
<AssociationAnswerView {...baseProps({ answer: "not-json" })} kind="matching" />
);

expect(screen.getByText("Stored answer could not be decoded.")).toBeInTheDocument();
});
});
Loading
Loading