From ecdca0633477abaf2818ff4f8586434a6c9bef21 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sat, 4 Jul 2026 19:29:43 -0700 Subject: [PATCH] Show LOC for public solutions --- frontend/src/api/api.ts | 1 + .../src/pages/leaderboard/Leaderboard.tsx | 2 + .../components/SubmissionCodeSidebar.test.tsx | 48 +++++++ .../components/SubmissionCodeSidebar.tsx | 22 ++- .../components/SubmissionSidebarContext.tsx | 12 ++ kernelboard/api/submission.py | 29 +++- tests/api/test_submission_api.py | 127 ++++++++++++++++++ 7 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.test.tsx diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 7b5cd468..f085e67a 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -43,6 +43,7 @@ export interface CodesResponse { results: Array<{ submission_id: number; code: string; + line_count?: number; }>; } diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 205a97e2..1b9e1b6e 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -417,6 +417,7 @@ function LeaderboardWithSidebar() { navigationItems, navigationIndex, codes, + lineCounts, isOpen, isLoadingCodes, navigate, @@ -455,6 +456,7 @@ function LeaderboardWithSidebar() { navigationItems={navigationItems} navigationIndex={navigationIndex} codes={codes} + lineCounts={lineCounts} isLoadingCodes={isLoadingCodes} onClose={close} onNavigate={navigate} diff --git a/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.test.tsx b/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.test.tsx new file mode 100644 index 00000000..43eb5752 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.test.tsx @@ -0,0 +1,48 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import SubmissionCodeSidebar from "./SubmissionCodeSidebar"; + +describe("SubmissionCodeSidebar", () => { + it("shows LOC when line count metadata is available", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("LOC")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); + + it("does not show LOC without line count metadata", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("LOC")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.tsx b/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.tsx index 77a44a12..29132b2f 100644 --- a/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.tsx +++ b/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.tsx @@ -22,6 +22,7 @@ interface SubmissionCodeSidebarProps { navigationItems: NavigationItem[]; navigationIndex: number; codes: Map; + lineCounts?: Map; isLoadingCodes?: boolean; onClose: () => void; onNavigate: (newIndex: number, item: NavigationItem) => void; @@ -34,6 +35,7 @@ export default function SubmissionCodeSidebar({ navigationItems, navigationIndex, codes, + lineCounts, isLoadingCodes = false, onClose, onNavigate, @@ -91,6 +93,9 @@ export default function SubmissionCodeSidebar({ }, [onWidthChange]); const drawerWidth = isMobile ? "100vw" : width; + const lineCount = selectedSubmission + ? lineCounts?.get(selectedSubmission.submissionId) + : undefined; return ( )} + {lineCount !== undefined && ( + + + LOC + + + {lineCount.toLocaleString()} + + + )} {selectedSubmission.fileName && ( ; + lineCounts: Map; isOpen: boolean; isLoadingCodes: boolean; navigate: (newIndex: number, item: NavigationItem) => void; @@ -40,6 +41,7 @@ export function SubmissionSidebarProvider({ const [navigationItems, setNavigationItems] = useState([]); const [navigationIndex, setNavigationIndex] = useState(0); const [codes, setCodes] = useState>(new Map()); + const [lineCounts, setLineCounts] = useState>(new Map()); const [isLoadingCodes, setIsLoadingCodes] = useState(false); const codesRef = useRef(codes); codesRef.current = codes; @@ -72,6 +74,15 @@ export function SubmissionSidebarProvider({ } return next; }); + setLineCounts((prev) => { + const next = new Map(prev); + for (const item of response?.results ?? []) { + if (typeof item.line_count === "number") { + next.set(item.submission_id, item.line_count); + } + } + return next; + }); }) .catch((err) => { console.warn("[SubmissionSidebar] Failed to fetch codes:", err); @@ -113,6 +124,7 @@ export function SubmissionSidebarProvider({ navigationItems, navigationIndex, codes, + lineCounts, isOpen: !!selectedSubmission, isLoadingCodes, navigate, diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 499ccfef..6830e9f7 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -195,7 +195,11 @@ def list_codes_route(): logger.info( "[list_codes] leaderboard is allowed, allow all users to see the leaderboard codes" ) - results = list_codes(leaderboard_id, submission_ids) + results = list_codes( + leaderboard_id, + submission_ids, + include_line_count=True, + ) return http_success( data={"results": results}, ) @@ -295,24 +299,35 @@ def list_submissions(): def list_codes( leaderboard_id: int, submission_ids: List[int], -) -> Tuple[List[dict[str, Any]], str]: + *, + include_line_count: bool = False, +) -> List[dict[str, Any]]: conn = get_db_connection() with conn.cursor() as cur: sql, params = _query_list_codes(leaderboard_id, submission_ids) cur.execute(sql, params) rows = cur.fetchall() - items = [ - { + items = [] + for r in rows: + code = decodeCodeText(r[3]) + item = { "submission_id": r[0], "leaderboard_id": r[1], "code_id": r[2], - "code": decodeCodeText(r[3]), + "code": code, } - for r in rows - ] + if include_line_count: + item["line_count"] = count_lines_of_code(code) + items.append(item) return items +def count_lines_of_code(code_text: str) -> int: + if not code_text: + return 0 + return len(code_text.splitlines()) + + def decodeCodeText(code_text): if isinstance(code_text, memoryview): return code_text.tobytes().decode("utf-8") diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index 3a4428dc..4fb3860e 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -193,6 +193,51 @@ def _post_submission(client, form_overrides=None, file_tuple=None): ) +def _seed_code_submission( + app, + *, + leaderboard_id: int, + submission_id: int, + code_id: int, + code: str, +) -> None: + with app.app_context(): + conn = get_db_connection() + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO leaderboard.user_info (id, user_name) + VALUES (%s, %s) + ON CONFLICT (id) DO UPDATE + SET user_name = EXCLUDED.user_name + """, + ("loc-test-user", "loc-test-user"), + ) + cur.execute( + """ + INSERT INTO leaderboard.code_files (id, code) + VALUES (%s, %s) + """, + (code_id, code), + ) + cur.execute( + """ + INSERT INTO leaderboard.submission + (id, leaderboard_id, file_name, user_id, code_id, submission_time, done) + VALUES + (%s, %s, %s, %s, %s, NOW(), TRUE) + """, + ( + submission_id, + leaderboard_id, + "loc_solution.py", + "loc-test-user", + code_id, + ), + ) + + def test_submission_happy_path(app, client, prepare): # auth + web_token default to True prepare() @@ -313,6 +358,88 @@ def test_submission_upstream_non_200_maps_to_http_error(app, client, prepare): assert "invalid format" in js["message"].lower() +# ---------------------------- +# /api/codes list tests +# ---------------------------- + +def test_list_codes_includes_line_count_for_ended_leaderboard(app, client, prepare): + prepare() + code = "import torch\n\nprint('ok')\n" + _seed_code_submission( + app, + leaderboard_id=339, + submission_id=910001, + code_id=910001, + code=code, + ) + + with app.app_context(): + conn = get_db_connection() + with conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE leaderboard.leaderboard + SET deadline = NOW() - INTERVAL '1 hour' + WHERE id = 339 + """ + ) + + resp = client.post( + "/api/codes", + json={"leaderboard_id": 339, "submission_ids": [910001]}, + ) + + assert resp.status_code == http.HTTPStatus.OK + item = resp.get_json()["data"]["results"][0] + assert item["code"] == code + assert item["line_count"] == 3 + + +def test_list_codes_omits_line_count_for_active_admin_access( + app, + client, + monkeypatch, +): + fake_admin = SimpleNamespace( + is_anonymous=False, + is_authenticated=True, + get_id=lambda: "discord:838132355075014667", + ) + monkeypatch.setattr(flask_login.utils, "_get_user", lambda: fake_admin) + + code = "import torch\nprint('active')\n" + _seed_code_submission( + app, + leaderboard_id=339, + submission_id=910002, + code_id=910002, + code=code, + ) + + with app.app_context(): + conn = get_db_connection() + with conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE leaderboard.leaderboard + SET deadline = NOW() + INTERVAL '1 hour' + WHERE id = 339 + """ + ) + + resp = client.post( + "/api/codes", + json={"leaderboard_id": 339, "submission_ids": [910002]}, + ) + + assert resp.status_code == http.HTTPStatus.OK + item = resp.get_json()["data"]["results"][0] + assert item["code"] == code + assert "line_count" not in item + + # ---------------------------- # /api/submissions list tests # ----------------------------