Repository navigation
Expand file tree
/
Copy pathccm.programming_task.mjs
More file actions
286 lines (267 loc) · 11.7 KB
/
Copy pathccm.programming_task.mjs
File metadata and controls
286 lines (267 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/**
* @overview Java programming exercise with a reusable code_editor and server feedback.
* @author André Kless <andre.kless@web.de>
* @copyright 2026 André Kless
* @license MIT
* @version 1.0.0
*/
export const component = {
name: "programming_task",
ccm: "././libs/framework/ccm-28.0.0.min.js",
config: {
ui: ["ccm.load", "././libs/ccm-ui/ccm-ui-1.0.0.min.mjs"],
views: ["ccm.load", "././resources/views.mjs"],
css: ["ccm.load", "././resources/styles.css"],
editor: ["ccm.instance", "././libs/code_editor/ccm.code_editor-2.0.0.min.mjs", {
language: "java",
ariaLabel: "Java-Lösung",
height: "380px",
}],
title: "Programmieraufgabe",
task: "",
startCode: "",
tests: "",
serverUrl: "",
// Optional async function: (submission, { serverUrl, signal, options }) => result.
validate: null,
validationOptions: {},
fileName: "Solution.java",
testFileName: "SolutionTest.java",
javaVersion: 25,
requestTimeout: 30000,
},
Instance: function () {
const app = this;
let active = null;
let mounted = false;
this.state = { phase: "idle", result: null, message: "" };
this.init = async () => {
const onEditorChange = this.editor.onchange;
this.editor.onchange = (event) => {
if (mounted && this.state.phase !== "idle") {
stopRequest();
this.state = { phase: "idle", result: null, message: "Code geändert. Bitte erneut prüfen." };
renderFeedback();
}
onEditorChange?.(event);
};
};
/** Starts the exercise with its configured start code and no previous result. */
this.start = async () => {
validateOptions();
stopRequest();
mounted = false;
this.editor.destroy();
this.state = { phase: "idle", result: null, message: "" };
this.editor.label = this.fileName;
this.editor.setValue(this.startCode, { notify: false });
this.ui.render(this.views.main(this), this.element, this);
await this.editor.start();
mounted = true;
renderFeedback();
};
/** Returns the current source and a detached copy of the current server result. */
this.getValue = () => ({
code: this.editor.getValue(),
result: this.state.result ? structuredClone(this.state.result) : null,
});
/** Sends one submission. Returns a result, or null on cancellation or request failure. */
this.check = async () => {
if (!mounted) throw new Error("Call start() before check()");
if (active) return null;
let url;
let submission;
let options;
try {
validateOptions();
options = structuredClone(this.validationOptions);
if (!this.serverUrl.trim()) throw new Error("Bitte eine Server-URL in der Konfiguration angeben.");
if (!this.tests.trim()) throw new Error("Für diese Aufgabe sind noch keine JUnit-Tests hinterlegt.");
try { url = new URL(this.serverUrl, document.baseURI); }
catch { throw new Error("Die Server-URL ist ungültig."); }
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Die Server-URL muss HTTP oder HTTPS verwenden.");
submission = {
code: this.editor.getValue(),
tests: this.tests,
fileName: this.fileName,
testFileName: this.testFileName,
javaVersion: this.javaVersion,
};
} catch (error) {
this.state = { phase: "error", result: null, message: error.message };
renderFeedback();
return null;
}
const job = { controller: new AbortController(), timedOut: false, fileName: this.fileName };
active = job;
this.state = { phase: "checking", result: null, message: "" };
this.editor.setDiagnostics([]);
renderFeedback();
job.timer = setTimeout(() => {
job.timedOut = true;
job.controller.abort();
}, this.requestTimeout);
try {
const data = await runValidation(this.validate ?? validateJSON, submission, {
serverUrl: url.href,
signal: job.controller.signal,
options,
});
const result = normalizeResponse(data);
// An edited/reset/restarted exercise must never receive an old response.
if (active !== job) return null;
this.editor.setDiagnostics(result.diagnostics.filter(item => !item.file || item.file === job.fileName));
this.state = { phase: "done", result, message: "" };
return structuredClone(result);
} catch (error) {
if (active !== job) return null;
this.state = {
phase: "error", result: null,
message: job.timedOut
? "Der Prüfserver hat nicht rechtzeitig geantwortet. Bitte erneut versuchen."
: error instanceof TypeError
? "Der Prüfserver ist nicht erreichbar. Bitte Server-URL, Verbindung und CORS-Freigabe prüfen."
: error.message,
};
return null;
} finally {
clearTimeout(job.timer);
if (active === job) {
active = null;
renderFeedback();
}
}
};
this.reset = () => {
stopRequest();
this.editor.setValue(this.startCode, { notify: false });
this.state = { phase: "idle", result: null, message: "Startcode wiederhergestellt." };
renderFeedback();
this.editor.focus();
};
this.cancel = () => {
if (!active) return;
stopRequest();
this.state = { phase: "idle", result: null, message: "Prüfung abgebrochen." };
renderFeedback();
};
this.destroy = () => {
stopRequest();
mounted = false;
this.editor.destroy();
this.state = { phase: "idle", result: null, message: "" };
};
this.events = {
check: () => { void this.check(); },
reset: () => this.reset(),
cancel: () => this.cancel(),
};
function stopRequest() {
if (!active) return;
const previous = active;
active = null;
clearTimeout(previous.timer);
previous.controller.abort();
}
/** Keeps cancellation responsive even if a custom adapter ignores its signal. */
async function runValidation(validate, submission, context) {
const { signal } = context;
signal.throwIfAborted();
let onAbort;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([
Promise.resolve().then(() => {
signal.throwIfAborted();
return validate(submission, context);
}),
aborted,
]);
} finally {
signal.removeEventListener("abort", onAbort);
}
}
/** Default backend contract; replace through config.validate for another API. */
async function validateJSON(submission, { serverUrl, signal }) {
const response = await fetch(serverUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "omit",
cache: "no-store",
body: JSON.stringify(submission),
signal,
});
if (!response.ok) throw new Error(`Der Prüfserver meldet einen HTTP-Fehler (${response.status}).`);
try { return await response.json(); }
catch (error) {
if (signal.aborted) throw error;
throw new Error("Der Prüfserver hat kein gültiges JSON zurückgegeben.");
}
}
/** Only replace feedback; the editor DOM, focus and undo history remain intact. */
function renderFeedback() {
if (!mounted) return;
const busy = app.state.phase === "checking";
app.element.querySelector('[data-on-click="check"]').disabled = busy;
app.element.querySelector('[data-on-click="cancel"]').hidden = !busy;
app.element.querySelector(".feedback").setAttribute("aria-busy", String(busy));
app.ui.render(app.views.feedback(app), app.element.querySelector(".feedback"), app);
}
function validateOptions() {
for (const key of ["title", "task", "startCode", "tests", "serverUrl", "fileName", "testFileName"])
if (typeof app[key] !== "string") throw new TypeError(`${key} must be a string`);
if (app.validate !== null && typeof app.validate !== "function")
throw new TypeError("validate must be a function or null");
if (!app.validationOptions || Object.getPrototypeOf(app.validationOptions) !== Object.prototype)
throw new TypeError("validationOptions must be a plain object");
for (const key of ["fileName", "testFileName"])
if (!/^[A-Za-z_$][\w$]*\.java$/.test(app[key])) throw new TypeError(`${key} must be a Java filename without a path`);
if (app.fileName === app.testFileName) throw new Error("fileName and testFileName must differ");
if (!Number.isInteger(app.javaVersion) || app.javaVersion < 25) throw new RangeError("javaVersion must be at least 25");
if (!Number.isInteger(app.requestTimeout) || app.requestTimeout < 1 || app.requestTimeout > 2147483647)
throw new RangeError("requestTimeout must be a positive integer up to 2147483647 milliseconds");
}
/** Validate the response before displaying a verdict or passing positions to the editor. */
function normalizeResponse(data) {
const invalid = () => new Error("Die Antwort des Prüfservers entspricht nicht dem vereinbarten JSON-Format.");
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
const string = (value, fallback = "") => {
if (value === undefined) return fallback;
if (typeof value !== "string") throw invalid();
return value;
};
const position = value => {
if (!Number.isSafeInteger(value) || value < 1) throw invalid();
return value;
};
if (!object(data) || !["passed", "failed", "compile_error", "error"].includes(data.status)) throw invalid();
if (data.tests !== undefined && !Array.isArray(data.tests)) throw invalid();
if (data.diagnostics !== undefined && !Array.isArray(data.diagnostics)) throw invalid();
const tests = (data.tests ?? []).map(item => {
if (!object(item) || typeof item.name !== "string" || !item.name.trim()
|| !["passed", "failed", "skipped"].includes(item.status)) throw invalid();
return { name: item.name, status: item.status, message: string(item.message) };
});
const diagnostics = (data.diagnostics ?? []).map(item => {
if (!object(item) || typeof item.message !== "string") throw invalid();
const diagnostic = { line: position(item.line), message: item.message, severity: item.severity ?? "error" };
if (!["error", "warning", "info", "hint"].includes(diagnostic.severity)) throw invalid();
if (item.file !== undefined) diagnostic.file = string(item.file);
for (const key of ["column", "endLine", "endColumn"])
if (item[key] !== undefined) diagnostic[key] = position(item[key]);
const endLine = diagnostic.endLine ?? diagnostic.line;
if (endLine < diagnostic.line || (endLine === diagnostic.line && diagnostic.endColumn !== undefined
&& diagnostic.endColumn < (diagnostic.column ?? 1))) throw invalid();
return diagnostic;
});
if (data.status === "passed" && (!tests.some(item => item.status === "passed")
|| tests.some(item => item.status === "failed") || diagnostics.some(item => item.severity === "error"))) throw invalid();
if (data.status === "failed" && !tests.some(item => item.status === "failed")) throw invalid();
return { status: data.status, tests, diagnostics, message: string(data.message),
stdout: string(data.stdout), stderr: string(data.stderr) };
}
},
};