prepare for better integration and testing
This commit is contained in:
@@ -1185,3 +1185,70 @@ h2:has(+ #exam-list:empty), h2:has(+ #packet-list:empty){
|
|||||||
.title-bar-button:hover {
|
.title-bar-button:hover {
|
||||||
background-color: darkgray;
|
background-color: darkgray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sync Status Styles */
|
||||||
|
#sync-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin: auto 0.8rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot.saved {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 8px #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot.syncing {
|
||||||
|
background-color: #FFC107;
|
||||||
|
box-shadow: 0 0 8px #FFC107;
|
||||||
|
animation: pulse-syncing 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot.failed {
|
||||||
|
background-color: #F44336;
|
||||||
|
box-shadow: 0 0 8px #F44336;
|
||||||
|
animation: pulse-failed 1s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-text {
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-syncing {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.85);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.15);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(0.85);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-failed {
|
||||||
|
from {
|
||||||
|
box-shadow: 0 0 4px #F44336;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
box-shadow: 0 0 12px #F44336;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
fullscreen
|
fullscreen
|
||||||
</button>
|
</button>
|
||||||
<div class="app-name nav-left">RTS</div>
|
<div class="app-name nav-left">RTS</div>
|
||||||
|
<div id="sync-status" class="nav-left"></div>
|
||||||
<div id="timer" title="click to pause"></div>
|
<div id="timer" title="click to pause"></div>
|
||||||
<div class="exam-name" title="currently loaded packet"></div>
|
<div class="exam-name" title="currently loaded packet"></div>
|
||||||
<button class="navigation nav-left" disabled="disabled"
|
<button class="navigation nav-left" disabled="disabled"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* global Dexie */
|
||||||
|
|
||||||
|
// Set up database
|
||||||
|
export const db = new Dexie("answers_database");
|
||||||
|
db.version(1).stores({
|
||||||
|
answers: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid, ans, eid",
|
||||||
|
flags: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid",
|
||||||
|
user_answers: "[qid+type+ans], [qid+type]",
|
||||||
|
session: "[eid+aid], eid, packet, aid, status, date, score, max_score, exam_time, time_left, question_order, questions_answered, total_questions",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const question_db = new Dexie("question_database");
|
||||||
|
question_db.version(1).stores({
|
||||||
|
question_data: "&[qid+type], qid, type",
|
||||||
|
saved_exams: "&eid, type, exam_mode, name, order, time, exam_json_id",
|
||||||
|
});
|
||||||
+18
-18
@@ -3,6 +3,8 @@ import * as helper from "./helpers.js";
|
|||||||
import * as viewer from "./viewer.js";
|
import * as viewer from "./viewer.js";
|
||||||
import * as interact from "./interact.js";
|
import * as interact from "./interact.js";
|
||||||
import * as config from "./config.js";
|
import * as config from "./config.js";
|
||||||
|
import { db, question_db } from "./db.js";
|
||||||
|
import { initSync, triggerImmediateSync } from "./sync.js";
|
||||||
// const { v4: uuidv4 } = require('uuid');
|
// const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
log.setDefaultLevel("warn")
|
log.setDefaultLevel("warn")
|
||||||
@@ -199,20 +201,13 @@ cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
|||||||
|
|
||||||
cornerstoneTools.init();
|
cornerstoneTools.init();
|
||||||
|
|
||||||
// Set up database
|
function putAnswer(answer) {
|
||||||
const db = new Dexie("answers_database");
|
answer.time_answered = new Date().toISOString();
|
||||||
db.version(1).stores({
|
answer.synced = false;
|
||||||
answers: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid, ans, eid",
|
db.answers.put(answer).then(() => {
|
||||||
flags: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid",
|
triggerImmediateSync(exam_details, URLS);
|
||||||
user_answers: "[qid+type+ans], [qid+type]",
|
|
||||||
session: "[eid+aid], eid, packet, aid, status, date, score, max_score, exam_time, time_left, question_order, questions_answered, total_questions",
|
|
||||||
});
|
|
||||||
|
|
||||||
const question_db = new Dexie("question_database");
|
|
||||||
question_db.version(1).stores({
|
|
||||||
question_data: "&[qid+type], qid, type",
|
|
||||||
saved_exams: "&eid, type, exam_mode, name, order, time, exam_json_id",
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
retrievePacketList();
|
retrievePacketList();
|
||||||
|
|
||||||
@@ -1102,9 +1097,13 @@ function loadSession() {
|
|||||||
date_started = Date.now();
|
date_started = Date.now();
|
||||||
//console.debug("new date", date_started);
|
//console.debug("new date", date_started);
|
||||||
}
|
}
|
||||||
|
exam_details.start_time = date_started / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUpQuestions(load_previous);
|
setUpQuestions(load_previous);
|
||||||
|
if (!review_mode) {
|
||||||
|
initSync(exam_details, URLS);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,7 +1453,7 @@ async function loadQuestion(n, section = 1, force_reload = false) {
|
|||||||
passcode: passcode,
|
passcode: passcode,
|
||||||
ans: evt.target.value,
|
ans: evt.target.value,
|
||||||
};
|
};
|
||||||
db.answers.put(answer);
|
putAnswer(answer);
|
||||||
|
|
||||||
$(
|
$(
|
||||||
"#question-list-item-" +
|
"#question-list-item-" +
|
||||||
@@ -1526,7 +1525,7 @@ async function loadQuestion(n, section = 1, force_reload = false) {
|
|||||||
ans: evt.target.value,
|
ans: evt.target.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.answers.put(answer);
|
putAnswer(answer);
|
||||||
console.debug("Save ", answer)
|
console.debug("Save ", answer)
|
||||||
saveSession();
|
saveSession();
|
||||||
updateQuestionListPanel(answer);
|
updateQuestionListPanel(answer);
|
||||||
@@ -1556,7 +1555,7 @@ async function loadQuestion(n, section = 1, force_reload = false) {
|
|||||||
};
|
};
|
||||||
// db.answers.put({aid: [cid, eid, qidn], ans: evt.target.value});
|
// db.answers.put({aid: [cid, eid, qidn], ans: evt.target.value});
|
||||||
console.debug("Save ", answer)
|
console.debug("Save ", answer)
|
||||||
db.answers.put(answer);
|
putAnswer(answer);
|
||||||
|
|
||||||
$(
|
$(
|
||||||
"#question-list-item-" +
|
"#question-list-item-" +
|
||||||
@@ -1657,7 +1656,7 @@ async function loadQuestion(n, section = 1, force_reload = false) {
|
|||||||
passcode: passcode,
|
passcode: passcode,
|
||||||
ans: evt.target.value,
|
ans: evt.target.value,
|
||||||
};
|
};
|
||||||
db.answers.put(answer);
|
putAnswer(answer);
|
||||||
|
|
||||||
$(
|
$(
|
||||||
"#question-list-item-" +
|
"#question-list-item-" +
|
||||||
@@ -1754,7 +1753,7 @@ async function loadQuestion(n, section = 1, force_reload = false) {
|
|||||||
ans: evt.target.value,
|
ans: evt.target.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.answers.put(answer);
|
putAnswer(answer);
|
||||||
|
|
||||||
$(
|
$(
|
||||||
"#question-list-item-" +
|
"#question-list-item-" +
|
||||||
@@ -2813,6 +2812,7 @@ $(".start-packet-button").click(function(evt) {
|
|||||||
// if in exam mode
|
// if in exam mode
|
||||||
|
|
||||||
exam_details.start_time = new Date().getTime() / 1000;
|
exam_details.start_time = new Date().getTime() / 1000;
|
||||||
|
initSync(exam_details, URLS);
|
||||||
|
|
||||||
if (timer != null) {
|
if (timer != null) {
|
||||||
timer.stop();
|
timer.stop();
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
// Background Sync Module for RTS
|
||||||
|
|
||||||
|
import { db } from "./db.js";
|
||||||
|
|
||||||
|
let syncInterval = null;
|
||||||
|
let debounceTimeout = null;
|
||||||
|
let currentStatus = "saved"; // "saved", "syncing", "failed"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the sync UI state
|
||||||
|
* @param {string} status - "saved", "syncing", "failed"
|
||||||
|
*/
|
||||||
|
export function updateSyncUI(status) {
|
||||||
|
currentStatus = status;
|
||||||
|
const container = document.getElementById("sync-status");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
switch (status) {
|
||||||
|
case "syncing":
|
||||||
|
html = `
|
||||||
|
<span class="sync-dot syncing"></span>
|
||||||
|
<span class="sync-text">Saving to server...</span>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
html = `
|
||||||
|
<span class="sync-dot failed"></span>
|
||||||
|
<span class="sync-text">Offline - Save failed</span>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "saved":
|
||||||
|
default:
|
||||||
|
html = `
|
||||||
|
<span class="sync-dot saved"></span>
|
||||||
|
<span class="sync-text">Saved to server</span>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform background synchronization of unsynced answers
|
||||||
|
*/
|
||||||
|
export async function performSync(exam_details, URLS) {
|
||||||
|
if (!exam_details || !exam_details.eid) return;
|
||||||
|
|
||||||
|
// Find all unsynced answers for this exam/cid/aid
|
||||||
|
const unsynced = await db.answers
|
||||||
|
.where({
|
||||||
|
aid: exam_details.aid,
|
||||||
|
cid: exam_details.cid,
|
||||||
|
eid: exam_details.eid,
|
||||||
|
})
|
||||||
|
.filter(ans => ans.synced === false)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (unsynced.length === 0) {
|
||||||
|
if (currentStatus === "syncing") {
|
||||||
|
updateSyncUI("saved");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSyncUI("syncing");
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
eid: exam_details.eid,
|
||||||
|
cid: exam_details.cid,
|
||||||
|
start_time: exam_details.start_time,
|
||||||
|
answers: JSON.stringify(unsynced),
|
||||||
|
in_progress: "true",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
$.post(URLS.exam_submit_url, json, null, "json")
|
||||||
|
.done(resolve)
|
||||||
|
.fail(reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && response.success) {
|
||||||
|
// Mark as synced in Dexie database
|
||||||
|
await db.transaction("rw", db.answers, async () => {
|
||||||
|
for (const ans of unsynced) {
|
||||||
|
ans.synced = true;
|
||||||
|
await db.answers.put(ans);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateSyncUI("saved");
|
||||||
|
} else {
|
||||||
|
updateSyncUI("failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Autosubmission failed:", error);
|
||||||
|
updateSyncUI("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an immediate, debounced sync (2s delay after last change)
|
||||||
|
*/
|
||||||
|
export function triggerImmediateSync(exam_details, URLS) {
|
||||||
|
if (debounceTimeout) {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
}
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
performSync(exam_details, URLS);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize background sync process
|
||||||
|
*/
|
||||||
|
export function initSync(exam_details, URLS) {
|
||||||
|
// Clear any existing sync process
|
||||||
|
if (syncInterval) {
|
||||||
|
clearInterval(syncInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
updateSyncUI("saved");
|
||||||
|
|
||||||
|
// Run periodic sync every 15 seconds
|
||||||
|
syncInterval = setInterval(() => {
|
||||||
|
performSync(exam_details, URLS);
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
Generated
+6364
-302
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -6,8 +6,10 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"lib": "lib"
|
"lib": "lib"
|
||||||
},
|
},
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
||||||
|
"lint": "eslint --fix . && echo 'Lint complete.'"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
"homepage": "https://github.com/xkjq/rts#readme",
|
"homepage": "https://github.com/xkjq/rts#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^6.8.0",
|
||||||
"eslint-config-google": "^0.14.0"
|
"eslint-config-google": "^0.14.0",
|
||||||
},
|
"jest": "^30.4.2"
|
||||||
"lint": "eslint --fix . && echo 'Lint complete.'"
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Unit tests for RTS Background Sync
|
||||||
|
|
||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
// Mock Dexie and jQuery globally before importing modules that use them
|
||||||
|
const mockAnswersWhere = jest.fn();
|
||||||
|
const mockAnswersFilter = jest.fn();
|
||||||
|
const mockAnswersToArray = jest.fn();
|
||||||
|
const mockAnswersPut = jest.fn();
|
||||||
|
const mockTransaction = jest.fn();
|
||||||
|
|
||||||
|
globalThis.Dexie = class {
|
||||||
|
constructor(name) {
|
||||||
|
this.name = name;
|
||||||
|
this.answers = {
|
||||||
|
where: mockAnswersWhere,
|
||||||
|
put: mockAnswersPut,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
version() {
|
||||||
|
return {
|
||||||
|
stores: () => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
transaction(mode, tables, cb) {
|
||||||
|
return mockTransaction(mode, tables, cb);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define mock jQuery $.post
|
||||||
|
const mockPostDone = jest.fn();
|
||||||
|
const mockPostFail = jest.fn();
|
||||||
|
const mockPost = jest.fn(() => ({
|
||||||
|
done: (cb) => {
|
||||||
|
mockPostDone(cb);
|
||||||
|
return {
|
||||||
|
fail: (failCb) => {
|
||||||
|
mockPostFail(failCb);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
globalThis.$ = {
|
||||||
|
post: mockPost,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock DOM elements
|
||||||
|
const mockHtml = jest.fn();
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: jest.fn(() => ({
|
||||||
|
set innerHTML(val) {
|
||||||
|
mockHtml(val);
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dynamic import to avoid ES module hoisting execution before mocks are set up
|
||||||
|
let performSync, updateSyncUI;
|
||||||
|
|
||||||
|
describe("RTS Sync Unit Tests", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const syncModule = await import("../js/sync.js");
|
||||||
|
performSync = syncModule.performSync;
|
||||||
|
updateSyncUI = syncModule.updateSyncUI;
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateSyncUI updates the DOM container", () => {
|
||||||
|
updateSyncUI("syncing");
|
||||||
|
expect(globalThis.document.getElementById).toHaveBeenCalledWith("sync-status");
|
||||||
|
expect(mockHtml).toHaveBeenCalledWith(expect.stringContaining("Saving to server..."));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("performSync does nothing if exam details are missing", async () => {
|
||||||
|
await performSync(null, {});
|
||||||
|
expect(mockAnswersWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("performSync processes unsynced answers and marks them as synced", async () => {
|
||||||
|
const examDetails = {
|
||||||
|
aid: "session-123",
|
||||||
|
cid: "9999",
|
||||||
|
eid: "rapid/1",
|
||||||
|
start_time: 1234567,
|
||||||
|
};
|
||||||
|
const URLS = {
|
||||||
|
exam_submit_url: "/submit-url",
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsyncedAnswers = [
|
||||||
|
{ aid: "session-123", cid: "9999", eid: "rapid/1", qid: 1, qidn: "1", ans: "Normal", synced: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
mockAnswersWhere.mockReturnValue({
|
||||||
|
filter: mockAnswersFilter.mockReturnValue({
|
||||||
|
toArray: mockAnswersToArray.mockResolvedValue(unsyncedAnswers)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPostDone.mockImplementation((cb) => {
|
||||||
|
cb({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
mockTransaction.mockImplementation(async (mode, tables, cb) => {
|
||||||
|
await cb();
|
||||||
|
});
|
||||||
|
|
||||||
|
await performSync(examDetails, URLS);
|
||||||
|
|
||||||
|
expect(mockAnswersWhere).toHaveBeenCalledWith({
|
||||||
|
aid: examDetails.aid,
|
||||||
|
cid: examDetails.cid,
|
||||||
|
eid: examDetails.eid,
|
||||||
|
});
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
URLS.exam_submit_url,
|
||||||
|
expect.objectContaining({
|
||||||
|
in_progress: "true",
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
"json"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user