From 1bd484b085af58cc7ecb7185f36349dbf9b3c2bd Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sat, 4 Jul 2026 19:45:58 -0700 Subject: [PATCH] Show LOC on concluded rankings --- frontend/src/api/api.ts | 2 +- .../src/pages/leaderboard/Leaderboard.tsx | 2 - .../leaderboard/components/RankingLists.tsx | 29 +++- .../components/SubmissionCodeSidebar.test.tsx | 48 ------- .../components/SubmissionCodeSidebar.tsx | 22 +-- .../components/SubmissionSidebarContext.tsx | 12 -- kernelboard/api/leaderboard.py | 86 ++++++++++++ kernelboard/api/submission.py | 42 ++---- tests/api/test_leaderboard_api.py | 75 +++++++++++ tests/api/test_submission_api.py | 127 ------------------ 10 files changed, 198 insertions(+), 247 deletions(-) delete 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 f085e67a..e0491f03 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -35,6 +35,7 @@ export interface LeaderboardDetail { score: number; user_name: string; submission_id: number; + line_count?: number; }> >; } @@ -43,7 +44,6 @@ 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 1b9e1b6e..205a97e2 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -417,7 +417,6 @@ function LeaderboardWithSidebar() { navigationItems, navigationIndex, codes, - lineCounts, isOpen, isLoadingCodes, navigate, @@ -456,7 +455,6 @@ function LeaderboardWithSidebar() { navigationItems={navigationItems} navigationIndex={navigationIndex} codes={codes} - lineCounts={lineCounts} isLoadingCodes={isLoadingCodes} onClose={close} onNavigate={navigate} diff --git a/frontend/src/pages/leaderboard/components/RankingLists.tsx b/frontend/src/pages/leaderboard/components/RankingLists.tsx index ebe4b5cd..26cbf2fa 100644 --- a/frontend/src/pages/leaderboard/components/RankingLists.tsx +++ b/frontend/src/pages/leaderboard/components/RankingLists.tsx @@ -29,6 +29,7 @@ interface RankingItem { submission_id: number; submission_count?: number; submission_time?: string; + line_count?: number; } interface RankingsListProps { @@ -84,6 +85,10 @@ const styles: Record> = { fontFamily: "monospace", color: "text.secondary", }, + lineCount: { + fontFamily: "monospace", + color: "text.secondary", + }, }; export default function RankingsList({ @@ -160,6 +165,13 @@ export default function RankingsList({ } const isExpanded = expanded[field] ?? true; const visibleItems = isExpanded ? items : items.slice(0, 3); + const showLineCount = expired && items.some( + (item) => typeof item.line_count === "number", + ); + const scoreSize = isAdmin ? 2 : showLineCount ? 2 : 3; + const deltaSize = isAdmin ? 1 : showLineCount ? 2 : 3; + const fileSize = 3; + const adminSubmissionCountSize = showLineCount ? 1 : 2; return ( @@ -189,18 +201,18 @@ export default function RankingsList({ {item.user_name} {getMedalIcon(item.rank)} - + {formatMicroseconds(item.score)} - + {item.prev_score > 0 && `+${formatMicroseconds(item.prev_score)}`} - + + {showLineCount && ( + + + {typeof item.line_count === "number" + ? `LOC: ${item.line_count.toLocaleString()}` + : ""} + + + )} {isAdmin && ( - + Subs: {item.submission_count} diff --git a/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.test.tsx b/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.test.tsx deleted file mode 100644 index 43eb5752..00000000 --- a/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 29132b2f..77a44a12 100644 --- a/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.tsx +++ b/frontend/src/pages/leaderboard/components/SubmissionCodeSidebar.tsx @@ -22,7 +22,6 @@ interface SubmissionCodeSidebarProps { navigationItems: NavigationItem[]; navigationIndex: number; codes: Map; - lineCounts?: Map; isLoadingCodes?: boolean; onClose: () => void; onNavigate: (newIndex: number, item: NavigationItem) => void; @@ -35,7 +34,6 @@ export default function SubmissionCodeSidebar({ navigationItems, navigationIndex, codes, - lineCounts, isLoadingCodes = false, onClose, onNavigate, @@ -93,9 +91,6 @@ 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; @@ -41,7 +40,6 @@ 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; @@ -74,15 +72,6 @@ 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); @@ -124,7 +113,6 @@ export function SubmissionSidebarProvider({ navigationItems, navigationIndex, codes, - lineCounts, isOpen: !!selectedSubmission, isLoadingCodes, navigate, diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index c5b9ea0f..4f0b4ab2 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -1,5 +1,6 @@ import logging import time +from datetime import datetime, timezone from http import HTTPStatus from typing import Any, List @@ -45,6 +46,16 @@ def leaderboard(leaderboard_id: int): # 3. Data transformation transform_start = time.perf_counter() res = to_api_leaderboard_item(data) + try: + if _is_leaderboard_concluded(data["leaderboard"]["deadline"]): + _attach_line_counts(conn, leaderboard_id, res) + except Exception: + conn.rollback() + logger.warning( + "Failed to attach line counts for leaderboard_id=%s", + leaderboard_id, + exc_info=True, + ) transform_time = (time.perf_counter() - transform_start) * 1000 total_time = (time.perf_counter() - total_start) * 1000 @@ -122,6 +133,81 @@ def to_api_leaderboard_item(data: dict[str, Any]): } +def _is_leaderboard_concluded(deadline) -> bool: + if deadline is None: + return False + if isinstance(deadline, str): + try: + deadline = datetime.fromisoformat(deadline) + except ValueError: + return False + + now = datetime.now(deadline.tzinfo or timezone.utc) + if deadline.tzinfo is None: + now = now.replace(tzinfo=None) + + return deadline < now + + +def _attach_line_counts(conn, leaderboard_id: int, leaderboard: dict[str, Any]) -> None: + submission_ids = { + entry["submission_id"] + for ranking in leaderboard["rankings"].values() + for entry in ranking + if entry.get("submission_id") is not None + } + if not submission_ids: + return + + with conn.cursor() as cur: + cur.execute( + """ + SELECT s.id, cf.code + FROM leaderboard.submission s + JOIN leaderboard.code_files cf ON cf.id = s.code_id + WHERE s.leaderboard_id = %s + AND s.id = ANY(%s::int[]) + """, + (leaderboard_id, list(submission_ids)), + ) + rows = cur.fetchall() + + line_counts = { + submission_id: _count_lines_of_code(_decode_code_text(code)) + for submission_id, code in rows + } + + for ranking in leaderboard["rankings"].values(): + for entry in ranking: + line_count = line_counts.get(entry["submission_id"]) + if line_count is not None: + entry["line_count"] = line_count + + +def _count_lines_of_code(code_text: str) -> int: + if not code_text: + return 0 + return len(code_text.splitlines()) + + +def _decode_code_text(code_text) -> str: + if isinstance(code_text, memoryview): + return code_text.tobytes().decode("utf-8", errors="replace") + if isinstance(code_text, bytes): + return code_text.decode("utf-8", errors="replace") + if isinstance(code_text, str): + if code_text.startswith("\\x"): + try: + return bytes.fromhex(code_text[2:]).decode("utf-8", errors="replace") + except Exception: + logger.info("failed to decode hex code text", exc_info=True) + return code_text + return code_text + + logger.info("unexpected code text type: %s", type(code_text)) + return str(code_text) + + def _get_query(): query = """ WITH diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 6830e9f7..abf6e082 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -35,10 +35,6 @@ WEB_AUTH_HEADER = "X-Web-Auth-Id" MAX_CONTENT_LENGTH = 1 * 1024 * 1024 # 1MB max file size -# This blocks the leaderboard to show all the ranking codes when the leaderboard is ended -BLOCKED_CODE_LEADERBOARD_LIST: list[str] = ["598"] # leaderboard id to block show - - USE_MOCK_SUBMISSION: bool = os.environ.get( "USE_MOCK_SUBMISSION", "" ).lower() == "true" @@ -184,22 +180,15 @@ def list_codes_route(): ) try: - # if leaderboard is allowed, allow all users to see the leaderboard codes is_ended = is_leaderboard_ended(leaderboard_id) - is_allowed = str(leaderboard_id) not in BLOCKED_CODE_LEADERBOARD_LIST logger.info("[list_codes] leaderboard is ended: %s", is_ended) - logger.info("[list_codes] leaderboard is allowed: %s", is_allowed) - # if leaderboard is ended and allowed, allow all users to see the leaderboard codes - if is_ended and is_allowed: + # if leaderboard is ended, allow all users to see the leaderboard codes + if is_ended: logger.info( - "[list_codes] leaderboard is allowed, allow all users to see the leaderboard codes" - ) - results = list_codes( - leaderboard_id, - submission_ids, - include_line_count=True, + "[list_codes] leaderboard is ended, allow all users to see the leaderboard codes" ) + results = list_codes(leaderboard_id, submission_ids) return http_success( data={"results": results}, ) @@ -299,35 +288,24 @@ def list_submissions(): def list_codes( leaderboard_id: int, submission_ids: List[int], - *, - include_line_count: bool = False, -) -> List[dict[str, Any]]: +) -> Tuple[List[dict[str, Any]], str]: 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 = [] - for r in rows: - code = decodeCodeText(r[3]) - item = { + items = [ + { "submission_id": r[0], "leaderboard_id": r[1], "code_id": r[2], - "code": code, + "code": decodeCodeText(r[3]), } - if include_line_count: - item["line_count"] = count_lines_of_code(code) - items.append(item) + for r in rows + ] 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_leaderboard_api.py b/tests/api/test_leaderboard_api.py index 02f39f52..7bcfadcb 100644 --- a/tests/api/test_leaderboard_api.py +++ b/tests/api/test_leaderboard_api.py @@ -7,6 +7,81 @@ def test_leaderboard(client): assert b"conv2d" in response.data +def test_concluded_leaderboard_includes_line_counts(client): + response = client.get("/api/leaderboard/339") + assert response.status_code == 200 + + payload = response.get_json() + ranked_items = [ + item + for ranking in payload["data"]["rankings"].values() + for item in ranking + ] + + assert ranked_items + assert all(isinstance(item.get("line_count"), int) for item in ranked_items) + + +def test_active_leaderboard_omits_line_counts(client, app): + with app.app_context(): + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute( + """ + UPDATE leaderboard.leaderboard + SET deadline = NOW() + INTERVAL '1 day' + WHERE id = 339 + """ + ) + conn.commit() + + response = client.get("/api/leaderboard/339") + assert response.status_code == 200 + + payload = response.get_json() + ranked_items = [ + item + for ranking in payload["data"]["rankings"].values() + for item in ranking + ] + + assert ranked_items + assert all("line_count" not in item for item in ranked_items) + + +def test_leaderboard_line_counts_support_bytea_code_storage(client, app): + with app.app_context(): + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute( + """ + ALTER TABLE leaderboard.code_files DROP COLUMN hash; + ALTER TABLE leaderboard.code_files RENAME COLUMN code TO old_code; + ALTER TABLE leaderboard.code_files + ADD COLUMN code BYTEA NOT NULL DEFAULT ''; + UPDATE leaderboard.code_files + SET code = convert_to(old_code, 'UTF8'); + ALTER TABLE leaderboard.code_files ALTER COLUMN old_code DROP NOT NULL; + ALTER TABLE leaderboard.code_files ALTER COLUMN code DROP DEFAULT; + """ + ) + conn.commit() + + response = client.get("/api/leaderboard/339") + assert response.status_code == 200 + + payload = response.get_json() + line_counts = [ + item["line_count"] + for ranking in payload["data"]["rankings"].values() + for item in ranking + if "line_count" in item + ] + + assert line_counts + assert all(isinstance(line_count, int) for line_count in line_counts) + + def test_nonexistent_leaderboard(client): response = client.get("/api/leaderboard/1000000") assert response.status_code == 404 diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index 4fb3860e..3a4428dc 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -193,51 +193,6 @@ 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() @@ -358,88 +313,6 @@ 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 # ----------------------------