judge: support generate testdata (#695)

Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
undefinedandpanda authored and GitHub committed 2023-12-14 17:03:42 +08:00
1 parent b83b2e792e
commit 55b4e1aaf0
27 files changed
+454 -68

No files matched your search

+2 -2
View File
@@ -50,8 +50,8 @@ const testlibFile = {
src: findFileSync('@hydrooj/hydrojudge/vendor/testlib/testlib.h'),
};
export async function compileWithTestlib(
src: string, type: 'checker' | 'validator' | 'interactor',
export async function compileLocalFile(
src: string, type: 'checker' | 'validator' | 'interactor' | 'generator' | 'std',
getLang, copyIn: CopyIn, withTestlib = true, next?: any,
) {
const s = src.replace('@', '.').split('.');
+6 -2
View File
@@ -6,7 +6,7 @@ import {
JudgeResultBody, ObjectId, RecordModel, SettingModel,
StorageModel, SystemModel, TaskModel,
} from 'hydrooj';
import { end, next } from 'hydrooj/src/handler/judge';
import { end, next, processJudgeFileCallback } from 'hydrooj/src/handler/judge';
import { getConfig } from '../config';
import { FormatError, SystemError } from '../error';
import { Context } from '../judge/interface';
@@ -46,6 +46,9 @@ const session = {
if (doThrow) throw new SystemError('Unsupported language {0}.', [lang]);
return null;
},
async postFile(target: string, filename: string, filepath: string) {
return await processJudgeFileCallback(new ObjectId(target), filename, filepath);
},
async cacheOpen(source: string, files: any[]) {
const filePath = path.join(getConfig('cache_dir'), source);
await fs.ensureDir(filePath);
@@ -84,9 +87,10 @@ export async function postInit(ctx) {
logger.debug('Record not found: %o', t);
return;
}
await (new JudgeTask(session, Object.assign(rdoc, t))).handle().catch(logger.error);
await (new JudgeTask(session, JSON.parse(JSON.stringify(Object.assign(rdoc, t))))).handle().catch(logger.error);
};
const parallelism = Math.max(getConfig('parallelism'), 2);
for (let i = 1; i < parallelism; i++) TaskModel.consume({ type: 'judge' }, handle);
TaskModel.consume({ type: 'judge', priority: { $gt: -50 } }, handle);
TaskModel.consume({ type: 'generate' }, handle);
}
+6 -1
View File
@@ -10,6 +10,7 @@ import * as sysinfo from '@hydrooj/utils/lib/sysinfo';
import type { JudgeResultBody } from 'hydrooj';
import { getConfig } from '../config';
import { FormatError, SystemError } from '../error';
import { Session } from '../interface';
import log from '../log';
import { JudgeTask } from '../task';
import { Lock } from '../utils';
@@ -18,7 +19,7 @@ function removeNixPath(text: string) {
return text.replace(/\/nix\/store\/[a-z0-9]{32}-/g, '/nix/');
}
export default class Hydro {
export default class Hydro implements Session {
ws: WebSocket;
language: Record<string, LangConfig>;
@@ -134,6 +135,10 @@ export default class Hydro {
return target;
}
async postFile(target: string, file: string) {
await this.post('judge/upload', { target }).attach('file', fs.createReadStream(file));
}
getLang(name: string, doThrow = true) {
if (this.language[name]) return this.language[name];
if (name === 'cpp' && this.language.cc) return this.language.cc;
+18
View File
@@ -1,6 +1,9 @@
import type { LangConfig } from '@hydrooj/utils/lib/lang';
import { NormalizedSubtask } from '@hydrooj/utils/lib/utils';
import type { JudgeRequest as OrigJudgeRequest, ObjectId } from 'hydrooj';
import { JudgeResultBody, ProblemConfigFile } from 'hydrooj';
import { CopyInFile } from './sandbox';
import type { JudgeTask } from './task';
export interface Execute {
execute: string;
@@ -16,3 +19,18 @@ export interface ParsedConfig extends Omit<ProblemConfigFile, 'time' | 'memory'
memory: number;
subtasks: NormalizedSubtask[];
}
// replace ObjectId to string
export type JudgeRequest = {
[K in keyof OrigJudgeRequest]: OrigJudgeRequest[K] extends ObjectId ? string : OrigJudgeRequest[K];
};
export interface Session {
getLang: (name: string) => LangConfig;
getNext: (task: JudgeTask) => NextFunction;
getEnd: (task: JudgeTask) => NextFunction;
cacheOpen: (source: string, files: any[], next?: NextFunction) => Promise<string>;
fetchFile: (target: string) => Promise<string>;
postFile: (target: string, filename: string, file: string) => Promise<void>;
config: { detail: boolean, host?: string };
}
+1 -1
View File
@@ -75,7 +75,7 @@ export const judge = async (ctx: Context) => await runFlow(ctx, {
compile: async () => {
[ctx.execute, ctx.checker] = await Promise.all([
ctx.compile(ctx.lang, ctx.code),
ctx.compileWithTestlib('checker', ctx.config.checker, ctx.config.checker_type),
ctx.compileLocalFile('checker', ctx.config.checker, ctx.config.checker_type),
]);
},
judgeCase,
+144
View File
@@ -0,0 +1,144 @@
/* eslint-disable no-await-in-loop */
import { tmpdir } from 'os';
import path from 'path';
import fs from 'fs-extra';
import { STATUS } from '@hydrooj/utils/lib/status';
import { SystemError } from 'hydrooj';
import { CopyInFile, runQueued } from '../sandbox';
import client from '../sandbox/client';
import signals from '../signals';
import { JudgeTask } from '../task';
import { parseMemoryMB, parseTimeMS } from '../utils';
export const judge = async (ctx: JudgeTask) => {
ctx.stat.judge = new Date();
ctx.next({ status: STATUS.STATUS_COMPILING });
if (!('content' in ctx.code)) throw new SystemError('Unsupported input');
const [generator, std] = ctx.code.content.toString().split('\n').map((i) => i.trim());
if (generator.includes('/') || generator === '..') throw new SystemError('Invalid input');
if (std.includes('/') || std === '..') throw new SystemError('Invalid input');
const [executeGenerator, executeStd] = await Promise.all([
ctx.compileLocalFile('generator', generator),
ctx.compileLocalFile('std', std),
]);
ctx.next({ status: STATUS.STATUS_JUDGING, progress: 0 });
let totalTime = 0;
let totalMemory = 0;
let totalStatus = 0;
async function runGenerator(i: number) {
const res = await runQueued(
`${executeGenerator.execute} ${i}`,
{
stdin: { content: ctx.input || '' },
copyIn: executeGenerator.copyIn,
copyOut: ['stdout'],
time: parseTimeMS('2s'),
memory: parseMemoryMB('256m'),
},
1,
);
const tmp = path.join(tmpdir(), `${ctx.request.rid}.${i}.in`);
ctx.clean.push(() => {
if (fs.existsSync(tmp)) fs.removeSync(tmp);
return res.fileIds['stdout'] ? client.deleteFile(res.fileIds['stdout']) : Promise.resolve();
});
const {
code, signalled, time, memory, fileIds,
} = res;
let { status } = res;
const message: string[] = [];
if (time > parseTimeMS(ctx.config.time || '2s')) {
status = STATUS.STATUS_TIME_LIMIT_EXCEEDED;
} else if (memory > parseMemoryMB('256m') * 1024) {
status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED;
} else if (code) {
status = STATUS.STATUS_RUNTIME_ERROR;
if (code < 32 && signalled) message.push(`ExitCode: ${code} (${signals[code]})`);
else message.push(`ExitCode: ${code}`);
}
totalTime += time;
totalMemory = Math.max(memory, totalMemory);
totalStatus = Math.max(status, totalStatus);
if (status === STATUS.STATUS_ACCEPTED) {
await client.getFile(fileIds['output'], tmp);
await ctx.session.postFile(ctx.request.rid.toString(), `${i}.in`, tmp);
}
ctx.next({
case: {
id: i,
subtaskId: 1,
status,
score: 0,
time,
memory,
message: message.join('\n').substring(0, 102400),
},
});
return status === STATUS.STATUS_ACCEPTED ? tmp : null;
}
async function runStd(i: number, stdin: CopyInFile) {
const res = await runQueued(
`${executeStd.execute} ${i}`,
{
stdin,
copyIn: executeStd.copyIn,
copyOut: ['stdout'],
time: parseTimeMS('2s'),
memory: parseMemoryMB('256m'),
},
1,
);
const tmp = path.join(tmpdir(), `${ctx.request.rid}.${i}.out`);
ctx.clean.push(() => {
if (fs.existsSync(tmp)) fs.removeSync(tmp);
return res.fileIds['stdout'] ? client.deleteFile(res.fileIds['stdout']) : Promise.resolve();
});
const {
code, signalled, time, memory, fileIds,
} = res;
let { status } = res;
const message: string[] = [];
if (time > parseTimeMS(ctx.config.time || '2s')) {
status = STATUS.STATUS_TIME_LIMIT_EXCEEDED;
} else if (memory > parseMemoryMB('256m') * 1024) {
status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED;
} else if (code) {
status = STATUS.STATUS_RUNTIME_ERROR;
if (code < 32 && signalled) message.push(`ExitCode: ${code} (${signals[code]})`);
else message.push(`ExitCode: ${code}`);
}
totalTime += time;
totalMemory = Math.max(memory, totalMemory);
totalStatus = Math.max(status, totalStatus);
if (status === STATUS.STATUS_ACCEPTED) {
await client.getFile(fileIds['output'], tmp);
await ctx.session.postFile(ctx.request.rid.toString(), `${i}.out`, tmp);
}
ctx.next({
case: {
id: i,
subtaskId: 2,
status,
score: 0,
time,
memory,
message: message.join('\n').substring(0, 102400),
},
});
return status === STATUS.STATUS_ACCEPTED;
}
for (let i = 1; i <= 10; i++) {
const result = await runGenerator(i);
if (result) await runStd(i, { src: result });
}
ctx.stat.done = new Date();
if (process.env.DEV) ctx.next({ message: JSON.stringify(ctx.stat) });
ctx.end({
status: totalStatus,
score: totalStatus === STATUS.STATUS_ACCEPTED ? 100 : 0,
time: totalTime,
memory: totalMemory,
});
};
+2 -2
View File
@@ -11,8 +11,8 @@ export async function judge(ctx: Context) {
ctx.next({ status: STATUS.STATUS_COMPILING, progress: 0 });
const [execute, checker, validator, input] = await Promise.all([
ctx.compile(ctx.lang, ctx.code),
ctx.compileWithTestlib('checker', ctx.config.checker, ctx.config.checker_type),
ctx.compileWithTestlib('validator', ctx.config.validator),
ctx.compileLocalFile('checker', ctx.config.checker, ctx.config.checker_type),
ctx.compileLocalFile('validator', ctx.config.validator),
ctx.session.fetchFile(ctx.files.hack),
]);
ctx.clean.push(() => fs.unlink(input));
+2 -1
View File
@@ -1,4 +1,5 @@
import * as def from './default';
import * as generate from './generate';
import * as hack from './hack';
import * as interactive from './interactive';
import { Context } from './interface';
@@ -7,5 +8,5 @@ import * as run from './run';
import * as submit_answer from './submit_answer';
export = {
default: def, interactive, run, submit_answer, objective, hack,
default: def, generate, interactive, run, submit_answer, objective, hack,
} as Record<string, { judge(ctx: Context): Promise<void> }>;
+1 -1
View File
@@ -68,7 +68,7 @@ export const judge = async (ctx: Context) => await runFlow(ctx, {
compile: async () => {
[ctx.executeUser, ctx.executeInteractor] = await Promise.all([
ctx.compile(ctx.lang, ctx.code),
ctx.compileWithTestlib('interactor', ctx.config.interactor),
ctx.compileLocalFile('interactor', ctx.config.interactor),
]);
},
judgeCase,
@@ -67,7 +67,7 @@ function judgeCase(c: NormalizedCase) {
export const judge = async (ctx: Context) => {
await runFlow(ctx, {
compile: async () => {
ctx.checker = await ctx.compileWithTestlib('checker', ctx.config.checker, ctx.config.checker_type);
ctx.checker = await ctx.compileLocalFile('checker', ctx.config.checker, ctx.config.checker_type);
},
judgeCase,
});
+10 -1
View File
@@ -1,3 +1,4 @@
import fs from 'fs';
import superagent from 'superagent';
import { getConfig } from '../config';
import { SandboxRequest, SandboxResult, SandboxVersion } from './interface';
@@ -8,7 +9,15 @@ const client = new Proxy({
run(req: SandboxRequest): Promise<SandboxResult[]> {
return superagent.post(`${url}/run`).send(req).then((res) => res.body);
},
getFile(fileId: string): Promise<Buffer> {
getFile(fileId: string, dest?: string): Promise<Buffer> {
if (dest) {
const w = fs.createWriteStream(dest);
superagent.get(`${url}/file/${fileId}`).pipe(w);
return new Promise((resolve, reject) => {
w.on('finish', () => resolve(null));
w.on('error', reject);
});
}
return superagent.get(`${url}/file/${fileId}`).responseType('arraybuffer').then((res) => res.body);
},
deleteFile(fileId: string): Promise<void> {
+18 -23
View File
@@ -1,30 +1,22 @@
import { basename } from 'path';
import { basename, join } from 'path';
import { fs } from '@hydrooj/utils';
import { LangConfig } from '@hydrooj/utils/lib/lang';
import { STATUS } from '@hydrooj/utils/lib/status';
import type {
FileInfo, JudgeMeta, JudgeRequest, JudgeResultBody, TestCase,
FileInfo, JudgeMeta, JudgeResultBody, TestCase,
} from 'hydrooj';
import readCases from './cases';
import checkers from './checkers';
import compile, { compileWithTestlib } from './compile';
import compile, { compileLocalFile } from './compile';
import { getConfig } from './config';
import { CompileError, FormatError } from './error';
import { Execute, NextFunction, ParsedConfig } from './interface';
import {
Execute, JudgeRequest, ParsedConfig, Session,
} from './interface';
import judge from './judge';
import { Logger } from './log';
import { CopyIn, CopyInFile, runQueued } from './sandbox';
import { compilerText, md5 } from './utils';
interface Session {
getLang: (name: string) => LangConfig;
getNext: (task: JudgeTask) => NextFunction;
getEnd: (task: JudgeTask) => NextFunction;
cacheOpen: (source: string, files: any[], next?: NextFunction) => Promise<string>;
fetchFile: (target: string) => Promise<string>;
config: { detail: boolean, host?: string };
}
const logger = new Logger('judge');
export class JudgeTask {
@@ -121,14 +113,15 @@ export class JudgeTask {
isSelfSubmission: this.meta.problemOwner === this.request.uid,
key: md5(`${this.source}/${getConfig('secret')}`),
lang: this.lang,
langConfig: ['objective', 'submit_answer'].includes(this.request.config.type) ? null : this.session.getLang(this.lang),
langConfig: (this.request.type === 'generate' || ['objective', 'submit_answer'].includes(this.request.config.type))
? null : this.session.getLang(this.lang),
},
);
this.stat.judge = new Date();
const type = this.request.contest?.toString() === '000000000000000000000000' ? 'run'
: this.files?.hack
? 'hack'
: this.config.type || 'default';
: this.request.type === 'generate' ? 'generate'
: this.files?.hack ? 'hack'
: this.config.type || 'default';
if (!judge[type]) throw new FormatError('Unrecognized problemType: {0}', [type]);
await judge[type].judge(this);
}
@@ -142,17 +135,19 @@ export class JudgeTask {
return result;
}
async compileWithTestlib(type: 'interactor' | 'validator' | 'checker', file: string, checkerType?: string) {
async compileLocalFile(type: 'interactor' | 'validator' | 'checker' | 'generator' | 'std', file: string, checkerType?: string) {
if (type === 'checker' && ['default', 'strict'].includes(checkerType)) return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) };
if (!checkers[checkerType]) throw new FormatError('Unknown checker type {0}.', [checkerType]);
const withTestlib = type !== 'checker' || checkerType === 'testlib';
if (type === 'checker' && !checkers[checkerType]) throw new FormatError('Unknown checker type {0}.', [checkerType]);
const withTestlib = type !== 'std' && (type !== 'checker' || checkerType === 'testlib');
const extra = type === 'std' ? this.config.user_extra_files : this.config.judge_extra_files;
const copyIn = {
user_code: this.code,
...Object.fromEntries(
(this.config.judge_extra_files || []).map((i) => [basename(i), { src: i }]),
(extra || []).map((i) => [basename(i), { src: i }]),
),
} as CopyIn;
const result = await compileWithTestlib(file, type, this.session.getLang, copyIn, withTestlib, this.next);
if (!file.startsWith('/')) file = join(this.folder, file);
const result = await compileLocalFile(file, type, this.session.getLang, copyIn, withTestlib, this.next);
this.clean.push(result.clean);
return result;
}
+45 -2
View File
@@ -1,20 +1,27 @@
import assert from 'assert';
import fs from 'fs-extra';
import yaml from 'js-yaml';
import { omit } from 'lodash';
import { ObjectId } from 'mongodb';
import sanitize from 'sanitize-filename';
import {
FileLimitExceededError, ForbiddenError, ProblemIsReferencedError, ValidationError,
} from '../error';
import {
JudgeResultBody, ProblemConfigFile, RecordDoc, Task, TestCase,
} from '../interface';
import { Logger } from '../logger';
import * as builtin from '../model/builtin';
import { STATUS } from '../model/builtin';
import { PERM, STATUS } from '../model/builtin';
import * as contest from '../model/contest';
import domain from '../model/domain';
import problem from '../model/problem';
import record from '../model/record';
import * as setting from '../model/setting';
import storage from '../model/storage';
import * as system from '../model/system';
import task from '../model/task';
import user from '../model/user';
import * as bus from '../service/bus';
import { updateJudge } from '../service/monitor';
import {
@@ -189,11 +196,46 @@ export class SubmissionDataDownloadHandler extends Handler {
}
}
export async function processJudgeFileCallback(rid: ObjectId, filename: string, filePath: string) {
const rdoc = await record.get(rid);
const [pdoc, udoc] = await Promise.all([
problem.get(rdoc.domainId, rdoc.pid),
user.getById(rdoc.domainId, rdoc.uid),
]);
if (!udoc.own(pdoc, PERM.PERM_EDIT_PROBLEM_SELF) && !udoc.hasPerm(PERM.PERM_EDIT_PROBLEM)) throw new ForbiddenError();
if (pdoc.reference) throw new ProblemIsReferencedError('edit files');
const stat = await fs.stat(filePath);
if ((pdoc.data?.length || 0)
+ (pdoc.additional_file?.length || 0)
>= system.get('limit.problem_files_max')) {
throw new FileLimitExceededError('count');
}
const size = Math.sum(
(pdoc.data || []).map((i) => i.size),
(pdoc.additional_file || []).map((i) => i.size),
stat.size,
);
if (size >= system.get('limit.problem_files_max_size')) {
throw new FileLimitExceededError('size');
}
await problem.addTestdata(pdoc.domainId, pdoc.docId, sanitize(filename), fs.createReadStream(filePath), udoc._id);
}
export class JudgeFileUpdateHandler extends Handler {
@post('rid', Types.ObjectId)
@post('name', Types.Filename)
async post(domainId: string, rid: ObjectId, filename: string) {
if (!this.request.files.file) throw new ValidationError('file');
await processJudgeFileCallback(rid, filename, this.request.files.file.filepath);
this.response.body = { ok: 1 };
}
}
class JudgeConnectionHandler extends ConnectionHandler {
category = '#judge';
processing: Task[] = [];
closed = false;
query: any = { type: 'judge' };
query: any = { type: { $in: ['judge', 'generate'] } };
rdocs: Record<string, RecordDoc> = {};
ip: string;
concurrency = 1;
@@ -282,6 +324,7 @@ class JudgeConnectionHandler extends ConnectionHandler {
export async function apply(ctx) {
ctx.Route('judge_files_download', '/judge/files', JudgeFilesDownloadHandler, builtin.PRIV.PRIV_JUDGE);
ctx.Route('judge_files_upload', '/judge/upload', JudgeFileUpdateHandler, builtin.PRIV.PRIV_JUDGE);
ctx.Route('judge_submission_download', '/judge/code', SubmissionDataDownloadHandler, builtin.PRIV.PRIV_JUDGE);
ctx.Connection('judge_conn', '/judge/conn', JudgeConnectionHandler, builtin.PRIV.PRIV_JUDGE);
}
+20 -9
View File
@@ -519,7 +519,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
}
if (pretest) {
if (setting.langs[lang]?.pretest) lang = setting.langs[lang].pretest as string;
if (!['default', 'fileio', 'remote_judge'].includes(this.response.body.pdoc.config?.type)) {
if (!['default', 'remote_judge'].includes(this.response.body.pdoc.config?.type)) {
throw new ProblemNotAllowPretestError('type');
}
}
@@ -543,7 +543,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
}
const rid = await record.add(
domainId, this.pdoc.docId, this.user._id, lang, code, true,
pretest ? { input, type: 'pretest' } : { contest: tid, files, type: tid ? 'contest' : 'judge' },
pretest ? { input, type: 'pretest' } : { contest: tid, files, type: 'judge' },
);
const rdoc = await record.get(domainId, rid);
if (!pretest) {
@@ -698,6 +698,12 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
} else this.response.template = 'problem_files.html';
}
async post() {
if (this.args.operation === 'get_links') return;
if (this.pdoc.reference) throw new ProblemIsReferencedError('edit files');
if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
}
@post('files', Types.Set)
@post('type', Types.Range(['testdata', 'additional_file']), true)
async postGetLinks(domainId: string, files: Set<string>, type = 'testdata') {
@@ -730,10 +736,8 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
@post('filename', Types.Filename, true)
@post('type', Types.Range(['testdata', 'additional_file']), true)
async postUploadFile(domainId: string, filename: string, type = 'testdata') {
if (this.pdoc.reference) throw new ProblemIsReferencedError('edit files');
if (!this.request.files.file) throw new ValidationError('file');
filename ||= this.request.files.file.originalFilename || String.random(16);
if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const files = [];
if (filename.endsWith('.zip') && type === 'testdata') {
let zip: AdmZip;
@@ -744,7 +748,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
}
const entries = zip.getEntries();
for (const entry of entries) {
if (!entry.name) continue;
if (!entry.name || entry.isDirectory) continue;
files.push({
type,
name: sanitize(entry.name),
@@ -788,9 +792,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
@post('newNames', Types.ArrayOf(Types.Filename))
@post('type', Types.Range(['testdata', 'additional_file']), true)
async postRenameFiles(domainId: string, files: string[], newNames: string[], type = 'testdata') {
if (this.pdoc.reference) throw new ProblemIsReferencedError('rename files');
if (files.length !== newNames.length) throw new ValidationError('files', 'newNames');
if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
await Promise.all(files.map(async (file, index) => {
const newName = newNames[index];
if (type === 'testdata') await problem.renameTestdata(domainId, this.pdoc.docId, file, newName, this.user._id);
@@ -802,12 +804,21 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
@post('files', Types.ArrayOf(Types.Filename))
@post('type', Types.Range(['testdata', 'additional_file']), true)
async postDeleteFiles(domainId: string, files: string[], type = 'testdata') {
if (this.pdoc.reference) throw new ProblemIsReferencedError('delete files');
if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
if (type === 'testdata') await problem.delTestdata(domainId, this.pdoc.docId, files, this.user._id);
else await problem.delAdditionalFile(domainId, this.pdoc.docId, files, this.user._id);
this.back();
}
@post('std', Types.Filename)
@post('gen', Types.Filename)
async postGenerateTestdata(domainId: string, std: string, gen: string) {
if (!this.pdoc.data.find((i) => i.name === std)) throw new BadRequestError();
if (!this.pdoc.data.find((i) => i.name === gen)) throw new BadRequestError();
const rid = await record.add(domainId, this.pdoc.docId, this.user._id, '_', `${gen}\n${std}`, true, {
type: 'generate',
});
this.response.redirect = this.url('record_detail', { rid });
}
}
export class ProblemFileDownloadHandler extends ProblemDetailHandler {
+3 -1
View File
@@ -342,11 +342,13 @@ export interface JudgeMeta {
problemOwner: number;
hackRejudge?: string;
rejudge?: boolean;
// FIXME stricter types
type?: string;
}
export interface JudgeRequest extends Omit<RecordDoc, '_id' | 'testCases'> {
priority: number;
type: 'judge';
type: 'judge' | 'generate';
rid: ObjectId;
config: ProblemConfigFile;
meta: JudgeMeta;
+1 -4
View File
@@ -33,10 +33,7 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) {
if (result.timeMax < result.timeMin) result.timeMax = result.timeMin = 1000;
if (cfg.langs) result.langs = cfg.langs;
if (cfg.redirect) result.redirect = cfg.redirect.split('/', 2) as any;
if (cfg.filename && result.type === 'default') {
result.type = 'fileio';
result.subType = cfg.filename;
}
if (cfg.filename && result.type === 'default') result.subType = cfg.filename;
return result;
}
+15 -6
View File
@@ -26,6 +26,9 @@ export default class RecordModel {
'files',
];
static RECORD_PRETEST = new ObjectId('000000000000000000000000');
static RECORD_GENERATE = new ObjectId('000000000000000000000001');
static async submissionPriority(uid: number, base: number = 0) {
const timeRecent = await RecordModel.coll
.find({ _id: { $gte: Time.getObjectID(moment().add(-30, 'minutes')) }, uid, rejudged: { $ne: true } })
@@ -82,8 +85,9 @@ export default class RecordModel {
}
meta = { ...meta, problemOwner: pdoc.owner };
if (typeof pdoc.config === 'string') throw new Error(pdoc.config);
const type = (pdoc.config.type === 'remote_judge' && rdoc.contest?.toHexString() !== '0'.repeat(24)) ? 'remotejudge' : 'judge';
config.type = pdoc.config.type === 'fileio' ? 'default' : pdoc.config.type as any;
let type = 'judge';
if (pdoc.config.type === 'remote_judge' && rdoc.contest?.toHexString() !== '0'.repeat(24)) type = 'remotejudge';
else if (meta?.type === 'generate') type = 'generate';
return await task.addMany(rids.map((rid) => ({
...(pdoc.config as any),
priority,
@@ -104,7 +108,7 @@ export default class RecordModel {
contest?: ObjectId,
input?: string,
files?: Record<string, string>,
type: 'judge' | 'rejudge' | 'contest' | 'pretest' | 'hack',
type: 'judge' | 'rejudge' | 'pretest' | 'hack' | 'generate',
} = { type: 'judge' },
) {
const data: RecordDoc = {
@@ -125,6 +129,7 @@ export default class RecordModel {
judgeAt: null,
rejudged: false,
};
let isContest = !!args.contest;
if (args.contest) data.contest = args.contest;
if (args.files) data.files = args.files;
if (args.type === 'rejudge') {
@@ -132,12 +137,16 @@ export default class RecordModel {
data.rejudged = true;
} else if (args.type === 'pretest') {
data.input = args.input || '';
data.contest = new ObjectId('000000000000000000000000');
isContest = false;
data.contest = RecordModel.RECORD_PRETEST;
}
const res = await RecordModel.coll.insertOne(data);
if (addTask) {
const priority = await RecordModel.submissionPriority(uid, args.type === 'pretest' ? -20 : (args.type === 'contest' ? 50 : 0));
await RecordModel.judge(domainId, res.insertedId, priority, args.type === 'contest' ? { detail: false } : {}, { rejudge: data.rejudged });
const priority = await RecordModel.submissionPriority(uid, args.type === 'pretest' ? -20 : (isContest ? 50 : 0));
await RecordModel.judge(domainId, res.insertedId, priority, isContest ? { detail: false } : {}, {
type: args.type,
rejudge: data.rejudged,
});
}
return res.insertedId;
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { Schedule } from '../interface';
import { Logger } from '../logger';
import * as bus from '../service/bus';
import db from '../service/db';
import RecordModel from './record';
const logger = new Logger('model/schedule');
const coll = db.collection('schedule');
@@ -136,7 +137,7 @@ export async function apply(ctx: Context) {
ctx.worker = Worker;
Worker.addHandler('task.daily', async () => {
await global.Hydro.model.record.coll.deleteMany({ contest: new ObjectId('000000000000000000000000') });
await RecordModel.coll.deleteMany({ contest: { $in: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } });
await global.Hydro.script.rp?.run({}, new Logger('task/rp').debug);
await global.Hydro.script.problemStat?.run({}, new Logger('task/problem').debug);
if (global.Hydro.model.system.get('server.checkUpdate') && !(new Date().getDay() % 3)) {
+4 -4
View File
@@ -1,8 +1,8 @@
/* eslint-disable no-await-in-loop */
import { ObjectId } from 'mongodb';
import Schema from 'schemastery';
import { STATUS } from '../model/builtin';
import * as document from '../model/document';
import RecordModel from '../model/record';
import db from '../service/db';
const sumStatus = (status) => ({ $sum: { $cond: [{ $eq: ['$status', status] }, 1, 0] } });
@@ -10,7 +10,7 @@ const sumStatus = (status) => ({ $sum: { $cond: [{ $eq: ['$status', status] }, 1
export async function udoc(report) {
report({ message: 'Udoc' });
const pipeline = [
{ $match: { contest: { $ne: new ObjectId('000000000000000000000000') } } },
{ $match: { contest: { $nin: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } } },
{
$group: {
_id: { domainId: '$domainId', pid: '$pid', uid: '$uid' },
@@ -49,7 +49,7 @@ export async function udoc(report) {
export async function psdoc(report) {
report({ message: 'Psdoc' });
const pipeline = [
{ $match: { contest: { $ne: new ObjectId('000000000000000000000000') } } },
{ $match: { contest: { $nin: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] } } },
{
$group: {
_id: { domainId: '$domainId', pid: '$pid', uid: '$uid' },
@@ -68,7 +68,7 @@ export async function pdoc(report) {
const pipeline = [
{
$match: {
contest: { $ne: new ObjectId('000000000000000000000000') },
contest: { $nin: [RecordModel.RECORD_PRETEST, RecordModel.RECORD_GENERATE] },
status: { $ne: STATUS.STATUS_CANCELED },
},
},
@@ -0,0 +1,63 @@
import { assign } from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom/client';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete, { AutoCompleteOptions } from '.';
import FileSelectAutoCompleteFC from './components/FileSelectAutoComplete';
interface FileSelectOptions {
data: {
id: string;
name: string;
}[];
}
const Component = React.forwardRef<any, any>((props, ref) => {
const [value, setValue] = React.useState(props.value);
return (
<FileSelectAutoCompleteFC
ref={ref as any}
height="auto"
selectedKeys={value.split(',').map((i) => i.trim()).filter((i) => i)}
onChange={(v) => {
setValue(v);
props.onChange(v);
}}
data={props.data}
multi={props.multi}
/>
);
});
export default class FileSelectAutoComplete<Multi extends boolean> extends AutoComplete {
static DOMAttachKey = 'ucwFileSelectAutoCompleteInstance';
options: FileSelectOptions & AutoCompleteOptions;
constructor($dom, options: FileSelectOptions & AutoCompleteOptions<Multi>) {
super($dom, {
classes: 'custom-select',
...options,
});
}
value(): Multi extends true ? number[] : string {
if (this.options.multi) return this.ref?.getSelectedItemKeys().map((i) => +i) ?? this.$dom.val();
return this.ref?.getSelectedItems()[0] ?? null;
}
attach() {
const value = this.$dom.val();
ReactDOM.createRoot(this.container).render(
<Component
ref={(ref) => { this.ref = ref; }}
data={this.options.data}
value={value}
multi={this.options.multi}
onChange={this.onChange}
/>,
);
}
}
assign(FileSelectAutoComplete, DOMAttachedObject);
window.Hydro.components.FileSelectAutoComplete = FileSelectAutoComplete;
@@ -100,7 +100,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class ScratchpadTool
}
render() {
let canUsePretest = ['default', 'fileio'].includes(UiContext.pdoc.config?.type);
let canUsePretest = UiContext.pdoc.config?.type === 'default';
const langInfo = availableLangs[this.props.editorLang];
if (UiContext.pdoc.config?.type === 'remote_judge' && langInfo) {
if (langInfo.pretest) canUsePretest = true;
@@ -3,7 +3,7 @@ import { i18n } from 'vj/utils';
export default function reducer(state = {
pretest: {
visible: ['default', 'fileio'].includes(UiContext.pdoc.config?.type)
visible: UiContext.pdoc.config?.type === 'default'
? localStorage.getItem('scratchpad/pretest') === 'true'
: false,
},
+1 -1
View File
@@ -1,4 +1,5 @@
__langname: English
'File IO: {0}': 'File IO: {0}'
Can be Extended For: Extension
Current Status: Status
Handled Requests: Req
@@ -27,7 +28,6 @@ problem_main: Problem Set
problem_solution: Problem Solution
problem_submit: Problem Submit
problem_type.default: 'Type: Default'
problem_type.fileio: 'Type: FileIO ({0})'
problem_type.interactive: 'Type: Interactive'
problem_type.objective: 'Type: Objective'
problem_type.remote_judge: 'Type: RemoteJudge'
+9 -2
View File
@@ -27,7 +27,9 @@ __langname: 简体中文
'Current dataset: {0}': '当前测试数据: {0}'
'Discussion: Terms Of Service': 讨论区服务条款
'Drag and drop testcases here:': '拖拽测试点到此处:'
'Enter a new name for the file: ': 请输入新的文件名:
'Failed to get credential: {0}': '获取认证数据失败: {0}'
'File IO: {0}': 文件IO:{0}
'File upload failed: {0}': 文件上传失败:{0}
'Format: category 1, category 2, ..., category n': 格式:分类1, 分类2, ..., 分类n
'Hello, {0}! You can click following link to active your new email of your {1} account:': 您好,{0}!您可以点击以下链接来激活您 {1} 账户的新电子邮件地址:
@@ -50,6 +52,8 @@ __langname: 简体中文
(None): (无)
(Not changed): 未更改
6-Digit Code: 6位代码
A data generator is a program that generates testdata. It should output result to stdout.: 数据生成器是一个生成测试数据的程序。它应该将结果输出到标准输出。
A standard program is a program that solves the problem. It should output answer to stdout.: 标准程序是一个解决问题的程序。它应该将答案输出到标准输出。
Aborted: 已放弃
About {0}: 关于 {0}
About Markdown: 关于 Markdown
@@ -245,6 +249,7 @@ Current Status: 当前状态
currently offline: 目前离线
Daily update check: 每日自动检查更新
Dark: 暗色
Data Generator: 数据生成器
Data of Problem {0} not found.: 题目 {0} 的数据缺失。
Data of problem {1} not found.: 题目 {1} 的数据未找到。
Data of record {0} not found.: 记录 {0} 的数据未找到。
@@ -358,7 +363,6 @@ End Time: 结束时间
Enroll Training: 参加训练
Enrolled: 已参加
Enrollees: 参加人数
'Enter a new name for the file: ': 请输入新的文件名:
error: 错误
Errors detected in the config. Confirm save?: 配置中存在错误。确认保存?
Evaluated difficulty: 估计的难度
@@ -398,6 +402,9 @@ Freeze scoreboard with N minutes remaining: 在赛事剩余N分钟时封榜
fs_upload: 上传文件
Gender Visibility: 性别可见性
Gender: 性别
Generate testdata is not supported for this problem type.: 此题目类型不支持生成测试数据。
Generate Testdata: 生成测试数据
Generate: 生成
Gravatar URL Prefix: Gravatar地址前缀
Group / UID: 小组或 UID
Group Name: 名称
@@ -686,7 +693,6 @@ problem_main: 题库
problem_solution: 题解
problem_submit: 递交代码
problem_type.default: 传统题
problem_type.fileio: '文件IO ({0})'
problem_type.interactive: 交互题
problem_type.objective: 客观题
problem_type.remote_judge: 远端评测题
@@ -845,6 +851,7 @@ Sorry: 对不起
Sort by: 排序
Source type: 来源类型
Standard mode: 标准模式
Standard Program: 标准程序
Star Topic: 收藏讨论
Star: 收藏
Starred Users: 关注用户
@@ -2,7 +2,8 @@ import $ from 'jquery';
import { map } from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ActionDialog, ConfirmDialog } from 'vj/components/dialog/index';
import FileSelectAutoComplete from 'vj/components/autocomplete/FileSelectAutoComplete';
import { ActionDialog, ConfirmDialog, InfoDialog } from 'vj/components/dialog/index';
import createHint from 'vj/components/hint';
import Notification from 'vj/components/notification';
import { previewFile } from 'vj/components/preview/preview.page';
@@ -318,6 +319,48 @@ const page = new NamedPage('problem_files', () => {
handleClickUpload(type, files);
}
async function handleGenerateTestdata(ev) {
ev.preventDefault();
const gen = $('[name=gen]').val();
const std = $('[name=std]').val();
if (!gen) {
Notification.error(i18n('Please input the generator.'));
return;
}
if (!std) {
Notification.error(i18n('Please input the standard program.'));
return;
}
try {
const res = await request.post('', {
operation: 'generate_testdata',
gen,
std,
});
if (res.error) Notification.error(res.error);
else {
Notification.success(i18n('Generating...'));
await new InfoDialog({
$body: tpl`
<div class="typo">
<iframe src="${res.url}"
scrolling="yes" border="0" frameborder="no" framespacing="0" width="100%" style="height: 70vh;"></iframe>
</div>`,
width: `${window.innerWidth - 200}px`,
height: `${window.innerHeight - 100}px`,
}).open();
}
} catch (error) {
Notification.error([error.message, ...error.params].join(' '));
}
}
if (UiContext.pdoc) {
FileSelectAutoComplete.getOrConstruct($('[name=gen]'), { data: UiContext.pdoc.data });
FileSelectAutoComplete.getOrConstruct($('[name=std]'), { data: UiContext.pdoc.data });
$('[name="generate_testdata"]').on('click', handleGenerateTestdata);
}
if ($('[name="upload_testdata"]').length) {
$(document).on('click', '[name="upload_testdata"]', () => handleClickUpload('testdata'));
$(document).on('click', '[name="upload_file"]', () => handleClickUpload('additional_file'));
@@ -69,6 +69,9 @@
<span class="bp5-tag bp5-large bp5-minimal problem__tag-item">{{ _('ID') }}: {{ pdoc.docId }}</span>
{% endif %}
<span class="bp5-tag bp5-large bp5-minimal problem__tag-item icon icon-book">{{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }}</span>
{% if pdoc.config.type == 'default' and pdoc.config.subType %}
<span class="bp5-tag bp5-large bp5-minimal problem__tag-item icon icon-book">{{ _('File IO: {0}').format(pdoc.config.subType) }}</span>
{% endif %}
{% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %}
<span class="bp5-tag bp5-large bp5-minimal problem__tag-item icon icon-stopwatch">{{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms</span>
<span class="bp5-tag bp5-large bp5-minimal problem__tag-item icon icon-comparison">{{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB</span>
@@ -1,3 +1,4 @@
{% import "components/nothing.html" as nothing with context %}
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
@@ -27,6 +28,36 @@
</div>
{% endif %}
</div>
{% if not reference and (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) %}
{{ set(UiContext, {
pdoc: pdoc
}) }}
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('Generate Testdata') }}(Beta)</h1>
</div>
{{ noscript_note.render() }}
<div class="section__body generate">
{% if pdoc.config.type === 'default' %}
{{ form.form_text({
columns: 12,
label:'Data Generator',
name:'gen',
help_text:'A data generator is a program that generates testdata. It should output result to stdout.'
}) }}
{{ form.form_text({
columns: 12,
label:'Standard Program',
name:'std',
help_text:'A standard program is a program that solves the problem. It should output answer to stdout.'
}) }}
<button class="rounded button" name="generate_testdata">{{ _('Generate') }}</button>
{% else %}
{{ nothing.render('Generate testdata is not supported for this problem type.') }}
{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="medium-4 columns">
<div class="section">