feat: enhance objective question (#376)

This commit is contained in:
undefined authored and GitHub committed 2022-06-28 21:12:35 +08:00
commit 231dee0acd
14 files changed
+267 -156

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hydrooj/hydrojudge",
"bin": "bin/hydrojudge.js",
"version": "2.20.7",
"version": "2.21.0",
"main": "package.json",
"author": "undefined <i@undefined.moe>",
"repository": "https://github.com/hydro-dev/Hydro.git",
+1 -1
View File
@@ -88,7 +88,7 @@ export default async function readCases(folder: string, cfg: Record<string, any>
const checkFile = ensureFile(folder);
const result = await readYamlCases(config, checkFile)
.catch((e) => { throw changeErrorType(e, FormatError); });
result.count = result.outputs?.length || Math.sum((result.subtasks || []).map((s) => s.cases.length));
result.count = Object.keys(result.answers || {}).length || Math.sum((result.subtasks || []).map((s) => s.cases.length));
if (!result.count) {
try {
result.subtasks = readSubtasksFromFiles(await collectFiles(folder), cfg);
+1 -4
View File
@@ -62,10 +62,7 @@ export interface Config {
hack?: string;
interactor?: string;
outputs?: {
output: string,
score: number,
}[];
answers?: Record<string, [answer: string | string[], score: number]>;
}
export interface ContextSubTask {
+91 -18
View File
@@ -1,4 +1,6 @@
import assert from 'assert';
import { readFile } from 'fs-extra';
import yaml from 'js-yaml';
import { STATUS } from '@hydrooj/utils/lib/status';
import { Context } from './interface';
@@ -11,27 +13,98 @@ export async function judge({
: ('content' in code)
? code.content.toString().replace(/\r/g, '')
: '';
const outputs = answer.split('\n');
let answers: { [x: string]: string | string[] } = {};
try {
answers = yaml.load(answer) as any;
assert(typeof answers === 'object');
} catch (e) {
return end({
status: STATUS.STATUS_WRONG_ANSWER,
score: 0,
message: 'Unable to parse answer.',
time: 0,
memory: 0,
});
}
let totalScore = 0;
let totalStatus = 0;
for (const i in config.outputs) {
const c = config.outputs[i];
outputs[i] = outputs[i] || '';
let status = STATUS.STATUS_WRONG_ANSWER;
let score = 0;
if (outputs[i].trim() === (c.output || c[0]).trim()) {
score = c.score || c[1];
status = STATUS.STATUS_ACCEPTED;
for (const key in config.answers) {
const [subtaskId, caseId] = key.split('-').map(Number);
const ansInfo = config.answers[key] as [string | string[], number];
const score = ansInfo[1];
const baseInfo = {
subtaskId,
id: caseId,
time: 0,
memory: 0,
};
if (typeof ansInfo[0] === 'string') {
if (ansInfo[0].trim() === (answers[key] as any).trim()) {
totalScore += score;
totalStatus = Math.max(totalStatus, STATUS.STATUS_ACCEPTED);
next({
status: totalStatus,
case: {
...baseInfo,
status: STATUS.STATUS_ACCEPTED,
score,
message: 'Correct',
},
}, +caseId);
} else {
totalStatus = STATUS.STATUS_WRONG_ANSWER;
next({
status: totalStatus,
case: {
...baseInfo,
status: STATUS.STATUS_WRONG_ANSWER,
score: 0,
message: 'Incorrect',
},
}, +caseId);
}
} else {
const stdAns = new Set(ansInfo[0]);
const ans = new Set(answers[key]);
const correct = stdAns.size === ans.size && [...stdAns].every((x) => ans.has(x));
const partialCorrect = [...stdAns].some((x) => ans.has(x)) && [...ans].every((x) => stdAns.has(x));
if (correct) {
totalScore += score;
totalStatus = Math.max(totalStatus, STATUS.STATUS_ACCEPTED);
next({
status: totalStatus,
case: {
...baseInfo,
status: STATUS.STATUS_ACCEPTED,
score,
message: 'Correct',
},
}, +caseId);
} else if (partialCorrect) {
totalScore += Math.floor(score / 2);
totalStatus = STATUS.STATUS_WRONG_ANSWER;
next({
status: totalStatus,
case: {
...baseInfo,
status: STATUS.STATUS_WRONG_ANSWER,
score: Math.floor(score / 2),
message: 'Partially Correct',
},
}, +caseId);
} else {
totalStatus = STATUS.STATUS_WRONG_ANSWER;
next({
status: totalStatus,
case: {
...baseInfo,
status: STATUS.STATUS_WRONG_ANSWER,
score: 0,
message: 'Incorrect',
},
}, +caseId);
}
}
totalScore += score;
totalStatus = Math.max(status, totalStatus);
next({
status: totalStatus,
progress: (100 * (+i + 1)) / config.outputs.length,
case: {
status, score, time: 0, memory: 0, message: '',
},
}, +i + 1);
}
return end({
status: totalStatus, score: totalScore, time: 0, memory: 0,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "3.13.19",
"version": "3.14.0",
"bin": "bin/hydrooj.js",
"main": "src/loader",
"module": "src/loader",
+1 -1
View File
@@ -170,7 +170,7 @@ export interface ProblemConfigFile {
user_extra_files?: string[];
judge_extra_files?: string[];
detail?: boolean;
outputs?: [string, number][];
answers?: Record<string, [string | string[], number]>;
redirect?: string;
cases?: TestCaseConfig[];
subtasks?: SubtaskConfig[];
+69
View File
@@ -17,6 +17,7 @@ import * as contest from './model/contest';
import * as discussion from './model/discussion';
import * as document from './model/document';
import domain from './model/domain';
import MessageModel from './model/message';
import problem from './model/problem';
import RecordModel from './model/record';
import StorageModel from './model/storage';
@@ -715,6 +716,74 @@ const scripts: UpgradeScript[] = [
}
return true;
},
async function _62_63() {
const _FRESH_INSTALL_IGNORE = 1;
const uids = new Set<number>();
await iterateAllDomain(async (ddoc) => {
const pdocs = await problem.getMulti(ddoc._id, { config: /type: objective/ })
.project({
config: 1, content: 1, docId: 1, owner: 1,
}).toArray();
for (const pdoc of pdocs) {
try {
const config = yaml.load(pdoc.config as string) as any;
if (config.type !== 'objective' || !config.outputs) continue;
config.answers = {};
let cnt = 0;
for (const l of config.outputs) {
cnt++;
config.answers[cnt] = l;
}
function processSingleLanguage(content: string) { // eslint-disable-line no-inner-declarations
let text = '';
try {
let scnt = 0;
const doc = yaml.load(content);
if (!(doc instanceof Array)) return content;
for (const s of doc) {
scnt++;
text += `${scnt}. ${s.desc}\n`;
if (s.choices) {
text += `{{ select(${scnt}) }}\n`;
const isPrefixed = s.choices.every((c) => /^[A-Z]\./.test(c));
let selectionId = 64;
for (const c of s.choices) {
selectionId++;
text += `- ${isPrefixed ? c.replace(/^[A-Z]\./, '') : c}\n`;
if (config.answers[scnt][0] === c) config.answers[scnt][0] = String.fromCharCode(selectionId);
}
} else text += `{{ input(${scnt}) }}\n`;
text += '\n';
}
} catch (e) { console.error(e); return content; }
return text;
}
try {
const langs = JSON.parse(pdoc.content);
if (typeof langs === 'object' && !(langs instanceof Array)) {
for (const lang in langs) {
if (typeof langs[lang] !== 'string') continue;
langs[lang] = processSingleLanguage(langs[lang]);
}
await problem.edit(ddoc._id, pdoc.docId, { content: JSON.stringify(langs) });
}
} catch (e) {
const content = processSingleLanguage(pdoc.content);
await problem.edit(ddoc._id, pdoc.docId, { content });
}
uids.add(pdoc.owner);
delete config.outputs;
await problem.addTestdata(ddoc._id, pdoc.docId, 'config.yaml', Buffer.from(yaml.dump(config)));
} catch (e) { console.error(e); }
}
});
for (const uid of uids) {
await MessageModel.send(1, uid, '我们更新了客观题的配置格式,已有题目已自动转换,查看文档获得更多信息。', MessageModel.FLAG_UNREAD);
}
return true;
},
];
export default scripts;
@@ -70,9 +70,16 @@ const problemConfigSchema = {
},
additionalProperties: false,
},
outputs: {
type: 'array',
minLength: 1,
answers: {
type: 'object',
patternProperties: {
'^\\d+(-\\d+)?$': {
type: 'array',
minLength: 2,
maxLength: 2,
},
},
additionalProperties: false,
},
},
additionalProperties: false,
@@ -1,83 +0,0 @@
import React from 'react';
import { sortBy } from 'lodash';
import request from 'vj/utils/request';
import Notification from 'vj/components/notification';
export default class ObjectiveContainer extends React.PureComponent {
constructor(args) {
super(args);
this.state = {};
this.onChange = this.onChange.bind(this);
this.onSubmit = this.onSubmit.bind(this);
}
onChange(ev) {
this.setState({ [ev.target.name]: ev.target.value });
}
onSubmit(ev) {
ev.preventDefault();
const entries = sortBy(Object.entries(this.state).map((i) => [+i[0], i[1]]));
const total = entries[entries.length - 1][0];
const ans = new Array(total).fill('');
entries.forEach((i) => ans[i[0]] = i[1]); // eslint-disable-line
request
.post(this.props.target, {
lang: '_',
code: ans.join('\n'),
})
.then((res) => {
window.location.href = res.url;
})
.catch((e) => {
Notification.error(e.message);
});
}
Textbox(args, name) {
return (
<label htmlFor={`textbox${name}`}>
{name + 1}. {args.desc.split('\n').map((i, index) => {
if (index) return <><br />{i}</>;
return i;
})}
<div name={`form_item_${name}`} className="textbox-container">
<input type="text" name={name} id={`textbox${name}`} className="textbox" onChange={this.onChange}></input>
</div>
</label>
);
}
Radio(args, name) {
return (
<>
{name + 1}. {args.desc.split('\n').map((i, index) => {
if (index) return <><br />{i}</>;
return i;
})}
{args.choices.map((i) => (
<label className="radiobox" htmlFor={`radio${name}${i}`} key={i}>
<input type="radio" name={name} id={`radio${name}${i}`} value={i} onChange={this.onChange} /> {i} <br />
</label>
))}
</>
);
}
render() {
return (
<form onSubmit={this.onSubmit}>
{this.props.panel.map((i, name) => (
<div className="row" key={i}>
<div className="medium-7 columns form__item end">
{i.choices ? this.Radio(i, name) : this.Textbox(i, name)}
</div>
</div>
))}
{document.getElementsByClassName('nav__item--round').length
? <input type="submit" disabled className="button rounded primary" value="登录后提交" />
: <input type="submit" className="button rounded primary" value="提交" />}
</form>
);
}
}
@@ -74,8 +74,8 @@ export function configYamlFormat(config: ProblemConfigFile) {
}
});
if (formatConfig.type === 'objective') {
Object.keys(formatConfig).filter((i) => !['type', 'outputs'].includes(i)).forEach((i) => delete formatConfig[i]);
formatConfig.outputs = formatConfig.outputs || [];
Object.keys(formatConfig).filter((i) => !['type', 'answers'].includes(i)).forEach((i) => delete formatConfig[i]);
formatConfig.answers = config.answers || {};
}
Object.keys(formatConfig).filter((i) => i.startsWith('__')).forEach((i) => delete formatConfig[i]);
return formatConfig;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.37.25",
"version": "4.38.0",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.js",
@@ -1,5 +1,5 @@
import yaml from 'js-yaml';
import ReactDOM, { createRoot } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import React from 'react';
import { getScoreColor } from '@hydrooj/utils/lib/status';
import { NamedPage } from 'vj/misc/Page';
@@ -7,6 +7,9 @@ import { downloadProblemSet } from 'vj/components/zipDownloader';
import loadReactRedux from 'vj/utils/loadReactRedux';
import delay from 'vj/utils/delay';
import pjax from 'vj/utils/pjax';
import request from 'vj/utils/request';
import tpl from 'vj/utils/tpl';
import i18n from 'vj/utils/i18n';
class ProblemPageExtender {
constructor() {
@@ -173,32 +176,75 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor
}
async function loadObjective() {
try {
let s = '';
try {
s = JSON.parse(UiContext.pdoc.content);
} catch {
s = UiContext.pdoc.content;
$('.outer-loader-container').show();
const ans = {};
let cnt = 0;
const reg = /{{ (input|select|multiselect)\(\d+(-\d+)?\) }}/g;
$('.problem-content .typo').children().each((i, e) => {
if (e.tagName === 'PRE' && !e.children[0].className.includes('#input')) return;
const questions = [];
let q;
while (q = reg.exec(e.innerText)) questions.push(q); // eslint-disable-line no-cond-assign
for (const [info, type] of questions) {
cnt++;
const id = info.replace(/{{ (input|select|multiselect)\((\d+(-\d+)?)\) }}/, '$2');
if (type === 'input') {
$(e).html($(e).html().replace(info, tpl`
<div class="objective_${id} medium-3" style="display: inline-block;">
<input type="text" name="${id}" class="textbox objective-input">
</div>
`));
} else {
if ($(e).next()[0].tagName !== 'UL') {
cnt--;
return;
}
$(e).html($(e).html().replace(info, ''));
$(e).next('ul').children().each((j, ele) => {
$(ele).after(tpl`
<div class="objective_${id} radiobox">
<input type="${type === 'select' ? 'radio' : 'checkbox'}" name="${id}" class="objective-input" value="${String.fromCharCode(65 + j)}">
${String.fromCharCode(65 + j)}. ${ele.innerHTML}
</div>
`);
$(ele).remove();
});
}
}
if (typeof s === 'object') {
const langs = Object.keys(s);
const f = langs.filter((i) => i.startsWith(UserContext.viewLang));
if (s[UserContext.viewLang]) s = s[UserContext.viewLang];
else if (f.length) s = s[f[0]];
else s = s[langs[0]];
}
const props = yaml.load(s);
if (!(props instanceof Array)) return;
$('.outer-loader-container').show();
const { default: Objective } = await import('vj/components/objective-question/index');
ReactDOM.createRoot($('.problem-content').get(0)).render(
<div className="section__body typo">
<Objective panel={props} target={UiContext.postSubmitUrl}></Objective>
</div>,
);
$('.outer-loader-container').hide();
} catch (e) { }
});
if (cnt) {
$('.problem-content .typo').append(document.getElementsByClassName('nav__item--round').length
? `<input type="submit" disabled class="button rounded primary" value="${i18n('Submit')}" />`
: `<input type="submit" class="button rounded primary" value="${i18n('Login to Submit')}" />`);
$('input.objective-input[type!=checkbox]').on('input', (e) => {
ans[e.target.name] = e.target.value;
});
$('input.objective-input[type=checkbox]').on('input', (e) => {
if (e.target.checked) {
if (ans[e.target.name] === undefined) ans[e.target.name] = [];
ans[e.target.name].push(e.target.value);
} else {
ans[e.target.name] = ans[e.target.name].filter((v) => v !== e.target.value);
}
});
$('input[type="submit"]').on('click', (e) => {
e.preventDefault();
request
.post(UiContext.postSubmitUrl, {
lang: '_',
code: yaml.dump(ans),
})
.then((res) => {
window.location.href = res.url;
})
.catch((err) => {
Notification.error(err.message);
});
});
}
$('.scratchpad--hide').hide();
$('.non-scratchpad--hide').hide();
$('.outer-loader-container').hide();
}
async function initChart() {
+16 -14
View File
@@ -7,21 +7,24 @@ export default async function readYamlCases(cfg: Record<string, any> = {}, check
judge_extra_files: [],
user_extra_files: [],
};
if (cfg.checker) {
if (!cfg.checker.includes('.')) {
config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${cfg.checker}.cpp`, false);
if (cfg.type === 'objective') {
if (cfg.checker || cfg.interactor) throw new Error('You cannot use checker or interactor for objective questions');
} else {
if (cfg.checker) {
if (!cfg.checker.includes('.')) {
config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${cfg.checker}.cpp`, false);
}
if (!config.checker) config.checker = checkFile(cfg.checker, 'Cannot find checker {0}.');
}
if (!config.checker) config.checker = checkFile(cfg.checker, 'Cannot find checker {0}.');
if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.');
['judge', 'user'].forEach((n) => {
const conf = cfg[`${n}_extra_files`];
if (!conf) return;
if (conf instanceof Array) {
config[`${n}_extra_files`] = conf.map((file) => checkFile(file, `Cannot find ${n} extra file {0}.`));
} else throw new Error(`Invalid ${n}_extra_files config.`);
});
}
if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.');
['judge', 'user'].forEach((n) => {
const conf = cfg[`${n}_extra_files`];
if (!conf) return;
if (conf instanceof Array) {
config[`${n}_extra_files`] = conf.map((file) => checkFile(file, `Cannot find ${n} extra file {0}.`));
} else throw new Error(`Invalid ${n}_extra_files config.`);
});
if (cfg.outputs) config.type = cfg.type || 'objective';
if (cfg.cases?.length) {
config.subtasks = [{
cases: cfg.cases,
@@ -30,7 +33,6 @@ export default async function readYamlCases(cfg: Record<string, any> = {}, check
}
if (cfg.time) config.time = parseTimeMS(cfg.time);
if (cfg.memory) config.memory = parseMemoryMB(cfg.memory);
if (config.type === 'objective' && !cfg.outputs?.length) throw new Error('outputs config not found');
return Object.assign(cfg, config);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/utils",
"version": "1.2.7",
"version": "1.3.0",
"description": "hydrooj utils",
"main": "package.json",
"repository": "https://github.com/hydro-dev/Hydro.git",