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
1 change: 1 addition & 0 deletions frontend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface CodesResponse {
results: Array<{
submission_id: number;
code: string;
line_count?: number;
}>;
}

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/leaderboard/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ function LeaderboardWithSidebar() {
navigationItems,
navigationIndex,
codes,
lineCounts,
isOpen,
isLoadingCodes,
navigate,
Expand Down Expand Up @@ -455,6 +456,7 @@ function LeaderboardWithSidebar() {
navigationItems={navigationItems}
navigationIndex={navigationIndex}
codes={codes}
lineCounts={lineCounts}
isLoadingCodes={isLoadingCodes}
onClose={close}
onNavigate={navigate}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<SubmissionCodeSidebar
selectedSubmission={{
submissionId: 123,
userName: "alice",
fileName: "solution.py",
score: 12.5,
}}
navigationItems={[]}
navigationIndex={0}
codes={new Map([[123, "print('hello')\nprint('world')\n"]])}
lineCounts={new Map([[123, 2]])}
onClose={vi.fn()}
onNavigate={vi.fn()}
/>,
);

expect(screen.getByText("LOC")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});

it("does not show LOC without line count metadata", () => {
renderWithProviders(
<SubmissionCodeSidebar
selectedSubmission={{
submissionId: 123,
userName: "alice",
fileName: "solution.py",
score: 12.5,
}}
navigationItems={[]}
navigationIndex={0}
codes={new Map([[123, "print('hello')\n"]])}
onClose={vi.fn()}
onNavigate={vi.fn()}
/>,
);

expect(screen.queryByText("LOC")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface SubmissionCodeSidebarProps {
navigationItems: NavigationItem[];
navigationIndex: number;
codes: Map<number, string>;
lineCounts?: Map<number, number>;
isLoadingCodes?: boolean;
onClose: () => void;
onNavigate: (newIndex: number, item: NavigationItem) => void;
Expand All @@ -34,6 +35,7 @@ export default function SubmissionCodeSidebar({
navigationItems,
navigationIndex,
codes,
lineCounts,
isLoadingCodes = false,
onClose,
onNavigate,
Expand Down Expand Up @@ -91,6 +93,9 @@ export default function SubmissionCodeSidebar({
}, [onWidthChange]);

const drawerWidth = isMobile ? "100vw" : width;
const lineCount = selectedSubmission
? lineCounts?.get(selectedSubmission.submissionId)
: undefined;

return (
<Drawer
Expand Down Expand Up @@ -194,7 +199,8 @@ export default function SubmissionCodeSidebar({
{/* Metadata */}
{(selectedSubmission.timestamp ||
selectedSubmission.originalTimestamp ||
selectedSubmission.score !== undefined) && (
selectedSubmission.score !== undefined ||
lineCount !== undefined) && (
<Box
sx={{
px: 2,
Expand Down Expand Up @@ -256,6 +262,20 @@ export default function SubmissionCodeSidebar({
</Typography>
</Box>
)}
{lineCount !== undefined && (
<Box>
<Typography
variant="caption"
color="text.secondary"
display="block"
>
LOC
</Typography>
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>
{lineCount.toLocaleString()}
</Typography>
</Box>
)}
</Box>
{selectedSubmission.fileName && (
<Typography
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface SubmissionSidebarStateType {
navigationItems: NavigationItem[];
navigationIndex: number;
codes: Map<number, string>;
lineCounts: Map<number, number>;
isOpen: boolean;
isLoadingCodes: boolean;
navigate: (newIndex: number, item: NavigationItem) => void;
Expand All @@ -40,6 +41,7 @@ export function SubmissionSidebarProvider({
const [navigationItems, setNavigationItems] = useState<NavigationItem[]>([]);
const [navigationIndex, setNavigationIndex] = useState(0);
const [codes, setCodes] = useState<Map<number, string>>(new Map());
const [lineCounts, setLineCounts] = useState<Map<number, number>>(new Map());
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
const codesRef = useRef(codes);
codesRef.current = codes;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -113,6 +124,7 @@ export function SubmissionSidebarProvider({
navigationItems,
navigationIndex,
codes,
lineCounts,
isOpen: !!selectedSubmission,
isLoadingCodes,
navigate,
Expand Down
29 changes: 22 additions & 7 deletions kernelboard/api/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)
Expand Down Expand Up @@ -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")
Expand Down
127 changes: 127 additions & 0 deletions tests/api/test_submission_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
# ----------------------------
Expand Down
Loading