Skip to content

Commit

Permalink
feat(be): calculate contest participants score (#1643)
Browse files Browse the repository at this point in the history
* fix(be): add finish time field on contest record

* feat(be): add finish time

* feat(be): implement the calculation of contest participants score

* fix(be): change seed script for test

* fix(be): add accepted problem num increment logic

* fix(be): fix conflict exception message

* fix(be): refactor logic using early return

* fix(be): add comment to explain the fix

* test(be): add bruno api docs

* fix(be): resolve client submission test error

* fix(be): add to be equal

* fix(be): add promise resolve value of submission update stub

* test(be): add a test scenario for creating submission with constest id

* test(be): add test scenario for creating submission with workbook id

* test(be): add test scenario for creating submission for already AC problem

* test(be): add scenario for updating submission result with conest id

* chore(be): add comments on schema

* feat(be): add contest not found handling logic

* docs(be): add no contest found bru doc

* docs(be): add create submission for contest with accpeted code burno doc

* docs(be): add create submission for contest with wrong answer code bruno doc

* docs(be): add create submission for contest with time limit exceeded

* test(be): add contest find or throw mocking data

* test(be): add enable copy paste field to resolve type check error

* docs(be): fix bruno api docs description and assertion

* test(be): add invitation code to contest mock data

---------

Co-authored-by: Jiyun Park <[email protected]>
  • Loading branch information
2 people authored and jihorobert committed Jul 28, 2024
1 parent 4c2b50a commit 0c47ae7
Show file tree
Hide file tree
Showing 10 changed files with 490 additions and 35 deletions.
93 changes: 89 additions & 4 deletions apps/backend/apps/client/src/submission/submission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { NotFoundException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Test, type TestingModule } from '@nestjs/testing'
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'
import { Language, ResultStatus, Role, type User } from '@prisma/client'
import {
Language,
ResultStatus,
Role,
type Contest,
type User
} from '@prisma/client'
import { expect } from 'chai'
import { plainToInstance } from 'class-transformer'
import { TraceService } from 'nestjs-otel'
Expand Down Expand Up @@ -38,6 +44,9 @@ const db = {
findUnique: stub(),
update: stub()
},
contest: {
findFirstOrThrow: stub()
},
contestProblem: {
findUniqueOrThrow: stub(),
findFirstOrThrow: stub()
Expand All @@ -46,7 +55,8 @@ const db = {
findUniqueOrThrow: stub()
},
contestRecord: {
findUniqueOrThrow: stub()
findUniqueOrThrow: stub(),
update: stub()
},
user: {
findFirstOrThrow: stub(),
Expand All @@ -57,6 +67,21 @@ const db = {

const CONTEST_ID = 1
const WORKBOOK_ID = 1
const mockContest: Contest = {
id: CONTEST_ID,
createdById: 1,
groupId: 1,
title: 'SKKU Coding Platform 모의대회',
description: 'test',
invitationCode: 'test',
startTime: new Date(),
endTime: new Date(),
isVisible: true,
isRankVisible: true,
enableCopyPaste: true,
createTime: new Date(),
updateTime: new Date()
}

describe('SubmissionService', () => {
let service: SubmissionService
Expand Down Expand Up @@ -121,6 +146,7 @@ describe('SubmissionService', () => {
describe('submitToContest', () => {
it('should call createSubmission', async () => {
const createSpy = stub(service, 'createSubmission')
db.contest.findFirstOrThrow(mockContest)
db.contestRecord.findUniqueOrThrow.resolves({
contest: {
groupId: 1,
Expand Down Expand Up @@ -216,6 +242,60 @@ describe('SubmissionService', () => {
expect(publishSpy.calledOnce).to.be.true
})

it('should create submission with contestId', async () => {
const publishSpy = stub(amqpConnection, 'publish')
db.problem.findUnique.resolves(problems[0])
db.submission.create.resolves({
...submissions[0],
contestId: CONTEST_ID
})
db.submission.findMany.resolves(submissions)
expect(
await service.createSubmission(
submissionDto,
problems[0],
submissions[0].userId,
{ contestId: CONTEST_ID }
)
).to.be.deep.equal({ ...submissions[0], contestId: CONTEST_ID })
expect(publishSpy.calledOnce).to.be.true
})

it('should throw conflict found exception if user has already gotten AC', async () => {
const publishSpy = stub(amqpConnection, 'publish')
db.problem.findUnique.resolves(problems[0])
db.submission.create.resolves(submissions[0])
db.submission.findMany.resolves([{ result: ResultStatus.Accepted }])

await expect(
service.createSubmission(
submissionDto,
problems[0],
submissions[0].userId,
{ contestId: CONTEST_ID }
)
).to.be.rejectedWith(ConflictFoundException)
expect(publishSpy.calledOnce).to.be.false
})

it('should create submission with workbookId', async () => {
const publishSpy = stub(amqpConnection, 'publish')
db.problem.findUnique.resolves(problems[0])
db.submission.create.resolves({
...submissions[0],
workbookId: WORKBOOK_ID
})
expect(
await service.createSubmission(
submissionDto,
problems[0],
submissions[0].userId,
{ workbookId: WORKBOOK_ID }
)
).to.be.deep.equal({ ...submissions[0], workbookId: WORKBOOK_ID })
expect(publishSpy.calledOnce).to.be.true
})

it('should throw exception if the language is not supported', async () => {
const publishSpy = stub(amqpConnection, 'publish')
db.problem.findUnique.resolves(problems[0])
Expand Down Expand Up @@ -280,19 +360,24 @@ describe('SubmissionService', () => {
]
}
}

db.submission.update.resolves(submissions[0])
await expect(service.handleJudgerMessage(target)).to.be.rejectedWith(
UnprocessableDataException
)
db.submission.update.reset()
})
})

describe('updateSubmissionResult', () => {
it('should call update submission result', async () => {
db.submission.update.reset()
db.submission.update.resolves(submissions[0])
db.submission.update.resolves({
...submissions[0],
contestId: CONTEST_ID
})
db.problem.findFirstOrThrow.resolves(problems[0])
db.problem.update.reset()

submissionResults.forEach((result, index) => {
db.submissionResult.create.onCall(index).resolves(result)
})
Expand Down
119 changes: 105 additions & 14 deletions apps/backend/apps/client/src/submission/submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,18 @@ export class SubmissionService implements OnModuleInit {
groupId = OPEN_SPACE_ID
) {
const now = new Date()

await this.prisma.contest.findFirstOrThrow({
where: {
id: contestId,
groupId,
startTime: {
lte: now
},
endTime: {
gt: now
}
}
})
const { contest } = await this.prisma.contestRecord.findUniqueOrThrow({
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down Expand Up @@ -192,8 +203,6 @@ export class SubmissionService implements OnModuleInit {
userId: number,
idOptions?: { contestId?: number; workbookId?: number }
) {
let submission: Submission

if (!problem.languages.includes(submissionDto.language)) {
throw new ConflictFoundException(
`This problem does not support language ${submissionDto.language}`
Expand All @@ -219,23 +228,45 @@ export class SubmissionService implements OnModuleInit {
...data
}

if (idOptions && idOptions.contestId) {
submission = await this.prisma.submission.create({
data: { ...submissionData, contestId: idOptions.contestId }
// idOptions Object가 undefined이거나 contestId와 workbookId가 모두 없는 경우
if (
idOptions === undefined ||
(!idOptions.contestId && !idOptions.workbookId)
) {
const submission = await this.prisma.submission.create({
data: submissionData
})
} else if (idOptions && idOptions.workbookId) {
submission = await this.prisma.submission.create({
data: { ...submissionData, workbookId: idOptions.workbookId }
await this.publishJudgeRequestMessage(code, submission)
return submission
}

if (idOptions.contestId) {
// 해당 contestId에 해당하는 Contest에서 해당 problemId에 해당하는 문제로 AC를 받은 submission이 있는지 확인
const hasPassed = await this.hasPassedProblem(userId, {
problemId: problem.id,
contestId: idOptions.contestId
})
} else {
submission = await this.prisma.submission.create({
data: submissionData
if (hasPassed) {
throw new ConflictFoundException(
'You have already gotten AC for this problem'
)
}
const submission = await this.prisma.submission.create({
data: { ...submissionData, contestId: idOptions.contestId }
})

await this.publishJudgeRequestMessage(code, submission)
return submission
}

await this.publishJudgeRequestMessage(code, submission)
if (idOptions.workbookId) {
const submission = await this.prisma.submission.create({
data: { ...submissionData, workbookId: idOptions.workbookId }
})

return submission
await this.publishJudgeRequestMessage(code, submission)
return submission
}
}

isValidCode(code: Snippet[], language: Language, templates: Template[]) {
Expand Down Expand Up @@ -439,6 +470,66 @@ export class SubmissionService implements OnModuleInit {
}
})
}

// contestId가 있는 경우에는 contestRecord 업데이트
// participants들의 score와 penalty를 업데이트
if (submission.userId && submission.contestId) {
const contestId = submission.contestId
const userId = submission.userId
let toBeAddedScore = 0,
toBeAddedPenalty = 0,
toBeAddedAcceptedProblemNum = 0,
isFinishTimeToBeUpdated = false
const contestRecord = await this.prisma.contestRecord.findUniqueOrThrow({
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention
contestId_userId: {
contestId,
userId
}
},
select: {
id: true,
acceptedProblemNum: true,
score: true,
totalPenalty: true,
finishTime: true
}
})

if (resultStatus === ResultStatus.Accepted) {
toBeAddedScore = (
await this.prisma.contestProblem.findFirstOrThrow({
where: {
contestId,
problemId: submission.problemId
},
select: {
score: true
}
})
).score
isFinishTimeToBeUpdated = true
toBeAddedAcceptedProblemNum = 1
} else {
toBeAddedPenalty = 1
}

await this.prisma.contestRecord.update({
where: {
id: contestRecord.id
},
data: {
acceptedProblemNum:
contestRecord.acceptedProblemNum + toBeAddedAcceptedProblemNum,
score: contestRecord.score + toBeAddedScore,
totalPenalty: contestRecord.totalPenalty + toBeAddedPenalty,
finishTime: isFinishTimeToBeUpdated
? submission.updateTime
: contestRecord.finishTime
}
})
}
}

// FIXME: Workbook 구분
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "contest_record" ADD COLUMN "finish_time" TIMESTAMP(3);
24 changes: 14 additions & 10 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ model ContestProblem {
contestId Int @map("contest_id")
problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade)
problemId Int @map("problem_id")
// 각 문제의 점수 (비율 아님)
score Int @default(0)
createTime DateTime @default(now()) @map("create_time")
updateTime DateTime @updatedAt @map("update_time")
Expand All @@ -321,16 +322,19 @@ model Announcement {
}

model ContestRecord {
id Int @id @default(autoincrement())
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
contestId Int @map("contest_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int? @map("user_id")
acceptedProblemNum Int @default(0) @map("accepted_problem_num")
score Int @default(0)
totalPenalty Int @default(0) @map("total_penalty")
createTime DateTime @default(now()) @map("create_time")
updateTime DateTime @updatedAt @map("update_time")
id Int @id @default(autoincrement())
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
contestId Int @map("contest_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int? @map("user_id")
acceptedProblemNum Int @default(0) @map("accepted_problem_num")
score Int @default(0)
// finishTime: Pariticipant가 가장 최근에 AC를 받은 시각
finishTime DateTime? @map("finish_time")
// totalPenalty: Submission 시, AC를 받지 못했을 때, 올라가는 Counter
totalPenalty Int @default(0) @map("total_penalty")
createTime DateTime @default(now()) @map("create_time")
updateTime DateTime @updatedAt @map("update_time")
@@unique([contestId, userId])
@@map("contest_record")
Expand Down
9 changes: 4 additions & 5 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,7 +1301,8 @@ const createContests = async () => {
data: {
order: problem.id - 1,
contestId: ongoingContests[0].id,
problemId: problem.id
problemId: problem.id,
score: problem.id * 10
}
})
}
Expand Down Expand Up @@ -1700,7 +1701,6 @@ const createCodeDrafts = async () => {

const createContestRecords = async () => {
const contestRecords: ContestRecord[] = []
let i = 0
// group 1 users
const group1Users = await prisma.userGroup.findMany({
where: {
Expand All @@ -1712,12 +1712,11 @@ const createContestRecords = async () => {
data: {
userId: user.userId,
contestId: 1,
acceptedProblemNum: user.userId,
totalPenalty: i * 60
acceptedProblemNum: 0,
totalPenalty: 0
}
})
contestRecords.push(contestRecord)
i++
}

// upcoming contest에 참가한 User 1의 contest register를 un-register하는 기능과,
Expand Down
Loading

0 comments on commit 0c47ae7

Please sign in to comment.