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
37 changes: 37 additions & 0 deletions v2/e2e/student.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,48 @@ test("opens an assigned mission in two actions and runs visible tests", async ({
await expect
.poll(() => page.evaluate(() => window.__TOMATIN_EDITOR__?.getValue()))
.toContain("const preciosEjemplo = [1200, 850]");
await expect(page.getByRole("button", { name: "Entregar" })).toBeDisabled();

await page.getByRole("button", { name: "Ejecutar" }).click();
await expect(
page.getByText("El código corre, pero aún no pasa todos los tests"),
).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "Entregar" })).toBeEnabled();
});

test("resizes results and keeps long editor lines on one line", async ({
page,
}) => {
await page.getByRole("link", { name: "Continuar" }).click();
await expect
.poll(() =>
page.evaluate(
() => window.__TOMATIN_EDITOR__?.getRawOptions().wordWrap,
),
)
.toBe("off");

const editor = page.locator(".code-pane");
const results = page.locator(".results-pane");
const resizer = page.getByRole("separator", {
name: "Ajustar ancho de Resultados",
});
const editorBefore = await editor.boundingBox();
const resultsBefore = await results.boundingBox();
const handle = await resizer.boundingBox();
expect(editorBefore).not.toBeNull();
expect(resultsBefore).not.toBeNull();
expect(handle).not.toBeNull();

await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + 80);
await page.mouse.down();
await page.mouse.move(handle!.x - 70, handle!.y + 80);
await page.mouse.up();

const editorAfter = await editor.boundingBox();
const resultsAfter = await results.boundingBox();
expect(editorAfter!.width).toBeLessThan(editorBefore!.width);
expect(resultsAfter!.width).toBeGreaterThan(resultsBefore!.width);
});

