Files
OJ2/apps/web/src/admin/problem/components/caseRows.ts
T
2026-10-26 13:52:24 +08:00

201 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TEST_CASE_EDIT_MAX_CASES, TEST_CASE_EDIT_MAX_FILE_BYTES, TEST_CASE_MAX_CASES } from '@oj2/contract'
import type { RunnableLanguage } from '@oj2/contract'
import { strFromU8, unzipSync } from 'fflate'
import type { Ref } from 'vue'
import { errorMessage } from 'utils/api'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import { trialRun } from 'utils/judge'
/**
* 编程题出题页的测试数据(设计稿「编程题出题页」B):一组一行,输出由标准答案在判题机上
* 跑出来,勾上「当例子」的几组按顺序写进题面的例子。
*
* 没有标准答案时(老题,954 道里 497 道)输出是手写的 —— 当初上传的文件原样读回来,
* 老师直接改;写上答案以后就改成跟着答案跑。
*/
export interface CaseRow {
key: number
input: string
/** 有标准答案时是答案跑出来的,没有时是手写的 */
output: string
example: boolean
/** 从文件读回来时存着的输出。答案跑出来的和它不一样,就标「变了」 */
saved: string | null
/** 最近一次跑完对应的是哪一版(见 signatureOf) */
ranFor: string
/** 标准答案在这组上没跑通的原因 */
error: string
}
/** 拿来跑测试数据的那份标准答案。已经套好了题目模板的前后两段 */
export interface AnswerSource {
language: RunnableLanguage
code: string
}
export type CaseStatus = 'manual' | 'empty' | 'running' | 'error' | 'changed' | 'ok'
let nextKey = 0
export function newRow(input = '', output = '', example = false, saved: string | null = null) {
return {
key: nextKey++,
input,
output,
example,
saved,
ranFor: '',
error: '',
} satisfies CaseRow
}
/**
* 测试点文件读回来时的样子:CRLF 统一成 LF,输入去掉末尾那一个换行(文件里都有,摆在输入框里
* 就是多出来的一行空白;上传时 caseFiles 会补回去),输出去掉末尾空白(判题机比对时本来就不看)
*/
export function normalizeCase(input: string, output: string) {
return {
input: input.replace(/\r\n/g, '\n').replace(/\n$/, ''),
output: output.replace(/\r\n/g, '\n').trimEnd(),
}
}
/** 判题机比对时去掉的是整段输出末尾的空白(见 services/test-case.ts 的 rstrip),这里同一个口径 */
export function sameOutput(a: string, b: string) {
return a.replace(/\r\n/g, '\n').trimEnd() === b.replace(/\r\n/g, '\n').trimEnd()
}
const signatureOf = (source: AnswerSource, row: CaseRow) => JSON.stringify([source.language, source.code, row.input])
export function caseStatus(row: CaseRow, source: AnswerSource | null): CaseStatus {
if (!source) return 'manual'
if (row.ranFor !== signatureOf(source, row)) return 'running'
if (row.error) return 'error'
if (row.saved !== null && !sameOutput(row.saved, row.output)) return 'changed'
return 'ok'
}
/** 答案报错时给老师看的一句话:状态 + 输出的最后一行(Python 的异常原文就在那) */
function describeFailure(result: SubmissionStatus, output: string, line?: number | null) {
const name = JUDGE_STATUS[result]?.name ?? '没跑通'
const last = output.trimEnd().split('\n').at(-1)?.trim()
const where = line ? `(第 ${line} 行)` : ''
return last ? `${name}:${last}${where}` : `${name}${where}`
}
/**
* 测试数据的重跑:一次只跑一批(后端每人同时最多两批,见 judge/trial.ts),
* 跑的过程中又改了就记一笔,跑完再补一轮。
*/
export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | null>) {
const running = ref(false)
let again = false
async function runAll() {
if (running.value) {
again = true
return
}
const answer = source.value
if (!answer) return
const todo = rows.value.filter((row) => row.ranFor !== signatureOf(answer, row))
if (!todo.length) return
running.value = true
try {
for (let start = 0; start < todo.length; start += 50) {
const batch = todo.slice(start, start + 50)
const signatures = batch.map((row) => signatureOf(answer, row))
try {
const response = await trialRun({
language: answer.language,
code: answer.code,
cases: batch.map((row) => ({ input: row.input })),
})
batch.forEach((row, i) => {
row.ranFor = signatures[i]!
if (response.status === 'compile-error') {
row.output = ''
row.error = `标准答案编译不过:${response.message.trim().split('\n').at(-1) ?? ''}`
} else if (response.status === 'too-much-output') {
row.output = ''
row.error = '输出太多(多半是死循环在不停打印)'
} else {
const run = response.cases[i]!
if (run.result === SubmissionStatus.accepted) {
row.output = run.output.trimEnd()
row.error = ''
} else {
row.output = ''
row.error = describeFailure(run.result, run.output, run.runtimeError?.line)
}
}
})
} catch (err) {
// 判题机忙、连不上:这一批记成没跑通,别无限重试;老师点「重跑」再来
const reason = errorMessage(err, '判题机暂时连不上')
batch.forEach((row, i) => {
row.ranFor = signatures[i]!
row.error = reason
})
}
}
} finally {
running.value = false
if (again) {
again = false
void runAll()
}
}
}
/** 「重跑」:把没跑通的几组作废重来 */
function rerunFailed() {
rows.value.forEach((row) => {
if (row.error) row.ranFor = ''
})
void runAll()
}
return { running, runAll, rerunFailed }
}
/**
* 导入 zip:在浏览器里拆成一组组。和后端 collectPairs 同一个规矩 —— 从 1.in / 1.out
* 开始连续编号,断了就停。太大(见契约 TEST_CASE_EDIT_*)就不拆,交给调用方直接上传。
*/
export function readCaseZip(archive: Uint8Array): { ok: true; cases: { input: string; output: string }[] } | { ok: false; reason: string } {
let files: Record<string, Uint8Array>
try {
files = unzipSync(archive, { filter: (file) => /^\d+\.(in|out)$/.test(file.name) })
} catch {
return { ok: false, reason: '压缩包损坏,或者不是 zip' }
}
const cases: { input: string; output: string }[] = []
for (let i = 1; files[`${i}.in`] && files[`${i}.out`]; i += 1) {
const input = files[`${i}.in`]!
const output = files[`${i}.out`]!
if (input.length > TEST_CASE_EDIT_MAX_FILE_BYTES || output.length > TEST_CASE_EDIT_MAX_FILE_BYTES) return { ok: false, reason: 'too-large' }
cases.push(normalizeCase(strFromU8(input), strFromU8(output)))
}
if (!cases.length) return { ok: false, reason: '压缩包里没有从 1.in / 1.out 开始连续编号的测试点' }
if (cases.length > TEST_CASE_MAX_CASES)
return {
ok: false,
reason: `测试数据最多 ${TEST_CASE_MAX_CASES} 组,这个压缩包里有 ${cases.length} 组,挑有代表性的留下`,
}
if (cases.length > TEST_CASE_EDIT_MAX_CASES) return { ok: false, reason: 'too-large' }
return { ok: true, cases }
}
/** 打包上传前的文件内容:CRLF 统一成 LF,非空输入补结尾换行(C 的 scanf / gets 读最后一行更稳) */
export function caseFiles(rows: { input: string; output: string }[]) {
const lf = (text: string) => text.replace(/\r\n/g, '\n')
return rows.flatMap((row, i) => {
const input = lf(row.input)
return [
{ name: `${i + 1}.in`, content: input && !input.endsWith('\n') ? `${input}\n` : input },
{ name: `${i + 1}.out`, content: lf(row.output) },
]
})
}