Skip to content

Commit 8ff4b32

Browse files
authored
Merge pull request #3589 from AtCoder-NoviSteps/feature/#3583-workbook-voting
[feat] 問題集の詳細ページのグレードアイコンから投票できるようにする
2 parents 9b63bd3 + 8369ebe commit 8ff4b32

12 files changed

Lines changed: 166 additions & 58 deletions

File tree

prisma/seed.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ async function addTask(
178178
) {
179179
// Note: Task-Tag relationships are handled separately via TaskTag table
180180
await taskFactory.create({
181-
contest_type: classifyContest(task.contest_id),
181+
contest_type: classifyContest(task.contest_id) ?? undefined,
182182
contest_id: task.contest_id,
183183
task_table_index: task.problem_index,
184184
task_id: task.id,

src/features/tasks/components/contest-table/TaskTableBodyCell.svelte

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,15 @@
4141
</div>
4242

4343
{#snippet taskGradeLabel(taskResult: TaskResult)}
44-
<VotableGrade {taskResult} {isLoggedIn} {isAtCoderVerified} {estimatedGrade} />
44+
<VotableGrade
45+
{taskResult}
46+
{isLoggedIn}
47+
{isAtCoderVerified}
48+
{estimatedGrade}
49+
defaultPadding={0.25}
50+
defaultWidth={6}
51+
reducedWidth={6}
52+
/>
4553
{/snippet}
4654

4755
{#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)}

src/features/votes/actions/vote_actions.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
import { fail } from '@sveltejs/kit';
2-
import { TaskGrade } from '@prisma/client';
2+
import type { TaskGrade } from '@prisma/client';
33

44
import { upsertVoteGradeTables } from '$features/votes/services/vote_grade';
55
import {
6-
BAD_REQUEST,
76
FORBIDDEN,
87
INTERNAL_SERVER_ERROR,
98
UNAUTHORIZED,
109
} from '$lib/constants/http-response-status-codes';
1110

12-
// Non-votable grades that must be excluded from valid vote submissions.
13-
const NON_VOTABLE_GRADES = new Set<string>([TaskGrade.PENDING]);
11+
import type { VoteAbsoluteGradeInput } from '$features/votes/zod/schema';
1412

1513
export const voteAbsoluteGrade = async ({
16-
request,
1714
locals,
15+
data,
1816
}: {
19-
request: Request;
2017
locals: App.Locals;
18+
data: VoteAbsoluteGradeInput;
2119
}) => {
22-
const formData = await request.formData();
2320
const session = await locals.auth.validate();
2421

2522
if (!session || !session.user || !session.user.userId) {
@@ -34,25 +31,8 @@ export const voteAbsoluteGrade = async ({
3431
});
3532
}
3633

37-
const userId = session.user.userId;
38-
const taskIdRaw = formData.get('taskId');
39-
const gradeRaw = formData.get('grade');
40-
41-
if (
42-
typeof taskIdRaw !== 'string' ||
43-
!taskIdRaw ||
44-
typeof gradeRaw !== 'string' ||
45-
!(Object.values(TaskGrade) as string[]).includes(gradeRaw) ||
46-
NON_VOTABLE_GRADES.has(gradeRaw)
47-
) {
48-
return fail(BAD_REQUEST, { message: 'Invalid request parameters.' });
49-
}
50-
51-
const taskId = taskIdRaw;
52-
const grade = gradeRaw as TaskGrade;
53-
5434
try {
55-
await upsertVoteGradeTables(userId, taskId, grade);
35+
await upsertVoteGradeTables(session.user.userId, data.taskId, data.grade as TaskGrade);
5636
return { success: true as const };
5737
} catch (error) {
5838
console.error('Failed to vote absolute grade: ', error);

src/features/votes/components/VotableGrade.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,20 @@
3535
// undefined means the prop was not passed — treat as verified to maintain backward compatibility.
3636
isAtCoderVerified?: boolean;
3737
estimatedGrade?: TaskGrade | null;
38+
defaultPadding?: number;
39+
defaultWidth?: number;
40+
reducedWidth?: number;
3841
}
3942
40-
let { taskResult, isLoggedIn, isAtCoderVerified, estimatedGrade }: Props = $props();
43+
let {
44+
taskResult,
45+
isLoggedIn,
46+
isAtCoderVerified,
47+
estimatedGrade,
48+
defaultPadding = 1,
49+
defaultWidth = 10,
50+
reducedWidth = 8,
51+
}: Props = $props();
4152
4253
// 表示用のグレード(投票後に画面リロードなしで差し替えるためのローカル状態)
4354
// PENDING かつ estimatedGrade(集計済み中央値)があればそれを優先表示。
@@ -185,7 +196,7 @@
185196
: ''}{isProvisional ? ', provisional' : ''}
186197
</span>
187198

188-
<GradeLabel taskGrade={displayGrade} defaultPadding={0.25} defaultWidth={6} reducedWidth={6} />
199+
<GradeLabel taskGrade={displayGrade} {defaultPadding} {defaultWidth} {reducedWidth} />
189200

190201
{#if taskResult.grade !== TaskGrade.PENDING && latestMedianGrade}
191202
<RelativeEvaluationBadge
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { TaskGrade } from '@prisma/client';
3+
4+
import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema';
5+
6+
describe('voteAbsoluteGradeSchema', () => {
7+
function validate(data: unknown): boolean {
8+
return voteAbsoluteGradeSchema.safeParse(data).success;
9+
}
10+
11+
describe('valid inputs', () => {
12+
test('ABC task with Q-grade', () => {
13+
expect(validate({ taskId: 'abc408_a', grade: TaskGrade.Q7 })).toBe(true);
14+
});
15+
16+
test('ARC task with D-grade', () => {
17+
expect(validate({ taskId: 'arc188_c', grade: TaskGrade.D3 })).toBe(true);
18+
});
19+
20+
test('ABC G problem with D6 grade', () => {
21+
expect(validate({ taskId: 'abc399_g', grade: TaskGrade.D6 })).toBe(true);
22+
});
23+
24+
test('AOJ 4-digit task ID', () => {
25+
expect(validate({ taskId: '1001', grade: TaskGrade.Q5 })).toBe(true);
26+
});
27+
28+
test('taskId with surrounding whitespace is trimmed', () => {
29+
expect(validate({ taskId: ' abc408_a ', grade: TaskGrade.Q7 })).toBe(true);
30+
});
31+
});
32+
33+
describe('boundary values', () => {
34+
test('taskId of exactly 1 character is accepted', () => {
35+
expect(validate({ taskId: 'a', grade: TaskGrade.Q5 })).toBe(true);
36+
});
37+
38+
test('Q11 (first valid grade) is accepted', () => {
39+
expect(validate({ taskId: 'abc408_a', grade: TaskGrade.Q11 })).toBe(true);
40+
});
41+
42+
test('D6 (last valid grade) is accepted', () => {
43+
expect(validate({ taskId: 'abc399_g', grade: TaskGrade.D6 })).toBe(true);
44+
});
45+
});
46+
47+
describe('invalid inputs', () => {
48+
test('empty taskId is rejected', () => {
49+
expect(validate({ taskId: '', grade: TaskGrade.Q5 })).toBe(false);
50+
});
51+
52+
test('whitespace-only taskId is rejected after trimming', () => {
53+
expect(validate({ taskId: ' ', grade: TaskGrade.Q5 })).toBe(false);
54+
});
55+
56+
test('PENDING grade is rejected', () => {
57+
expect(validate({ taskId: 'abc408_a', grade: TaskGrade.PENDING })).toBe(false);
58+
});
59+
60+
test('unknown grade string is rejected', () => {
61+
expect(validate({ taskId: 'abc408_a', grade: 'INVALID' })).toBe(false);
62+
});
63+
64+
test('missing grade is rejected', () => {
65+
expect(validate({ taskId: 'abc408_a' })).toBe(false);
66+
});
67+
68+
test('missing taskId is rejected', () => {
69+
expect(validate({ grade: TaskGrade.Q5 })).toBe(false);
70+
});
71+
72+
test('null taskId is rejected', () => {
73+
expect(validate({ taskId: null, grade: TaskGrade.Q5 })).toBe(false);
74+
});
75+
76+
test('null grade is rejected', () => {
77+
expect(validate({ taskId: 'abc408_a', grade: null })).toBe(false);
78+
});
79+
});
80+
});

src/features/votes/zod/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { z } from 'zod';
2+
import { TaskGrade } from '@prisma/client';
3+
4+
export const voteAbsoluteGradeSchema = z.object({
5+
taskId: z.string().trim().min(1),
6+
grade: z
7+
.nativeEnum(TaskGrade)
8+
.refine((val) => val !== TaskGrade.PENDING, { message: 'Cannot vote for PENDING grade' }),
9+
});
10+
11+
export type VoteAbsoluteGradeInput = z.infer<typeof voteAbsoluteGradeSchema>;

src/features/workbooks/stores/replenishment_workbook.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test, vi } from 'vitest';
1+
import { expect, test, vi, type Mock } from 'vitest';
22

33
import { replenishmentWorkBooksStore } from '$features/workbooks/stores/replenishment_workbook.svelte';
44

@@ -47,7 +47,7 @@ describe('Replenishment workbooks store', () => {
4747

4848
// Note: This test is skipped because it is not possible to mock localStorage in JSDOM.
4949
test.skip('persists state in localStorage', () => {
50-
(mockLocalStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify(false));
50+
(mockLocalStorage.getItem as Mock).mockReturnValue(JSON.stringify(false));
5151

5252
replenishmentWorkBooksStore.toggleView();
5353

src/lib/types/auth_forms.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export type AuthFormConstraints = {
1919
password?: FieldConstraints;
2020
};
2121

22+
type SchemaShape = Record<string, unknown>;
23+
2224
/**
2325
* Represents the state and data structure for authentication forms.
2426
*
@@ -31,7 +33,7 @@ export type AuthFormConstraints = {
3133
* @property {string} data.password - The password field value
3234
* @property {Record<string, string[]>} errors - Collection of validation errors keyed by field name
3335
* @property {AuthFormConstraints} [constraints] - Optional validation constraints for the form
34-
* @property {Record<string, unknown>} [shape] - Optional form schema or structure definition
36+
* @property {SchemaShape} [shape] - Optional schema shape for nested error mapping
3537
* @property {string} message - General message associated with the form (success, error, etc.)
3638
*/
3739
export type AuthForm = {
@@ -41,7 +43,7 @@ export type AuthForm = {
4143
data: { username: string; password: string };
4244
errors: Record<string, string[]>;
4345
constraints?: AuthFormConstraints;
44-
shape?: Record<string, unknown>;
46+
shape?: SchemaShape;
4547
message: string;
4648
};
4749

src/routes/problems/+page.server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import { type Actions } from '@sveltejs/kit';
1+
import { fail, type Actions } from '@sveltejs/kit';
2+
import { superValidate } from 'sveltekit-superforms';
3+
import { zod4 } from 'sveltekit-superforms/adapters';
24

35
import * as task_crud from '$lib/services/task_results';
46
import { getVoteGradeStatistics } from '$features/votes/services/vote_statistics';
57
import type { TaskResults } from '$lib/types/task';
68
import { Roles } from '$lib/types/user';
79
import { updateTaskResult } from '$lib/actions/update_task_result';
810
import { voteAbsoluteGrade } from '@/features/votes/actions/vote_actions';
11+
import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema';
12+
import { BAD_REQUEST } from '$lib/constants/http-response-status-codes';
913

1014
// 一覧表ページは、ログインしていなくても閲覧できるようにする
1115
export async function load({ locals, url }) {
@@ -53,6 +57,10 @@ export const actions = {
5357
return await updateTaskResult({ request, locals }, operationLog);
5458
},
5559
voteAbsoluteGrade: async ({ request, locals }) => {
56-
return await voteAbsoluteGrade({ request, locals });
60+
const form = await superValidate(request, zod4(voteAbsoluteGradeSchema));
61+
if (!form.valid) {
62+
return fail(BAD_REQUEST, { form });
63+
}
64+
return await voteAbsoluteGrade({ locals, data: form.data });
5765
},
5866
} satisfies Actions;

src/routes/votes/[slug]/+page.server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { error } from '@sveltejs/kit';
1+
import { error, fail } from '@sveltejs/kit';
2+
import { superValidate } from 'sveltekit-superforms';
3+
import { zod4 } from 'sveltekit-superforms/adapters';
24
import type { Actions, PageServerLoad } from './$types';
35

46
import { getTask } from '$lib/services/tasks';
@@ -8,6 +10,8 @@ import {
810
getVoteStatsByTaskId,
911
} from '$features/votes/services/vote_statistics';
1012
import { voteAbsoluteGrade } from '$features/votes/actions/vote_actions';
13+
import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema';
14+
import { BAD_REQUEST } from '$lib/constants/http-response-status-codes';
1115

1216
export const load: PageServerLoad = async ({ locals, params }) => {
1317
const session = await locals.auth.validate();
@@ -44,6 +48,10 @@ export const load: PageServerLoad = async ({ locals, params }) => {
4448

4549
export const actions: Actions = {
4650
voteAbsoluteGrade: async ({ request, locals }) => {
47-
return await voteAbsoluteGrade({ request, locals });
51+
const form = await superValidate(request, zod4(voteAbsoluteGradeSchema));
52+
if (!form.valid) {
53+
return fail(BAD_REQUEST, { form });
54+
}
55+
return await voteAbsoluteGrade({ locals, data: form.data });
4856
},
4957
};

0 commit comments

Comments
 (0)