test("keeps independent code when changing language", async ({ page }) => {
Expand Down
182 changes: 172 additions & 10 deletions v2/src/pages/MissionWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ArrowLeft,
Expand All @@ -19,6 +21,7 @@ import {
ExternalLink,
FileCode2,
Github,
GripVertical,
History,
Lightbulb,
LoaderCircle,
Expand Down Expand Up @@ -62,6 +65,28 @@ type MobilePane = "brief" | "code" | "results";
type BriefTab = "problem" | "hints" | "history" | "solution";
type SaveState = "loading" | "saving" | "synced" | "local" | "error";

const RESULTS_WIDTH_KEY = "tomatin.v3.workspace-results-width";
const MIN_RESULTS_WIDTH = 290;
const MIN_EDITOR_WIDTH = 420;
const RESIZER_WIDTH = 7;
const DEFAULT_RESULTS_WIDTH = 320;

function readResultsWidth() {
const stored = Number(window.localStorage.getItem(RESULTS_WIDTH_KEY));
return Number.isFinite(stored) && stored >= MIN_RESULTS_WIDTH
? stored
: DEFAULT_RESULTS_WIDTH;
}

function executionFingerprint(
missionId: string,
missionVersion: number,
language: Language,
code: string,
) {
return `${missionId}:${missionVersion}:${language}:${code}`;
}

const configureMonaco: BeforeMount = (monaco) => {
monaco.editor.defineTheme("tomatin-terminal", {
base: "vs-dark",
Expand Down Expand Up @@ -312,10 +337,15 @@ export function Component() {
const [solution, setSolution] = useState<MissionSolution | null>(null);
const [solutionError, setSolutionError] = useState("");
const [solutionLoading, setSolutionLoading] = useState(false);
const [resultsWidth, setResultsWidth] = useState(readResultsWidth);
const [executedFingerprints, setExecutedFingerprints] = useState<Set<string>>(
new Set(),
);
const saveTimer = useRef<number | undefined>(undefined);
const lastEditingSignal = useRef(0);
const openedActivityKey = useRef("");
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
const workbenchRef = useRef<HTMLDivElement | null>(null);
const isStaff = profile?.role === "owner" || profile?.role === "mentor";
const canViewSolution = isStaff && !isStudentPreview;

Expand Down Expand Up @@ -552,13 +582,47 @@ export function Component() {
}
}, [briefTab, canViewSolution]);

useEffect(() => {
const workbench = workbenchRef.current;
if (!workbench || typeof ResizeObserver === "undefined") return;
const clampCurrentWidth = () => {
const maxWidth = Math.max(
MIN_RESULTS_WIDTH,
workbench.clientWidth - MIN_EDITOR_WIDTH - RESIZER_WIDTH,
);
setResultsWidth((current) =>
Math.min(Math.max(current, MIN_RESULTS_WIDTH), maxWidth),
);
};
const observer = new ResizeObserver(clampCurrentWidth);
observer.observe(workbench);
clampCurrentWidth();
return () => observer.disconnect();
}, []);

if (!mission) return <Navigate to="/missions" replace />;
if (!profile || !viewProfile || !snapshot) return null;

const activeMission = mission;
const activeProfile = viewProfile;
const allowedLanguages = validAssignment?.allowedLanguages ?? [...LANGUAGES];
const currentCode = codeByLanguage[language];
const currentExecutionFingerprint = executionFingerprint(
activeMission.id,
activeMission.version,
language,
currentCode,
);
const hasRunCurrentCode =
executedFingerprints.has(currentExecutionFingerprint) ||
history.some(
(attempt) =>
attempt.kind === "run" &&
attempt.missionVersion === activeMission.version &&
attempt.language === language &&
attempt.code === currentCode,
);
const submitNeedsRun = Boolean(validAssignment) && !hasRunCurrentCode;
const testInputs = Object.fromEntries(
activeMission.variants[language].publicTests.map((testCase) => [
testCase.id,
Expand All @@ -567,7 +631,7 @@ export function Component() {
);

async function execute(kind: AttemptKind) {
if (isStudentPreview) return;
if (isStudentPreview || (kind === "submit" && submitNeedsRun)) return;
setRunning(kind);
setResult(null);
setMobilePane("results");
Expand All @@ -586,6 +650,13 @@ export function Component() {
})),
};
setResult(annotatedResult);
if (kind === "run") {
setExecutedFingerprints((current) => {
const next = new Set(current);
next.add(currentExecutionFingerprint);
return next;
});
}
const attempt: Attempt = {
id: annotatedResult.id,
userId: activeProfile.id,
Expand All @@ -609,6 +680,46 @@ export function Component() {
setRunning(null);
}

function clampResultsWidth(nextWidth: number) {
const workbenchWidth = workbenchRef.current?.clientWidth ?? 0;
const maxWidth = Math.max(
MIN_RESULTS_WIDTH,
workbenchWidth - MIN_EDITOR_WIDTH - RESIZER_WIDTH,
);
return Math.min(Math.max(nextWidth, MIN_RESULTS_WIDTH), maxWidth);
}

function resizeResults(event: ReactPointerEvent<HTMLButtonElement>) {
const workbench = workbenchRef.current;
if (!workbench) return;
event.currentTarget.setPointerCapture(event.pointerId);
setResultsWidth(
clampResultsWidth(workbench.getBoundingClientRect().right - event.clientX),
);
}

function finishResize(event: ReactPointerEvent<HTMLButtonElement>) {
const workbench = workbenchRef.current;
const next = workbench
? clampResultsWidth(
workbench.getBoundingClientRect().right - event.clientX,
)
: resultsWidth;
setResultsWidth(next);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
window.localStorage.setItem(RESULTS_WIDTH_KEY, String(next));
}

function resizeResultsWithKeyboard(direction: -1 | 1) {
setResultsWidth((current) => {
const next = clampResultsWidth(current + direction * 24);
window.localStorage.setItem(RESULTS_WIDTH_KEY, String(next));
return next;
});
}

function resetStarter() {
setCodeByLanguage((current) => ({
...current,
Expand Down Expand Up @@ -1000,7 +1111,16 @@ export function Component() {
</div>
</aside>

<section className={`code-pane mobile-${mobilePane === "code" ? "visible" : "hidden"}`}>
<div
className="workbench-panes"
ref={workbenchRef}
style={
{
"--results-panel-width": `${resultsWidth}px`,
} as CSSProperties
}
>
<section className={`code-pane mobile-${mobilePane === "code" ? "visible" : "hidden"}`}>
<div className="code-toolbar">
<div className="language-switcher" role="group" aria-label="Lenguaje">
{allowedLanguages.map((entry) => (
Expand Down Expand Up @@ -1083,7 +1203,13 @@ export function Component() {
scrollBeyondLastLine: false,
smoothScrolling: true,
tabSize: language === "python" ? 4 : 2,
wordWrap: "on",
wordWrap: "off",
scrollBeyondLastColumn: 5,
scrollbar: {
horizontal: "auto",
vertical: "auto",
alwaysConsumeMouseWheel: false,
},
stickyScroll: { enabled: false },
}}
/>
Expand Down Expand Up @@ -1115,7 +1241,12 @@ export function Component() {
<button
className="button primary"
type="button"
disabled={Boolean(running) || isStudentPreview}
disabled={Boolean(running) || isStudentPreview || submitNeedsRun}
title={
submitNeedsRun
? "Ejecuta este código antes de entregarlo"
: undefined
}
onClick={() => void execute("submit")}
>
{running === "submit" ? (
Expand All @@ -1126,12 +1257,42 @@ export function Component() {
{validAssignment ? "Entregar" : "Comprobar"}
</button>
</div>
</section>
</section>

<section
className={`results-pane mobile-${mobilePane === "results" ? "visible" : "hidden"}`}
aria-live="polite"
>
<button
className="workspace-resizer"
type="button"
role="separator"
aria-label="Ajustar ancho de Resultados"
aria-orientation="vertical"
aria-valuemin={MIN_RESULTS_WIDTH}
aria-valuenow={Math.round(resultsWidth)}
title="Arrastra para ajustar Resultados"
onPointerDown={resizeResults}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
resizeResults(event);
}
}}
onPointerUp={finishResize}
onPointerCancel={finishResize}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
resizeResultsWithKeyboard(1);
} else if (event.key === "ArrowRight") {
event.preventDefault();
resizeResultsWithKeyboard(-1);
}
}}
>
<GripVertical aria-hidden="true" />
</button>

<section
className={`results-pane mobile-${mobilePane === "results" ? "visible" : "hidden"}`}
aria-live="polite"
>
<div className="results-titlebar">
<span>
<TestTube2 aria-hidden="true" />
Expand Down Expand Up @@ -1176,7 +1337,8 @@ export function Component() {
}}
/>
)}
</section>
</section>
</div>
</div>
</main>
);
Expand Down
Loading