diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index e0491f0..f085e67 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -35,7 +35,6 @@ export interface LeaderboardDetail { score: number; user_name: string; submission_id: number; - line_count?: number; }> >; } @@ -44,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 205a97e..1b9e1b6 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/RankingLists.tsx b/frontend/src/pages/leaderboard/components/RankingLists.tsx index 26cbf2f..ebe4b5c 100644 --- a/frontend/src/pages/leaderboard/components/RankingLists.tsx +++ b/frontend/src/pages/leaderboard/components/RankingLists.tsx @@ -29,7 +29,6 @@ interface RankingItem { submission_id: number; submission_count?: number; submission_time?: string; - line_count?: number; } interface RankingsListProps { @@ -85,10 +84,6 @@ const styles: Record> = { fontFamily: "monospace", color: "text.secondary", }, - lineCount: { - fontFamily: "monospace", - color: "text.secondary", - }, }; export default function RankingsList({ @@ -165,13 +160,6 @@ 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 ( @@ -201,18 +189,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 new file mode 100644 index 0000000..43eb575 --- /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 77a44a1..29132b2 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/leaderboard.py b/kernelboard/api/leaderboard.py index c7e5c97..c5b9ea0 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -141,8 +141,7 @@ def _get_query(): ORDER BY CASE WHEN lang = 'Python' THEN 0 ELSE 1 END LIMIT 1 ) AS starter, - task->'benchmarks' AS benchmarks, - deadline < NOW() AS show_solution_code_metadata + task->'benchmarks' AS benchmarks FROM leaderboard.leaderboard WHERE id = %(leaderboard_id)s ), @@ -180,25 +179,11 @@ def _get_query(): s.file_name AS file_name, r.submission_id AS submission_id, COALESCE(sc.submission_count, 0) AS submission_count, - CASE - WHEN cf.code IS NULL THEN NULL - WHEN cf.code = '' THEN 0 - ELSE array_length( - regexp_split_to_array( - regexp_replace(cf.code, E'(\\r\\n|\\r|\\n)$', ''), - E'\\r\\n|\\r|\\n' - ), - 1 - ) - END AS line_count, RANK() OVER (PARTITION BY r.runner, u.id ORDER BY r.score ASC) AS rank FROM leaderboard.runs r JOIN leaderboard.submission s ON r.submission_id = s.id LEFT JOIN leaderboard.user_info u ON s.user_id = u.id LEFT JOIN submission_counts sc ON s.user_id = sc.user_id AND r.runner = sc.runner - LEFT JOIN leaderboard.code_files cf - ON cf.id = s.code_id - AND (SELECT show_solution_code_metadata FROM leaderboard_info) WHERE NOT r.secret AND r.score IS NOT NULL AND r.passed @@ -235,7 +220,6 @@ def _get_query(): SELECT jsonb_build_object( 'rankings', (SELECT jsonb_object_agg(g.gpu_type, ( SELECT jsonb_agg( - ( jsonb_build_object( 'user_name', r.user_name, 'score', r.score, @@ -244,12 +228,6 @@ def _get_query(): 'submission_count', r.submission_count, 'submission_time', r.submission_time ) - || - CASE - WHEN r.line_count IS NULL THEN '{}'::jsonb - ELSE jsonb_build_object('line_count', r.line_count) - END - ) ORDER BY r.score ASC ) FROM top_runs r WHERE r.runner = g.gpu_type))), diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index abf6e08..6830e9f 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -35,6 +35,10 @@ 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" @@ -180,15 +184,22 @@ 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, allow all users to see the leaderboard codes - if is_ended: + # if leaderboard is ended and allowed, allow all users to see the leaderboard codes + if is_ended and is_allowed: logger.info( - "[list_codes] leaderboard is ended, allow all users to see the leaderboard codes" + "[list_codes] leaderboard is allowed, allow all users to see the leaderboard codes" + ) + results = list_codes( + leaderboard_id, + submission_ids, + include_line_count=True, ) - results = list_codes(leaderboard_id, submission_ids) return http_success( data={"results": results}, ) @@ -288,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 3a4428d..4fb3860 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 # ----------------------------