diff --git a/README.md b/README.md index 7c5439c2..be626280 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ### 新版比赛页面兼容性 -支持新版 `/web/contest/` 比赛列表、题目列表、题面、订正排名、标程和题解页面。脚本保留新版应用的导航、语言切换和排序,并使用与旧版页面相同的 Bootstrap 5 界面与导航栏行为(新版顶部导航栏、用户菜单、翻译等),在页面异步加载及站内切换后应用界面主题、倒计时、复制、讨论入口、批量打开题目和代码阅读器等增强功能。比赛题号及文件输入输出信息通过新版 API 同步,提交页面不再依赖旧版比赛 HTML。相关问题:[#1029](https://github.com/XMOJ-Script-dev/XMOJ-Script/issues/1029)。 +支持新版 `/web/contest/` 比赛列表、题目列表、题面、订正排名、标程和题解页面。脚本保留新版应用的导航和排序,统一使用中文,并使用与旧版页面相同的 Bootstrap 5 界面与导航栏行为(新版顶部导航栏、用户菜单、翻译等),在页面异步加载及站内切换后应用界面主题、倒计时、复制、讨论入口、批量打开题目和代码阅读器等增强功能。比赛题号及文件输入输出信息通过新版 API 同步,提交页面不再依赖旧版比赛 HTML。相关问题:[#1029](https://github.com/XMOJ-Script-dev/XMOJ-Script/issues/1029)。 ### 介绍 diff --git a/Update.json b/Update.json index 8dc5b4f8..d784cc30 100644 --- a/Update.json +++ b/Update.json @@ -3839,6 +3839,17 @@ } ], "Notes": "

修复新版比赛页面与旧版页面导航栏中文字体不一致的问题。

" + }, + "3.8.3": { + "UpdateDate": 1791106754384, + "Prerelease": true, + "UpdateContents": [ + { + "PR": 1040, + "Description": "fix: restore account and profile pages and enforce Chinese UI" + } + ], + "Notes": "

修复修改账号页面迁移后菜单与更新日志无法访问的问题,保留新版账号表单,在当前账号页面独立显示标签编辑,并保留 AC 代码导出功能。旧账号链接自动跳转到当前页面。恢复个人中心的身份信息、评分、标签、最后在线与已解决题目,修复请求失败后的空白信息及图例重叠。移除语言切换入口,经典界面和新版竞赛界面统一使用中文,隐藏账号表单中的语言选项并统一提交中文。修复最后在线时间显示为 HTML 源码的问题。重新绘制个人中心提交与正确数量的历史图表,兼容浅色、深色主题及移动端,默认清理设置不再隐藏图表。提交与正确数量在同一图表中以细线和半透明柱形叠加展示,减少密集曲线遮挡。恢复暂存更改中的 API 请求超时和错误日志。

" } } } \ No newline at end of file diff --git a/XMOJ.user.js b/XMOJ.user.js index ea1c87d8..ab400e36 100644 --- a/XMOJ.user.js +++ b/XMOJ.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name XMOJ -// @version 3.8.2 +// @version 3.8.3 // @description XMOJ增强脚本 // @author @XMOJ-Script-dev, @langningchen and the community // @namespace https://github/langningchen @@ -44,6 +44,115 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +// Set the app preference at document-start, before Vue requests /api/nav. +function InitializeChineseLanguage() { + let wasEnglish = document.cookie.split(";").some(cookie => cookie.trim() == "XMOJ_LANG=en"); + document.cookie = "XMOJ_LANG=zh; path=/; max-age=31536000; SameSite=Lax" + (location.protocol == "https:" ? "; Secure" : ""); + return wasEnglish; +} + +const chineseAccountForms = new WeakSet(); +function EnforceChineseAccountLanguage(root) { + const radios = [...root.querySelectorAll('input[type="radio"]')]; + const Matches = (radio, language) => { + const label = [...radio.labels || []].map(node => node.textContent.trim()); + return language == "zh" ? /^(cn|zh|zh[-_]cn)$/i.test(radio.value) || label.some(text => /^(中文|简体中文|Chinese)$/i.test(text)) : + /^en(?:[-_]us)?$/i.test(radio.value) || label.some(text => /^English$/i.test(text)); + }; + for (const chinese of radios.filter(radio => Matches(radio, "zh"))) { + const english = radios.find(radio => radio !== chinese && radio.name === chinese.name && radio.form === chinese.form && Matches(radio, "en")); + if (!chinese.name || !english) continue; + if (!chinese.checked) chinese.click(); + chinese.setAttribute("data-xmoj-script-chinese-language", ""); + let group = chinese.parentElement; + while (group && !group.contains(english)) group = group.parentElement; + if (group && !group.matches("form, body, html") && !group.querySelector('input:not([type="radio"]), select, textarea, button')) { + group.setAttribute("data-xmoj-script-language-selector", ""); + const heading = group.previousElementSibling; + if (heading && /^(界面语言|语言|Language)$/i.test(heading.textContent.trim())) heading.setAttribute("data-xmoj-script-language-selector", ""); + } else { + for (const radio of [chinese, english]) { + radio.setAttribute("data-xmoj-script-language-selector", ""); + for (const label of radio.labels || []) label.setAttribute("data-xmoj-script-language-selector", ""); + } + } + for (const label of root.querySelectorAll("label, legend")) { + if (/^(界面语言|语言|Language)$/i.test(label.textContent.trim())) label.setAttribute("data-xmoj-script-language-selector", ""); + } + const form = chinese.form; + if (form && !chineseAccountForms.has(form)) { + chineseAccountForms.add(form); + // Keep the native field enabled so the hidden Chinese value is submitted. + form.addEventListener("submit", () => EnforceChineseAccountLanguage(form), true); + form.addEventListener("formdata", event => { + EnforceChineseAccountLanguage(form); + for (const radio of form.querySelectorAll("input[data-xmoj-script-chinese-language]")) event.formData.set(radio.name, radio.value); + }); + } + } +} + +function EnforceChineseView(root = document) { + document.documentElement.lang = "zh-CN"; + if (!document.getElementById("UserScript-ChineseLanguage")) { + let style = document.createElement("style"); + style.id = "UserScript-ChineseLanguage"; + style.textContent = `#lang_cn_to_en, #lang_en_to_cn, .xmoj-lang-switch, + [data-xmoj-script-language-selector] { display: none !important; } + .lang_en { display: none !important; } + .lang_cn { display: revert !important; }`; + document.head.appendChild(style); + } + if (location.pathname === "/modify_user_info.php") EnforceChineseAccountLanguage(root); + // Keep Vue's nodes and handlers intact; it can replace the navbar on navigation. + for (let link of root.querySelectorAll('#xmoj-navbar > ul.navbar-right > li > a[href="#"]:not(.dropdown-toggle)')) { + if (/^(English|中文)$/.test(link.textContent.trim())) link.setAttribute("data-xmoj-script-language-selector", ""); + } + // The problem/solution selector has its own reactive language, separate from nav. + for (let button of root.querySelectorAll(".xmoj-lang-switch button")) { + if (button.textContent.trim() == "中文" && !button.classList.contains("hidden") && !button.hidden) button.click(); + } +} + +async function EnsureChinesePage() { + EnforceChineseView(); + const retryKey = "UserScript-ChineseLanguageReload"; + const pageKey = location.pathname + location.search + location.hash; + // Classic pages persist the preference through the current account form. + // change_lang.php is defunct; do not call it or reload classic pages for it. + if (!(IsContestWebApp() && initiallyEnglishLanguage)) { + sessionStorage.removeItem(retryKey); + return false; + } + try { + // A server that refuses the preference must not trap the user in a reload loop. + if (sessionStorage.getItem(retryKey) == pageKey) throw new Error("中文设置未生效"); + sessionStorage.setItem(retryKey, pageKey); + location.reload(); + } catch (error) { + console.error("[XMOJ-Script] Chinese language:", error); + let alert = document.createElement("div"); + alert.className = "alert alert-warning"; + alert.role = "alert"; + alert.textContent = "切换中文失败,请检查网络后重试。 "; + let retry = document.createElement("button"); + retry.type = "button"; + retry.className = "btn btn-outline-secondary"; + retry.textContent = "重试"; + retry.addEventListener("click", () => { + sessionStorage.removeItem(retryKey); + location.reload(); + }); + alert.appendChild(retry); + document.body.prepend(alert); + RevealPage(); + return false; + } + return true; +} + +const initiallyEnglishLanguage = InitializeChineseLanguage(); + // The /web application owns its DOM. Never run the legacy body.innerHTML rewrites // inside it; InitializeContestWebApp maps its Bootstrap 3 markup to Bootstrap 5. function IsContestWebApp(pathname = location.pathname) { @@ -605,6 +714,11 @@ let _earlyObs = null; // the saved theme and inject Bootstrap CSS + the skin CSS before the first paint, // and we block the page's own old stylesheets from loading at all. (() => { + const AccountRedirect = GetAccountSettingsRedirect(); + if (AccountRedirect) { + location.replace(AccountRedirect); + return; + } // Old contest pages: move to the /web app before anything is drawn. const ContestWebRedirect = GetContestWebRedirect(); if (ContestWebRedirect) { @@ -1695,7 +1809,14 @@ let clearCredential = async () => { } } }; -let RequestAPI = (Action, Data, CallBack) => { +let RequestAPI = (Action, Data, CallBack, ErrorCallBack) => { + let completed = false; + let Fail = (message) => { + if (completed) return; + completed = true; + if (ErrorCallBack) ErrorCallBack(message); + else console.error("[XMOJ-Script] Request " + Action + ": " + message); + }; try { let Session = ""; let Temp = document.cookie.split(";"); @@ -1738,18 +1859,38 @@ let RequestAPI = (Action, Data, CallBack) => { "DebugMode": UtilityEnabled("DebugMode") }, data: DataString, + // Preserve the stashed general request limit and faster badge retries. + timeout: ErrorCallBack ? 15000 : 30000, + onerror: () => Fail("网络错误,请重试"), + ontimeout: () => Fail("请求超时,请重试"), + onabort: () => Fail("请求已取消,请重试"), onload: (Response) => { + if (completed) return; if (UtilityEnabled("DebugMode")) { console.log("Received for", Action + ":", Response.responseText); } + if (ErrorCallBack && (Response.status < 200 || Response.status >= 300)) { + Fail("请求失败(HTTP " + Response.status + "),请重试"); + return; + } + let result; try { - CallBack(JSON.parse(Response.responseText)); + result = JSON.parse(Response.responseText); } catch (Error) { console.log(Response.responseText); + Fail("服务器响应异常,请重试"); + return; + } + completed = true; + try { + if (CallBack) CallBack(result); + } catch (Error) { + console.error(Error); } } }); } catch (e) { + Fail("请求失败,请重试"); console.error(e); if (UtilityEnabled("DebugMode")) { SmartAlert("XMOJ-Script internal error!\n\n" + e + "\n\n" + "If you see this message, please report it to the developer.\nDon't forget to include console logs and a way to reproduce the error!\n\nDon't want to see this message? Disable DebugMode."); @@ -2148,15 +2289,470 @@ function CreateProblemSwitcher(ProblemList, IsCurrent) { return problemSwitcher; } +// Read server-rendered links, or the numeric p(id, count) calls in unexecuted HTML. +// Never eval profile scripts: document.write after page load can replace the page. +function GetProfileSolvedProblems(root = document) { + let table = root.querySelector("#statics"); + let problems = new Set(); + for (let link of table?.querySelectorAll("a[href]") || []) { + try { + let url = new URL(link.getAttribute("href"), location.href); + let value = url.searchParams.get("id"); + let id = Number(value); + if (url.pathname == "/problem.php" && /^\d+$/.test(value) && Number.isSafeInteger(id) && id > 0) problems.add(id); + } catch { /* Ignore malformed links rather than aborting the profile. */ } + } + for (let script of table?.querySelectorAll("script") || []) { + for (let match of script.textContent.matchAll(/\bp\(\s*(\d+)\s*,\s*\d+\s*\)/g)) { + if (Number.isSafeInteger(Number(match[1])) && Number(match[1]) > 0) problems.add(Number(match[1])); + } + } + return [...problems]; +} + +function InitializeProfileBadge(container, userID, isAdmin) { + let badge = document.createElement("span"); + badge.className = "badge me-2"; + badge.id = "UserScriptProfileBadge"; + let status = document.createElement("span"); + status.setAttribute("role", "status"); + let retry = document.createElement("button"); + retry.type = "button"; + retry.className = "btn btn-outline-secondary btn-sm"; + retry.textContent = "重试加载标签"; + let controls = document.createElement("span"); + container.append(badge, status, retry, controls); + const ClearCache = () => { + let keys = []; + for (let i = 0; i < localStorage.length; i++) { + let key = localStorage.key(i); + if (key.startsWith("UserScript-User-" + userID + "-Badge-")) keys.push(key); + } + keys.forEach(key => localStorage.removeItem(key)); + }; + const Fail = message => { + status.textContent = "标签暂不可用:" + message; + retry.hidden = false; + retry.disabled = false; + }; + const Load = () => { + status.textContent = "正在加载标签..."; + retry.hidden = true; + retry.disabled = true; + controls.replaceChildren(); + RequestAPI("GetBadge", {UserID: userID}, response => { + if (!response?.Success) { Fail(response?.Message || "服务器响应异常,请重试"); return; } + let data = response.Data ?? {}; + let content = String(data.Content ?? ""); + badge.textContent = content; + badge.hidden = content === ""; + badge.style.backgroundColor = /^#[0-9a-f]{6}$/i.test(data.BackgroundColor) ? data.BackgroundColor : "#000000"; + badge.style.color = /^#[0-9a-f]{6}$/i.test(data.Color) ? data.Color : "#ffffff"; + status.textContent = ""; + retry.disabled = false; + if (!isAdmin) return; + let button = document.createElement("button"); + button.type = "button"; + button.className = "btn btn-outline-primary btn-sm"; + button.textContent = content === "" ? "添加标签" : "删除标签"; + controls.appendChild(button); + button.addEventListener("click", () => { + if (content !== "" && !confirm("您确定要删除此标签吗?")) return; + button.disabled = true; + const Failed = message => { status.textContent = message; button.disabled = false; }; + RequestAPI(content === "" ? "NewBadge" : "DeleteBadge", {UserID: userID}, result => { + if (!result?.Success) { Failed(result?.Message || "服务器响应异常,请重试"); return; } + ClearCache(); + Load(); + }, Failed); + }); + }, Fail); + }; + retry.addEventListener("click", Load); + Load(); +} + +function GetProfileActivityData(root = document) { + // Read only the numeric history statements emitted by userinfo.php. Never eval + // the page's graph initializer: the old Flot renderer is unreliable here. + const script = [...root.querySelectorAll("script")].find(node => /\$\.plot\s*\(/.test(node.textContent) && /["']#submission["']/.test(node.textContent)); + if (!script) return null; + const series = [new Map(), new Map()]; + for (const match of script.textContent.matchAll(/\bd([12])\.push\(\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]\s*\)\s*;?/g)) { + const timestamp = Number(match[2]), count = Number(match[3]); + if (!Number.isSafeInteger(timestamp) || timestamp <= 0 || timestamp > 8640000000000000 || !Number.isSafeInteger(count)) continue; + series[Number(match[1]) - 1].set(timestamp, count); + } + return series.map(points => [...points].sort((a, b) => a[0] - b[0])); +} + +function InitializeProfileActivityChart(chart) { + const data = GetProfileActivityData(); + chart.style.cssText = "width:100%;max-width:600px;text-align:left;border:1px solid var(--bs-border-color, #adb5bd);box-sizing:border-box;padding:12px;margin-top:12px"; + chart.replaceChildren(); + if (!data || !data.some(points => points.length)) { + chart.textContent = data ? "暂无提交记录" : "暂时无法读取提交记录"; + chart.setAttribute("role", "status"); + return; + } + const colors = ["#d4a017", "#4799cc"], labels = ["提交", "正确"]; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", "提交与正确数量随时间的变化"); + svg.style.cssText = "display:block;width:100%;height:auto"; + chart.appendChild(svg); + const Node = (tag, attributes, text, parent = svg) => { + const node = document.createElementNS(svg.namespaceURI, tag); + for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value); + if (text != null) node.textContent = text; + parent.appendChild(node); + return node; + }; + const date = new Intl.DateTimeFormat("zh-CN", {timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit"}); + const DateLabel = timestamp => date.format(new Date(timestamp)).replaceAll("/", "-"); + const points = data.flat(); + let min = Math.min(...points.map(point => point[0])), max = Math.max(...points.map(point => point[0])); + const day = 86400000; + min -= day / 2; + max += day / 2; + const maximum = Math.max(1, ...points.map(point => point[1])); + const power = 10 ** Math.floor(Math.log10(maximum / 4)); + const step = Math.max(1, [1, 2, 5, 10].find(value => value * power >= maximum / 4) * power); + const limit = Math.ceil(maximum / step) * step; + let previousWidth = 0; + const Draw = () => { + const width = Math.max(280, Math.min(600, svg.clientWidth || 600)); + if (width === previousWidth) return; + previousWidth = width; + svg.replaceChildren(); + svg.setAttribute("viewBox", "0 0 " + width + " 280"); + const left = 45, right = width - 12, top = 28, bottom = 238; + const X = timestamp => left + (timestamp - min) / (max - min) * (right - left); + const Y = count => bottom - count / limit * (bottom - top); + for (let tick = 0; tick <= limit; tick += step) { + Node("line", {x1: left, x2: right, y1: Y(tick), y2: Y(tick), stroke: "var(--bs-border-color, #adb5bd)", opacity: "0.5"}); + Node("text", {x: left - 6, y: Y(tick) + 4, "text-anchor": "end", "font-size": "12", fill: "var(--bs-body-color, currentColor)"}, Number(tick.toPrecision(12))); + } + for (let index = 0; index < labels.length; index++) Node("text", {x: left + index * 65, y: 16, "font-size": "13", fill: colors[index]}, labels[index]); + // Paint translucent accepted bars first, with a thin submission line above + // them. Invisible hover targets preserve tooltips without dense dot markers. + const accepted = Node("g", {"data-layer": "accepted"}); + const barWidth = Math.max(1, Math.min(12, (right - left) * day / (max - min) * 0.6)); + for (const [timestamp, count] of data[1]) { + const bar = Node("rect", {x: X(timestamp) - barWidth / 2, y: Y(count), width: barWidth, height: bottom - Y(count), fill: colors[1], "fill-opacity": "0.55", "data-series": "accepted"}, null, accepted); + Node("title", {}, DateLabel(timestamp) + " 正确:" + count, bar); + } + const submitted = Node("g", {"data-layer": "submitted"}); + if (data[0].length) Node("path", {d: data[0].map(([timestamp, count], index) => (index ? "L" : "M") + X(timestamp) + " " + Y(count)).join(" "), fill: "none", stroke: colors[0], "stroke-width": "1.25", "stroke-opacity": "0.8", "data-series": "submitted"}, null, submitted); + for (const [timestamp, count] of data[0]) { + const point = Node("circle", {cx: X(timestamp), cy: Y(count), r: data[0].length === 1 ? "3" : "5", fill: data[0].length === 1 ? colors[0] : "transparent", "data-series": "submitted"}, null, submitted); + Node("title", {}, DateLabel(timestamp) + " 提交:" + count, point); + } + const singleDate = max - min === day; + const ticks = singleDate ? 1 : Math.max(2, Math.floor((right - left) / 110)); + for (let i = 0; i < ticks; i++) { + const timestamp = singleDate ? min + day / 2 : min + day / 2 + i / (ticks - 1) * (max - min - day); + Node("text", {x: X(timestamp), y: bottom + 22, "text-anchor": singleDate ? "middle" : i === 0 ? "start" : i === ticks - 1 ? "end" : "middle", "font-size": "11", fill: "var(--bs-body-color, currentColor)", "data-axis": "date"}, DateLabel(timestamp)); + } + }; + Draw(); + if (typeof ResizeObserver === "function") { + const observer = new ResizeObserver(() => { + if (!chart.isConnected) { observer.disconnect(); return; } + Draw(); + }); + observer.observe(chart); + } +} + +function InitializeProfileChart(table) { + let chart = table.querySelector("#PieDiv"); + if (!chart) return; + let entries = [...table.rows].filter(row => row.cells[1]?.querySelector('a[href*="jresult="]') && !/^(解决|Solved)/i.test(row.cells[0]?.textContent.trim())) + .map(row => ({label: row.cells[0].textContent.trim(), value: Number(row.cells[1].textContent.trim())})) + .filter(entry => Number.isFinite(entry.value) && entry.value >= 0); + let total = entries.reduce((sum, entry) => sum + entry.value, 0); + if (!entries.length) return; + let colors = ["#FF8080", "#8080FF", "#80bb80", "#FF0066", "#9900FF", "#996633", "#006633", "#000000", "#66cddd", "#0066FF"]; + let pie = document.createElement("div"); + pie.setAttribute("role", "img"); + pie.setAttribute("aria-label", "判题结果分布"); + pie.style.cssText = "width:100px;height:100px;border-radius:50%;flex-shrink:0"; + pie.style.setProperty("border-radius", "50%", "important"); + let legend = document.createElement("ul"); + legend.className = "list-unstyled mb-0"; + legend.style.textAlign = "left"; + let position = 0; + let segments = entries.map((entry, index) => { + let start = position; + position += total ? entry.value / total * 100 : 0; + let color = colors[index % colors.length]; + let item = document.createElement("li"); + let marker = document.createElement("span"); + marker.style.cssText = "display:inline-block;width:10px;height:10px;margin-right:6px;background:" + color; + item.append(marker, document.createTextNode(entry.label + " [" + (total ? Math.round(entry.value / total * 100) : 0) + "%]")); + legend.appendChild(item); + return color + " " + start + "% " + position + "%"; + }); + pie.style.background = total ? "conic-gradient(" + segments.join(",") + ")" : "#808080"; + // The old canvas positions ten labels within 100px; normal flow prevents overlap. + chart.style.cssText = "display:flex;flex-wrap:wrap;align-items:center;gap:12px;width:auto;height:auto"; + chart.replaceChildren(pie, legend); +} + +// Commit the complete profile before any optional API call can fail or stall. +function InitializeUserProfile(isAdmin = false) { + if (location.pathname != "/userinfo.php" || new URLSearchParams(location.search).has("ByUserScript") || document.getElementById("UserScriptProfile")) return; + let table = document.getElementById("statics"); + if (!table) return; + let caption = table.caption?.cloneNode(true); + caption?.querySelectorAll("a").forEach(link => link.remove()); + let identity = caption?.textContent.trim() || ""; + let separator = identity.indexOf("--"); + let userID = separator >= 0 ? identity.slice(0, separator).trim() : new URLSearchParams(location.search).get("user"); + let nickname = separator >= 0 ? identity.slice(separator + 2).trim() : identity; + if (!userID) return; + let problems = GetProfileSolvedProblems(); + let rows = [...table.rows]; + const Value = pattern => rows.find(row => pattern.test(row.cells[0]?.textContent.trim() || ""))?.cells[1]?.textContent.trim(); + let email = Value(/^(电子邮箱|Email:?)$/i) || ""; + let submitted = Number(Value(/^(提交|Submit(?:ted)?|Submissions?:?)$/i)); + let accepted = Number(Value(/^(正确|Accepted:?)$/i)); + let rating = submitted > 0 && Number.isFinite(accepted) ? Math.round(accepted / submitted * 1000) : 0; + let profile = document.createElement("div"); + profile.id = "UserScriptProfile"; + profile.className = "row text-start text-left"; + let left = document.createElement("div"); + left.className = "col-md-5"; + let right = document.createElement("div"); + right.className = "col-md-7"; + profile.append(left, right); + let header = document.createElement("div"); + header.className = "row mb-2"; + let avatarContainer = document.createElement("div"); + avatarContainer.className = "col-auto"; + let avatar = document.createElement("img"); + avatar.className = "rounded me-2"; + avatar.alt = userID + " 的头像"; + avatar.width = avatar.height = 120; + avatar.src = "https://cravatar.cn/avatar/00000000000000000000000000000000?d=mp&f=y"; + // The native profile already contains the email and statistics. No second + // profile fetch is needed to show the avatar or compute the rating. + if (email) { + try { avatar.src = "https://cravatar.cn/avatar/" + CryptoJS.MD5(email).toString() + "?d=retro"; } + catch (error) { console.error("[XMOJ-Script] Profile avatar:", error); } + } + avatarContainer.appendChild(avatar); + let info = document.createElement("div"); + info.className = "col-auto"; + info.style.lineHeight = "40px"; + const Line = text => { + let line = document.createElement("div"); + line.textContent = text; + info.appendChild(line); + return line; + }; + Line("用户名:" + userID); + Line("昵称:" + nickname); + if (UtilityEnabled("Rating")) Line("评分:" + rating); + let lastOnline = Line("最后在线:加载中..."); + lastOnline.id = "UserScriptProfileLastOnline"; + let badges = document.createElement("div"); + info.appendChild(badges); + header.append(avatarContainer, info); + left.appendChild(header); + let heading = document.createElement("h5"); + heading.textContent = "已解决题目"; + right.appendChild(heading); + let solved = document.createElement("div"); + solved.id = "UserScriptProfileSolved"; + solved.style.lineHeight = "1.8"; + for (let id of problems) { + let link = document.createElement("a"); + link.href = "/problem.php?id=" + id; + link.target = "_blank"; + link.textContent = id; + solved.append(link, document.createTextNode(" ")); + } + right.appendChild(solved); + + // Move the original statistics nodes so their links and handlers survive. + table.insertAdjacentElement("beforebegin", profile); + for (let link of table.caption?.querySelectorAll("a") || []) info.appendChild(link); + table.caption?.remove(); + let solvedCell = [...table.querySelectorAll("td[rowspan]")].find(cell => + cell.querySelector('a[href*="problem.php"]') || [...cell.querySelectorAll("script")].some(script => /\bfunction\s+p\s*\(/.test(script.textContent))); + let activity = document.getElementById("submission"); + if (solvedCell?.querySelector('a[href*="problem.php"]')) { + // Keep the native per-problem submission-count links and their handlers. + solved.replaceChildren(); + let group = null; + for (let node of [...solvedCell.childNodes]) { + if (node.nodeName == "SCRIPT" || node === activity) continue; + if (node.nodeName == "A" && node.getAttribute("href")?.includes("problem.php")) { + group = document.createElement("span"); + group.style.cssText = "display:inline-block;white-space:nowrap;margin-right:8px"; + solved.appendChild(group); + } + (group || solved).appendChild(node); + } + } + // Submission history is functional and must survive the default cleanup setting. + if (activity) { + right.appendChild(activity); + InitializeProfileActivityChart(activity); + } + solvedCell?.remove(); + let firstRow = table.rows[0]; + if (firstRow?.cells.length >= 2 && !firstRow.cells[0].textContent.trim() && !firstRow.cells[1].textContent.trim()) firstRow.remove(); + for (let row of table.rows) { + if (row.cells[0]?.textContent.trim() == "Statistics") row.cells[0].textContent = "统计"; + if (row.cells[0]?.textContent.trim() == "Email:") row.cells[0].textContent = "电子邮箱"; + for (let cell of row.cells) cell.removeAttribute("align"); + } + table.removeAttribute("width"); + left.appendChild(table); + InitializeProfileChart(table); + document.title = "用户 " + userID + " 的个人中心"; + InitializeProfileBadge(badges, userID, isAdmin); + RequestAPI("LastOnline", {Username: userID}, response => { + lastOnline.innerHTML = "最后在线:" + (response?.Success && response.Data?.logintime != null ? GetRelativeTime(response.Data.logintime) : "暂无记录"); + }, () => { lastOnline.textContent = "最后在线:暂不可用"; }); +} + +function IsAccountSettingsPage(pathname) { + return pathname == "/modify_user_info.php"; +} + +function GetAccountSettingsRedirect() { + return location.pathname == "/modifypage.php" ? "/modify_user_info.php" + location.search + location.hash : null; +} + +function InitializeAccountFeatures(authenticated) { + if (!authenticated || !CurrentUsername || !IsAccountSettingsPage(location.pathname) || new URLSearchParams(location.search).has("ByUserScript")) return; + InitializeAccountBadgeEditor(); +} + +function LoadAccountBadge(content, background, color, onLoad, onError) { + RequestAPI("GetBadge", {"UserID": String(CurrentUsername)}, (response) => { + if (!response?.Success) { + onError(response?.Message || "服务器响应异常,请重试"); + return; + } + let badge = response.Data ?? {}; + content.value = String(badge.Content ?? ""); + // Color inputs cannot represent an empty value. Use explicit defaults + // for missing/invalid colors rather than the browser's silent fallback. + background.value = /^#[0-9a-f]{6}$/i.test(badge.BackgroundColor) ? badge.BackgroundColor : "#000000"; + color.value = /^#[0-9a-f]{6}$/i.test(badge.Color) ? badge.Color : "#ffffff"; + onLoad(); + }, onError); +} + +// Unloaded fields must never be saved as a blank replacement for an existing badge. +function InitializeAccountBadgeLoading(content, background, color, status, onReady = () => {}) { + let state = {loaded: false}; + let retry = document.createElement("button"); + retry.type = "button"; + retry.className = "btn btn-outline-secondary mt-2"; + retry.innerText = "重试加载标签"; + status.insertAdjacentElement("afterend", retry); + let Load = () => { + state.loaded = false; + [content, background, color].forEach(input => { input.disabled = true; }); + onReady(false); + status.innerText = "正在加载标签..."; + retry.hidden = true; + retry.disabled = true; + LoadAccountBadge(content, background, color, () => { + state.loaded = true; + [content, background, color].forEach(input => { input.disabled = false; }); + status.innerText = ""; + retry.disabled = false; + onReady(true); + }, message => { + status.innerText = "标签加载失败:" + message; + retry.hidden = false; + retry.disabled = false; + }); + }; + retry.addEventListener("click", Load); + Load(); + return state; +} + +function SaveAccountBadge(content, background, color) { + let userID = String(CurrentUsername); + return new Promise(resolve => { + RequestAPI("EditBadge", { + "UserID": userID, + "Content": String(content), + "BackgroundColor": String(background), + "Color": String(color) + }, (response) => { + if (!response?.Success) { + resolve({Success: false, Message: response?.Message || "服务器响应异常,请重试"}); + return; + } + let keys = []; + for (let i = 0; i < localStorage.length; i++) { + let key = localStorage.key(i); + if (key.startsWith("UserScript-User-" + userID + "-Badge-")) keys.push(key); + } + keys.forEach(key => localStorage.removeItem(key)); + resolve(response); + }, message => resolve({Success: false, Message: message})); + }); +} + +// Keep the migrated site's form, including its submit handler and CSRF fields. +// Script badges are saved separately so they cannot interfere with account edits. +function InitializeAccountBadgeEditor() { + if (document.getElementById("UserScriptBadgeEditor")) return; + let container = document.querySelector("main") || document.querySelector("body > .container") || document.body; + let editor = document.createElement("div"); + editor.id = "UserScriptBadgeEditor"; + editor.className = "border p-2 my-3"; + editor.innerHTML = `
标签编辑
+
+
+
+
+
+
+ +
`; + let content = editor.querySelector("#UserScriptBadgeContent"); + let background = editor.querySelector("#UserScriptBadgeBackground"); + let color = editor.querySelector("#UserScriptBadgeColor"); + let button = editor.querySelector("button"); + let status = editor.querySelector("[role='status']"); + container.appendChild(editor); + let badgeState = InitializeAccountBadgeLoading(content, background, color, status, loaded => { + button.disabled = !loaded; + }); + button.addEventListener("click", async () => { + if (!badgeState.loaded) return; + button.disabled = true; + status.innerText = ""; + try { + let result = await SaveAccountBadge(content.value, background.value, color.value); + status.innerText = result.Success ? "修改成功" : result.Message; + } finally { + button.disabled = false; + } + }); +} + // The ResetType user menu. Shared by the legacy navbar and the /web app so both // menus offer the same entries and animate the same way. function CreateUserMenuItems() { let Entries = [ - ["修改帐号", () => { location.href = "https://www.xmoj.tech/modifypage.php"; }], + ["修改帐号", () => { location.href = "https://www.xmoj.tech/modify_user_info.php"; }], ["个人中心", () => { location.href = "https://www.xmoj.tech/userinfo.php?user=" + CurrentUsername; }], ["短消息", () => { location.href = "https://www.xmoj.tech/mail.php"; }], ["插件设置", () => { location.href = "https://www.xmoj.tech/index.php?ByUserScript=1"; }], - ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modifypage.php?ByUserScript=1"; }], + ["插件更新日志", () => { location.href = "https://www.xmoj.tech/modify_user_info.php?ByUserScript=1"; }], ["注销", () => { clearCredential(); GM.cookie.set({ @@ -2749,6 +3345,7 @@ async function InitializeContestWebApp() { return false; }); if (UtilityEnabled("NewBootstrap")) ApplyBootstrap5Markup(); + EnforceChineseView(root); EnhanceNav(); if (!revealed && root.querySelector(".navbar")) { // As on the legacy pages: top bar first, then show the page. @@ -3009,6 +3606,7 @@ class NavbarStyler { // which executes userscripts as classic scripts (not ES modules). (async () => { if (GetContestWebRedirect()) return; +if (GetAccountSettingsRedirect()) return; if (document.readyState === "loading") { await new Promise(r => document.addEventListener("DOMContentLoaded", r, { once: true })); } @@ -3026,6 +3624,7 @@ if (_foucStyle) { } setTimeout(RevealPage, 4000); } +if (await EnsureChinesePage()) return; if (IsContestWebApp()) { await InitializeContestWebApp(); return; @@ -3054,7 +3653,10 @@ if (profileElement === null) { } CurrentUsername = profileElement.innerText; CurrentUsername = CurrentUsername.replaceAll(/[^a-zA-Z0-9]/g, ""); +// Initialize migrated account tools independently of the legacy navbar/layout handler. +InitializeAccountFeatures(logined || (CurrentUsername && !/^(Login|Guest)$/i.test(CurrentUsername))); let IsAdmin = AdminUserList.indexOf(CurrentUsername) !== -1; +InitializeUserProfile(IsAdmin); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)"); const applyTheme = (theme) => { @@ -3113,12 +3715,15 @@ async function main() { if (UtilityEnabled("Translate")) { if (document.querySelector("#navbar > ul:nth-child(1) > li:nth-child(2) > a") != null) document.querySelector("#navbar > ul:nth-child(1) > li:nth-child(2) > a").innerText = "题库"; } - //send analytics + // Preserve native account listeners during page-wide customization. RequestAPI("SendData", {}); - if (UtilityEnabled("ReplaceLinks")) { + // Whole-body replacements discard native forms, profile charts, + // and listeners already installed on the enhanced profile. + let preserveNativePage = ["/modify_user_info.php", "/modify_password.php", "/userinfo.php"].includes(location.pathname); + if (UtilityEnabled("ReplaceLinks") && !preserveNativePage) { document.body.innerHTML = String(document.body.innerHTML).replaceAll(/\[([^<]*)<\/a>\]/g, ""); } - if (UtilityEnabled("ReplaceXM")) { + if (UtilityEnabled("ReplaceXM") && !preserveNativePage) { document.body.innerHTML = String(document.body.innerHTML).replaceAll("我", "高老师"); document.body.innerHTML = String(document.body.innerHTML).replaceAll("小明", "高老师"); document.body.innerHTML = String(document.body.innerHTML).replaceAll("下海", "上海"); @@ -3131,6 +3736,7 @@ async function main() { document.title = String(document.title).replaceAll("小明", "高老师"); } + // Bootstrap stylesheet and markup migration. if (UtilityEnabled("NewBootstrap")) { // Remove any old Bootstrap/theme stylesheets that the browser's preload // scanner may have fetched and applied before the document-start @@ -3503,7 +4109,7 @@ async function main() { Alert.classList.add("alert-primary"); Alert.role = "alert"; Alert.innerHTML = `欢迎您使用XMOJ增强脚本!点击 - 此处 + 此处 查看更新日志。`; Container.appendChild(Alert); let UtilitiesCard = document.createElement("div"); @@ -3622,7 +4228,7 @@ async function main() { }, {"ID": "AddAnimation", "Type": "A", "Name": "增加动画"}, { "ID": "ReplaceYN", "Type": "F", "Name": "题目前状态提示替换为好看的图标" }, {"ID": "RemoveAlerts", "Type": "D", "Name": "去除多余反复的提示"}, { - "ID": "Translate", "Type": "F", "Name": "统一使用中文,翻译了部分英文*" + "ID": "Translate", "Type": "F", "Name": "翻译部分英文和统一用语*" }, { "ID": "ReplaceLinks", "Type": "F", "Name": "将网站中所有以方括号包装的链接替换为按钮" }, {"ID": "RemoveUseless", "Type": "D", "Name": "删去无法使用的功能*"}, { @@ -5089,9 +5695,9 @@ async function main() { PassCheck.click(); } }); - } else if (location.pathname == "/modifypage.php" && document.querySelector("body > div > div") == null) { - //页面结构异常(如403/404页面),跳过处理 - } else if (location.pathname == "/modifypage.php") { + } else if (IsAccountSettingsPage(location.pathname) && (document.querySelector("body > div > div") == null || (SearchParams.get("ByUserScript") == null && document.querySelector("body > div > div form") == null))) { + //页面结构异常或未登录,保留站点提示 + } else if (IsAccountSettingsPage(location.pathname)) { if (SearchParams.get("ByUserScript") != null) { document.title = "XMOJ-Script 更新日志"; if (document.querySelector("body > div > div.mt-3") != null) document.querySelector("body > div > div.mt-3").innerHTML = ""; @@ -5145,388 +5751,72 @@ async function main() { UpdateDataCardLink.innerText = "查看该版本"; } }); - } else { - document.title = "修改账号"; - let Nickname = document.getElementsByName("nick")[0].value; - let School = document.getElementsByName("school")[0].value; - let EmailAddress = document.getElementsByName("email")[0].value; - let CodeforcesAccount = document.getElementsByName("acc_cf")[0].value; - let AtcoderAccount = document.getElementsByName("acc_atc")[0].value; - let USACOAccount = document.getElementsByName("acc_usaco")[0].value; - let LuoguAccount = document.getElementsByName("acc_luogu")[0].value; - if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").innerHTML = `
-
-
-
-
-
-
- - 修改头像 -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - -
`; - document.getElementById("Nickname").value = Nickname; - document.getElementById("School").value = School; - document.getElementById("EmailAddress").value = EmailAddress; - document.getElementById("CodeforcesAccount").value = CodeforcesAccount; - document.getElementById("AtcoderAccount").value = AtcoderAccount; - document.getElementById("USACOAccount").value = USACOAccount; - document.getElementById("LuoguAccount").value = LuoguAccount; - RequestAPI("GetBadge", { - "UserID": String(CurrentUsername) - }, (Response) => { - if (Response.Success) { - BadgeRow.style.display = ""; - BadgeContent.value = Response.Data.Content; - BadgeBackgroundColor.value = Response.Data.BackgroundColor; - BadgeColor.value = Response.Data.Color; - let Temp = []; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("UserScript-User-" + CurrentUsername + "-Badge-")) { - Temp.push(localStorage.key(i)); - } - } - for (let i = 0; i < Temp.length; i++) { - localStorage.removeItem(Temp[i]); - } - } - }); - ModifyInfo.addEventListener("click", async () => { - ModifyInfo.disabled = true; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = ""; - ErrorElement.style.display = "none"; - SuccessElement.style.display = "none"; - //表单元素缺失时不提交,避免用空值覆盖用户信息 - if (document.querySelector("#BadgeContent") == null || document.querySelector("#BadgeBackgroundColor") == null || document.querySelector("#BadgeColor") == null || document.querySelector("#Nickname") == null || document.querySelector("#OldPassword") == null || document.querySelector("#NewPassword") == null || document.querySelector("#NewPasswordAgain") == null || document.querySelector("#School") == null || document.querySelector("#EmailAddress") == null || document.querySelector("#CodeforcesAccount") == null || document.querySelector("#AtcoderAccount") == null || document.querySelector("#USACOAccount") == null || document.querySelector("#LuoguAccount") == null) { - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - ErrorElement.style.display = "block"; - ErrorElement.innerText = "页面加载异常,请刷新后重试"; - return; - } - let BadgeContent = document.querySelector("#BadgeContent").value; - let BadgeBackgroundColor = document.querySelector("#BadgeBackgroundColor").value; - let BadgeColor = document.querySelector("#BadgeColor").value; - await new Promise((Resolve) => { - RequestAPI("EditBadge", { - "UserID": String(CurrentUsername), - "Content": String(BadgeContent), - "BackgroundColor": String(BadgeBackgroundColor), - "Color": String(BadgeColor) - }, (Response) => { - if (Response.Success) { - Resolve(); - } else { - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - ErrorElement.style.display = "block"; - ErrorElement.innerText = Response.Message; - } - }); - }); - let Nickname = document.querySelector("#Nickname").value; - let OldPassword = document.querySelector("#OldPassword").value; - let NewPassword = document.querySelector("#NewPassword").value; - let NewPasswordAgain = document.querySelector("#NewPasswordAgain").value; - let School = document.querySelector("#School").value; - let EmailAddress = document.querySelector("#EmailAddress").value; - let CodeforcesAccount = document.querySelector("#CodeforcesAccount").value; - let AtcoderAccount = document.querySelector("#AtcoderAccount").value; - let USACOAccount = document.querySelector("#USACOAccount").value; - let LuoguAccount = document.querySelector("#LuoguAccount").value; - await fetch("https://www.xmoj.tech/modify.php", { - "headers": { - "content-type": "application/x-www-form-urlencoded" - }, - "referrer": location.href, - "method": "POST", - "body": "nick=" + encodeURIComponent(Nickname) + "&" + "opassword=" + encodeURIComponent(OldPassword) + "&" + "npassword=" + encodeURIComponent(NewPassword) + "&" + "rptpassword=" + encodeURIComponent(NewPasswordAgain) + "&" + "school=" + encodeURIComponent(School) + "&" + "email=" + encodeURIComponent(EmailAddress) + "&" + "acc_cf=" + encodeURIComponent(CodeforcesAccount) + "&" + "acc_atc=" + encodeURIComponent(AtcoderAccount) + "&" + "acc_usaco=" + encodeURIComponent(USACOAccount) + "&" + "acc_luogu=" + encodeURIComponent(LuoguAccount) - }); - ModifyInfo.disabled = false; - if (ModifyInfo.querySelector("span") != null) ModifyInfo.querySelector("span").style.display = "none"; - SuccessElement.style.display = "block"; - }); - if (UtilityEnabled("ExportACCode")) { - let ExportACCode = document.createElement("button"); - if (document.querySelector("body > div.container > div") != null) document.querySelector("body > div.container > div").appendChild(ExportACCode); - ExportACCode.innerText = "导出AC代码"; - ExportACCode.className = "btn btn-outline-secondary"; - ExportACCode.addEventListener("click", () => { - ExportACCode.disabled = true; - ExportACCode.innerText = "正在导出..."; - let Request = new XMLHttpRequest(); - Request.addEventListener("readystatechange", () => { - if (Request.readyState == 4) { - if (Request.status == 200) { - let Response = Request.responseText; - let ACCode = Response.split("------------------------------------------------------\r\n"); - let ScriptElement = document.createElement("script"); - ScriptElement.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; - document.head.appendChild(ScriptElement); - ScriptElement.onload = () => { - var Zip = new JSZip(); - for (let i = 0; i < ACCode.length; i++) { - let CurrentCode = ACCode[i]; - if (CurrentCode != "") { - let lineBreakPos = CurrentCode.search(/[\r\n]/); - if (lineBreakPos === -1) continue; - let headerLine = CurrentCode.slice(0, lineBreakPos); - let digitMatch = headerLine.match(/\d+/); - if (!digitMatch) continue; - let CurrentQuestionID = digitMatch[0]; - let bodyStart = lineBreakPos + 1; - if (CurrentCode[lineBreakPos] === '\r' && CurrentCode[lineBreakPos + 1] === '\n') { - bodyStart = lineBreakPos + 2; - } - CurrentCode = CurrentCode.slice(bodyStart); - CurrentCode = CurrentCode.replaceAll("\r", ""); - Zip.file(CurrentQuestionID + ".cpp", CurrentCode); + } + if (SearchParams.get("ByUserScript") == null && UtilityEnabled("ExportACCode")) { + let ExportACCode = document.createElement("button"); + if (document.querySelector("body > div.container > div") != null) document.querySelector("body > div.container > div").appendChild(ExportACCode); + ExportACCode.innerText = "导出AC代码"; + ExportACCode.className = "btn btn-outline-secondary"; + ExportACCode.addEventListener("click", () => { + ExportACCode.disabled = true; + ExportACCode.innerText = "正在导出..."; + let Request = new XMLHttpRequest(); + Request.addEventListener("readystatechange", () => { + if (Request.readyState == 4) { + if (Request.status == 200) { + let Response = Request.responseText; + let ACCode = Response.split("------------------------------------------------------\r\n"); + let ScriptElement = document.createElement("script"); + ScriptElement.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; + document.head.appendChild(ScriptElement); + ScriptElement.onload = () => { + var Zip = new JSZip(); + for (let i = 0; i < ACCode.length; i++) { + let CurrentCode = ACCode[i]; + if (CurrentCode != "") { + let lineBreakPos = CurrentCode.search(/[\r\n]/); + if (lineBreakPos === -1) continue; + let headerLine = CurrentCode.slice(0, lineBreakPos); + let digitMatch = headerLine.match(/\d+/); + if (!digitMatch) continue; + let CurrentQuestionID = digitMatch[0]; + let bodyStart = lineBreakPos + 1; + if (CurrentCode[lineBreakPos] === '\r' && CurrentCode[lineBreakPos + 1] === '\n') { + bodyStart = lineBreakPos + 2; } + CurrentCode = CurrentCode.slice(bodyStart); + CurrentCode = CurrentCode.replaceAll("\r", ""); + Zip.file(CurrentQuestionID + ".cpp", CurrentCode); } - ExportACCode.innerText = "正在生成压缩包……"; - Zip.generateAsync({type: "blob"}) - .then(function (Content) { - saveAs(Content, "ACCodes.zip"); - ExportACCode.innerText = "AC代码导出成功"; - ExportACCode.disabled = false; - setTimeout(() => { - ExportACCode.innerText = "导出AC代码"; - }, 1000); - }); - }; - } else { - ExportACCode.disabled = false; - ExportACCode.innerText = "AC代码导出失败"; - setTimeout(() => { - ExportACCode.innerText = "导出AC代码"; - }, 1000); - } + } + ExportACCode.innerText = "正在生成压缩包……"; + Zip.generateAsync({type: "blob"}) + .then(function (Content) { + saveAs(Content, "ACCodes.zip"); + ExportACCode.innerText = "AC代码导出成功"; + ExportACCode.disabled = false; + setTimeout(() => { + ExportACCode.innerText = "导出AC代码"; + }, 1000); + }); + }; + } else { + ExportACCode.disabled = false; + ExportACCode.innerText = "AC代码导出失败"; + setTimeout(() => { + ExportACCode.innerText = "导出AC代码"; + }, 1000); } - }); - Request.open("GET", "https://www.xmoj.tech/export_ac_code.php", true); - Request.send(); + } }); - } + Request.open("GET", "https://www.xmoj.tech/export_ac_code.php", true); + Request.send(); + }); } } else if (location.pathname == "/userinfo.php" && document.querySelector("body > div > div") == null) { //页面结构异常(如403/404页面),跳过处理 } else if (location.pathname == "/userinfo.php") { - if (SearchParams.get("ByUserScript") === null) { - if (UtilityEnabled("RemoveUseless")) { - let Temp = document.getElementById("submission").childNodes; - for (let i = 0; i < Temp.length; i++) { - Temp[i].remove(); - } - } - if (document.querySelector("body > script:nth-child(5)") != null) eval(document.querySelector("body > script:nth-child(5)").innerHTML); - if (document.querySelector("#statics > tbody > tr:nth-child(1)") != null) document.querySelector("#statics > tbody > tr:nth-child(1)").remove(); - - let Temp = (document.querySelector("#statics > tbody") != null) ? document.querySelector("#statics > tbody").children : []; - for (let i = 0; i < Temp.length; i++) { - if (Temp[i].children[0] != undefined) { - if (Temp[i].children[0].innerText == "Statistics") { - Temp[i].children[0].innerText = "统计"; - } else if (Temp[i].children[0].innerText == "Email:") { - Temp[i].children[0].innerText = "电子邮箱"; - } - Temp[i].children[1].removeAttribute("align"); - } - } - - Temp = (document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)") != null) ? document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)").childNodes : []; - let ACProblems = []; - for (let i = 0; i < Temp.length; i++) { - if (Temp[i].tagName == "A" && Temp[i].href.indexOf("problem.php?id=") != -1) { - ACProblems.push(Number(Temp[i].innerText.trim())); - } - } - if (document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)") != null) document.querySelector("#statics > tbody > tr:nth-child(1) > td:nth-child(3)").remove(); - - let UserID, UserNick; - if (document.querySelector("#statics > caption") != null) [UserID, UserNick] = document.querySelector("#statics > caption").childNodes[0].data.trim().split("--"); - if (document.querySelector("#statics > caption") != null) document.querySelector("#statics > caption").remove(); - document.title = "用户 " + UserID + " 的个人中心"; - let Row = document.createElement("div"); - Row.className = "row"; - let LeftDiv = document.createElement("div"); - LeftDiv.className = "col-md-5"; - Row.appendChild(LeftDiv); - - let LeftTopDiv = document.createElement("div"); - LeftTopDiv.className = "row mb-2"; - LeftDiv.appendChild(LeftTopDiv); - let AvatarContainer = document.createElement("div"); - AvatarContainer.classList.add("col-auto"); - let AvatarElement = document.createElement("img"); - let UserEmailHash = (await GetUserInfo(UserID)).EmailHash; - if (UserEmailHash == undefined) { - AvatarElement.src = `https://cravatar.cn/avatar/00000000000000000000000000000000?d=mp&f=y`; - } else { - AvatarElement.src = `https://cravatar.cn/avatar/${UserEmailHash}?d=retro`; - } - AvatarElement.classList.add("rounded", "me-2"); - AvatarElement.style.height = "120px"; - AvatarContainer.appendChild(AvatarElement); - LeftTopDiv.appendChild(AvatarContainer); - - let UserInfoElement = document.createElement("div"); - UserInfoElement.classList.add("col-auto"); - UserInfoElement.style.lineHeight = "40px"; - UserInfoElement.innerHTML += "用户名:" + escapeHTML(UserID) + "
"; - UserInfoElement.innerHTML += "昵称:" + escapeHTML(UserNick) + "
"; - if (UtilityEnabled("Rating")) { - UserInfoElement.innerHTML += "评分:" + ((await GetUserInfo(UserID)).Rating) + "
"; - } - // Create a placeholder for the last online time - let lastOnlineElement = document.createElement('div'); - lastOnlineElement.innerHTML = "最后在线:加载中...
"; - UserInfoElement.appendChild(lastOnlineElement); - let BadgeInfo = await GetUserBadge(UserID); - if (IsAdmin) { - if (BadgeInfo.Content !== "") { - let DeleteBadgeButton = document.createElement("button"); - DeleteBadgeButton.className = "btn btn-outline-danger btn-sm"; - DeleteBadgeButton.innerText = "删除标签"; - DeleteBadgeButton.addEventListener("click", async () => { - if (confirm("您确定要删除此标签吗?")) { - RequestAPI("DeleteBadge", { - "UserID": UserID - }, (Response) => { - if (UtilityEnabled("DebugMode")) console.log(Response); - if (Response.Success) { - let Temp = []; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("UserScript-User-" + UserID + "-Badge-")) { - Temp.push(localStorage.key(i)); - } - } - for (let i = 0; i < Temp.length; i++) { - localStorage.removeItem(Temp[i]); - } - window.location.reload(); - } else { - SmartAlert(Response.Message); - } - }); - } - }); - UserInfoElement.appendChild(DeleteBadgeButton); - } else { - let AddBadgeButton = document.createElement("button"); - AddBadgeButton.className = "btn btn-outline-primary btn-sm"; - AddBadgeButton.innerText = "添加标签"; - AddBadgeButton.addEventListener("click", async () => { - RequestAPI("NewBadge", { - "UserID": UserID - }, (Response) => { - if (Response.Success) { - let Temp = []; - for (let i = 0; i < localStorage.length; i++) { - if (localStorage.key(i).startsWith("UserScript-User-" + UserID + "-Badge-")) { - Temp.push(localStorage.key(i)); - } - } - for (let i = 0; i < Temp.length; i++) { - localStorage.removeItem(Temp[i]); - } - window.location.reload(); - } else { - SmartAlert(Response.Message); - } - }); - }); - UserInfoElement.appendChild(AddBadgeButton); - } - } - RequestAPI("LastOnline", {"Username": UserID}, (result) => { - if (result.Success) { - if (UtilityEnabled("DebugMode")) { - console.log('lastOnline:' + result.Data.logintime); - } - lastOnlineElement.innerHTML = "最后在线:" + GetRelativeTime(result.Data.logintime) + "
"; - } else { - lastOnlineElement.innerHTML = "最后在线:近三个月内从未
"; - } - }); - LeftTopDiv.appendChild(UserInfoElement); - LeftDiv.appendChild(LeftTopDiv); - - let LeftTable = document.querySelector("body > div > div > center > table"); - LeftDiv.appendChild(LeftTable); - let RightDiv = document.createElement("div"); - RightDiv.className = "col-md-7"; - Row.appendChild(RightDiv); - RightDiv.innerHTML = "
已解决题目
"; - for (let i = 0; i < ACProblems.length; i++) { - RightDiv.innerHTML += "" + ACProblems[i] + " "; - } - if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").innerHTML = ""; - if (document.querySelector("body > div > div") != null) document.querySelector("body > div > div").appendChild(Row); - } else { + if (SearchParams.get("ByUserScript") !== null) { document.title = "上传标程"; if (document.querySelector("body > div > div.mt-3") != null) document.querySelector("body > div > div.mt-3").innerHTML = ` @@ -5554,12 +5844,7 @@ async function main() { return Response.text(); }).then((Response) => { let ParsedDocument = new DOMParser().parseFromString(Response, "text/html"); - let ScriptData = (ParsedDocument.querySelector("#statics > tbody > tr:nth-child(2) > td:nth-child(3) > script") != null) ? ParsedDocument.querySelector("#statics > tbody > tr:nth-child(2) > td:nth-child(3) > script").innerText : ""; - ScriptData = ScriptData.substr(ScriptData.indexOf("}") + 1).trim(); - ScriptData = ScriptData.split(";"); - for (let i = 0; i < ScriptData.length; i++) { - ACList.push(Number(ScriptData[i].substring(2, ScriptData[i].indexOf(",")))); - } + ACList = GetProfileSolvedProblems(ParsedDocument); }); RequestAPI("GetStdList", {}, async (Result) => { if (Result.Success) { diff --git a/package.json b/package.json index 834ca508..5f8df3e5 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "xmoj-script", - "version": "3.8.2", + "version": "3.8.3", "description": "an improvement script for xmoj.tech", "main": "AddonScript.js", "scripts": { - "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs" + "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs tests/navbar-styler.test.cjs tests/account-settings.test.cjs tests/chinese-language.test.cjs tests/profile-page.test.cjs" }, "devDependencies": { "playwright": "1.59.1" diff --git a/tests/account-settings.test.cjs b/tests/account-settings.test.cjs new file mode 100644 index 00000000..2530147d --- /dev/null +++ b/tests/account-settings.test.cjs @@ -0,0 +1,358 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const {chromium} = require('playwright'); + +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); +function Between(start, end) { + for (const marker of [start, end]) { + assert.equal(source.split(marker).length - 1, 1, 'expected a unique extraction marker: ' + marker); + } + assert.ok(source.indexOf(start) < source.indexOf(end)); + return source.slice(source.indexOf(start), source.indexOf(end)); +} +const helpers = Between('function IsAccountSettingsPage(', 'function InitializeUserMenu('); +const earlyRedirect = Between('function GetAccountSettingsRedirect(', 'function InitializeAccountFeatures(') + + Between('// Set to true by the early block', 'const CaptchaSiteKey'); +const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = ('); +const preprocessing = 'window.RunAccountPreprocessing = () => {' + Between( + '// Preserve native account listeners during page-wide customization.', + '// Bootstrap stylesheet and markup migration.') + '};'; +const handler = 'window.RunAccountPage = async () => { if (false) {' + + Between('} else if (IsAccountSettingsPage(location.pathname) &&', '} else if (location.pathname == "/userinfo.php" &&') + '}};'; +const nativeForm = `
+ + +
`; +const startup = 'window.RunAccountStartup = authenticated => { const logined = authenticated; ' + Between( + '// Initialize migrated account tools independently of the legacy navbar/layout handler.', + 'let IsAdmin = AdminUserList.indexOf(CurrentUsername)') + '};'; + +test('account-page migration browser regressions', {timeout: 60000}, async t => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + async function Page(route, content = nativeForm, options = {}) { + const page = await browser.newPage(); + await page.route('**/*', request => request.fulfill({contentType: 'text/html; charset=utf-8', body: + options.markup || `
${content}
`})); + await page.goto('https://account-settings.test' + route); + await page.evaluate(options => { + window.CurrentUsername = options.signedOut ? 'Login' : 'Tester'; + window.SearchParams = new URL(location.href).searchParams; + window.UtilityEnabled = name => ['ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'ExportACCode' && options.export); + window.ServerURL = 'https://updates.test'; + window.GetRelativeTime = () => 'today'; + window.escapeHTML = value => value; + window.GetUserInfo = async () => ({EmailHash: 'hash'}); + window.apiCalls = []; + document.cookie = 'PHPSESSID=fixture; path=/'; + window.GM_info = {script: {version: 'test'}}; + window.badgeFailureMode = options.badgeFailureMode; + window.badgeLoadFailureMode = options.badgeLoadFailureMode; + window.badgeAccessDenied = options.noBadge; + window.GM_xmlhttpRequest = request => { + const action = new URL(request.url).pathname.slice(1); + if (action !== 'SendData') apiCalls.push({action, data: JSON.parse(request.data).Data}); + if (action === 'EditBadge' || action === 'GetBadge') { + const mode = action === 'GetBadge' ? window.badgeLoadFailureMode : window.badgeFailureMode; + if (mode === 'pending') { + window.pendingBadgeRequest = request; + return; + } + if (['network', 'timeout', 'abort'].includes(mode)) { + window.requestTimeout = request.timeout; + request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]](); + // A late callback must not turn a failed save into success. + request.onload({status: 200, responseText: '{"Success":true}'}); + return; + } + if (mode === 'throw') throw new Error('Transport initialization failed'); + if (['invalid-json', 'invalid-response', 'http'].includes(mode)) { + request.onload({status: mode === 'http' ? 503 : 200, + responseText: mode === 'invalid-json' ? 'Error' : 'null'}); + return; + } + } + request.onload({status: 200, responseText: JSON.stringify(action === 'GetBadge' ? { + Success: !window.badgeAccessDenied, Message: 'Load denied', Data: Object.hasOwn(options, 'badgeData') ? options.badgeData : + {Content: 'Badge', BackgroundColor: '#112233', Color: '#ffffff'} + } : {Success: !options.badgeFailure, Message: 'Save failed'})}); + }; + window.requests = []; + window.fetch = async url => { + requests.push(url); + return {json: async () => ({UpdateHistory: {'1.0.0': { + UpdateDate: '2026-01-01', UpdateContents: [{PR: 920, Description: 'Account migration'}] + }}})}; + }; + window.nativeAccountForm = document.querySelector('form'); + nativeAccountForm?.addEventListener('submit', event => { + event.preventDefault(); + window.nativeSubmitCount = (window.nativeSubmitCount || 0) + 1; + }); + }, {...options, signedOut: options.signedOut || content === 'Please log in'}); + await page.addScriptTag({content: api + helpers + preprocessing + handler + startup}); + await page.evaluate(async authenticated => { + RunAccountStartup(authenticated); + RunAccountPreprocessing(); + await RunAccountPage(); + }, !options.signedOut && content !== 'Please log in'); + return page; + } + try { + await t.test('preserves migrated fields, CSRF, action and native submit listener', async () => { + const page = await Page('/modify_user_info.php', nativeForm, {export: true}); + assert.equal(await page.evaluate(() => document.querySelector('form') === nativeAccountForm), true); + assert.equal(await page.locator('form').getAttribute('action'), '/modify_user_info.php'); + assert.equal(await page.locator('[name="csrf"]').inputValue(), 'token'); + assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname'); + assert.equal(await page.locator('[name="school"]').inputValue(), 'School'); + assert.equal(await page.locator('[name="email"]').inputValue(), 'user@example.test'); + assert.equal(await page.locator('input[type="password"]').count(), 0); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); + assert.equal(await page.getByRole('button', {name: '导出AC代码', exact: true}).count(), 1); + assert.deepEqual(await page.evaluate(() => requests), []); + await page.close(); + }); + for (const [layout, markup] of [ + ['main without a form', '
'], + ['direct body form', nativeForm], + ['changed container and navbar', '
' + nativeForm + '
'] + ]) { + await t.test('shows and saves the badge editor with ' + layout, async () => { + const page = await Page('/modify_user_info.php', nativeForm, {markup}); + assert.equal(await page.locator('#UserScriptBadgeEditor').isVisible(), true); + assert.equal(await page.getByRole('heading', {name: '标签编辑'}).isVisible(), true); + assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), 'Badge'); + assert.equal(await page.locator('form #UserScriptBadgeEditor').count(), 0); + await page.evaluate(() => RunAccountStartup(true)); + assert.equal(await page.locator('#UserScriptBadgeEditor').count(), 1); + await page.locator('#UserScriptBadgeContent').fill('Restored badge'); + await page.getByRole('button', {name: '修改标签', exact: true}).click(); + assert.equal(await page.getByRole('status').innerText(), '修改成功'); + assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge').map(call => call.data.Content)), ['Restored badge']); + assert.equal(await page.locator('[name="nick"]').inputValue(), 'Nickname'); + await page.close(); + }); + } + await t.test('uses the signed-in profile if the old login check no longer matches', async () => { + const page = await Page('/modify_user_info.php'); + await page.evaluate(() => { + document.getElementById('UserScriptBadgeEditor').remove(); + RunAccountStartup(false); + }); + assert.equal(await page.locator('#UserScriptBadgeEditor').isVisible(), true); + assert.equal(await page.locator('#UserScriptBadgeContent').inputValue(), 'Badge'); + await page.close(); + }); + for (const failure of [false, true]) { + await t.test('badge save ' + (failure ? 'reports failure and retains cache' : 'clears only this user’s badge cache'), async () => { + const page = await Page('/modify_user_info.php', nativeForm, {badgeFailure: failure}); + await page.evaluate(() => { + localStorage.setItem('UserScript-User-Tester-Badge-Content', 'stale'); + localStorage.setItem('UserScript-User-Other-Badge-Content', 'keep'); + }); + await page.locator('#UserScriptBadgeContent').fill(''); + await page.getByRole('button', {name: '修改标签', exact: true}).click(); + assert.deepEqual(await page.evaluate(() => apiCalls[1]), {action: 'EditBadge', data: { + UserID: 'Tester', Content: '', BackgroundColor: '#112233', Color: '#ffffff' + }}); + assert.equal(await page.getByRole('status').innerText(), failure ? 'Save failed' : '修改成功'); + assert.equal(await page.getByRole('button', {name: '修改标签', exact: true}).isEnabled(), true); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), failure ? 'stale' : null); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Other-Badge-Content')), 'keep'); + assert.equal(await page.evaluate(() => window.nativeSubmitCount || 0), 0); + await page.close(); + }); + } + for (const route of ['/modify_user_info.php']) { + const button = 'button:has-text("修改标签")'; + const content = '#UserScriptBadgeContent'; + const background = '#UserScriptBadgeBackground'; + const color = '#UserScriptBadgeColor'; + for (const badgeData of [null, {}, {Content: null, BackgroundColor: '', Color: 'invalid'}]) { + await t.test('normalizes incomplete badge data on ' + route + ': ' + JSON.stringify(badgeData), async () => { + const page = await Page(route, nativeForm, {badgeData}); + assert.equal(await page.locator(content).inputValue(), ''); + assert.equal(await page.locator(background).inputValue(), '#000000'); + assert.equal(await page.locator(color).inputValue(), '#ffffff'); + await page.locator(button).click(); + assert.deepEqual(await page.evaluate(() => apiCalls.find(call => call.action === 'EditBadge').data), { + UserID: 'Tester', Content: '', BackgroundColor: '#000000', Color: '#ffffff' + }); + assert.equal(await page.locator(button).isEnabled(), true); + await page.close(); + }); + } + for (const [mode, message] of [ + ['network', '网络错误,请重试'], ['timeout', '请求超时,请重试'], + ['abort', '请求已取消,请重试'], ['throw', '请求失败,请重试'], + ['invalid-json', '服务器响应异常,请重试'], ['invalid-response', '服务器响应异常,请重试'], + ['http', '请求失败(HTTP 503),请重试'] + ]) { + await t.test('recovers and retries a ' + mode + ' badge failure on ' + route, async () => { + const page = await Page(route, nativeForm, {badgeFailureMode: mode}); + await page.evaluate(() => { + localStorage.setItem('UserScript-User-Tester-Badge-Content', 'stale'); + localStorage.setItem('UserScript-User-Other-Badge-Content', 'keep'); + }); + await page.locator(content).fill('New badge'); + await page.locator(button).click(); + assert.equal(await page.locator(button).isEnabled(), true); + assert.equal(await page.locator('[role="status"]').innerText(), message); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'stale'); + assert.deepEqual(await page.evaluate(() => requests), []); + if (['network', 'timeout', 'abort'].includes(mode)) { + assert.equal(await page.evaluate(() => requestTimeout), 15000); + } + await page.evaluate(() => { window.badgeFailureMode = null; }); + await page.locator(button).click(); + assert.equal(await page.locator(button).isEnabled(), true); + assert.equal(await page.locator('[role="status"]').innerText(), '修改成功'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), null); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Other-Badge-Content')), 'keep'); + assert.deepEqual(await page.evaluate(() => requests), []); + await page.close(); + }); + } + } + await t.test('badge access denial leaves the native form usable', async () => { + const page = await Page('/modify_user_info.php', nativeForm, {noBadge: true}); + assert.equal(await page.locator('#UserScriptBadgeContent').isDisabled(), true); + assert.match(await page.getByRole('status').innerText(), /Load denied/); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); + await page.close(); + }); + for (const route of ['/modify_user_info.php']) { + const content = '#UserScriptBadgeContent'; + const save = 'button:has-text("修改标签")'; + const status = '[role="status"]'; + for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'invalid-response', 'http', 'denied']) { + await t.test('protects existing badges and retries a ' + mode + ' load failure on ' + route, async () => { + const page = await Page(route, nativeForm, + {badgeLoadFailureMode: mode, noBadge: mode === 'denied'}); + assert.match(await page.locator(status).innerText(), /标签加载失败:/); + assert.equal(await page.locator(content).isDisabled(), true); + assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isVisible(), true); + await page.evaluate(() => localStorage.setItem('UserScript-User-Tester-Badge-Content', 'existing badge')); + assert.equal(await page.locator(save).isDisabled(), true); + await page.locator(save).dispatchEvent('click'); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => nativeSubmitCount), 1); + assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), 'existing badge'); + await page.evaluate(() => { + window.badgeLoadFailureMode = null; + window.badgeAccessDenied = false; + }); + await page.getByRole('button', {name: '重试加载标签', exact: true}).click(); + assert.equal(await page.locator(content).isEnabled(), true); + assert.equal(await page.locator(content).inputValue(), 'Badge'); + assert.equal(await page.getByRole('button', {name: '重试加载标签', exact: true}).isHidden(), true); + await page.locator(content).fill('Recovered badge'); + await page.locator(save).click(); + assert.equal(await page.evaluate(() => apiCalls.find(call => call.action === 'EditBadge').data.Content), 'Recovered badge'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Tester-Badge-Content')), null); + await page.close(); + }); + } + await t.test('blocks badge writes while the initial load is pending on ' + route, async () => { + const page = await Page(route, nativeForm, {badgeLoadFailureMode: 'pending'}); + assert.match(await page.locator(status).innerText(), /正在加载标签/); + assert.equal(await page.locator(content).isDisabled(), true); + await page.locator(save).dispatchEvent('click'); + assert.deepEqual(await page.evaluate(() => apiCalls.filter(call => call.action === 'EditBadge')), []); + await page.evaluate(() => pendingBadgeRequest.onload({status: 200, responseText: JSON.stringify({ + Success: true, Data: {Content: 'Existing badge', BackgroundColor: '#112233', Color: '#ffffff'} + })})); + assert.equal(await page.locator(content).isEnabled(), true); + assert.equal(await page.locator(content).inputValue(), 'Existing badge'); + await page.close(); + }); + } + for (const route of ['/modify_user_info.php']) { + await t.test('renders changelog on ' + route + ' even without an account form', async () => { + const page = await Page(route + '?ByUserScript=1', 'Please log in', {export: true}); + assert.equal(await page.title(), 'XMOJ-Script 更新日志'); + assert.equal(await page.locator('.card-title').innerText(), '1.0.0'); + assert.match(await page.locator('.list-group-item').innerText(), /Account migration/); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + assert.equal(await page.locator('button').count(), 0); + await page.close(); + }); + await t.test('leaves login and error messages intact on ' + route, async () => { + const page = await Page(route, 'Please log in', {export: true}); + assert.equal(await page.locator('.mt-3').innerText(), 'Please log in'); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + await page.evaluate(async () => { + document.body.innerHTML = '

403

'; + await RunAccountPage(); + }); + assert.equal(await page.locator('h1').innerText(), '403'); + await page.close(); + }); + } + await t.test('redirects defunct account links to the current page preserving the query and fragment', async () => { + const page = await browser.newPage(); + await page.route('**/*', request => request.fulfill({contentType: 'text/html', body: nativeForm})); + // Run the actual document-start block, rather than redirecting from the test. + await page.addInitScript({content: 'function GetContestWebRedirect() { return null; }\nfunction IsContestWebApp() { return false; }\nlocalStorage.setItem("UserScript-Setting-NewBootstrap", "false");\n' + earlyRedirect}); + await page.goto('https://account-settings.test/modifypage.php?ByUserScript=1#latest'); + await page.waitForURL('https://account-settings.test/modify_user_info.php?ByUserScript=1#latest'); + assert.equal(await page.locator('form').getAttribute('action'), '/modify_user_info.php'); + await page.close(); + }); + await t.test('leaves the separate password form intact', async () => { + const page = await Page('/modify_password.php', '
', {export: true}); + assert.equal(await page.evaluate(() => document.querySelector('form') === nativeAccountForm), true); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + assert.equal(await page.locator('button').count(), 0); + await page.close(); + }); + for (const [label, route] of [['修改帐号', '/modify_user_info.php'], ['插件更新日志', '/modify_user_info.php?ByUserScript=1']]) { + await t.test('user menu sends ' + label + ' to the migrated route', async () => { + const page = await Page('/index.php'); + await page.evaluate(() => CreateUserMenuItems().forEach(item => document.body.appendChild(item))); + await Promise.all([page.waitForURL('https://www.xmoj.tech' + route), page.getByText(label, {exact: true}).click()]); + assert.equal(page.url(), 'https://www.xmoj.tech' + route); + await page.close(); + }); + } + } finally { + await browser.close(); + } +}); + +test('ordinary API requests retain the stashed timeout and log failures once', () => { + for (const event of ['onerror', 'ontimeout', 'onabort']) { + let request; + const errors = [], responses = []; + const context = vm.createContext({ + document: {cookie: 'PHPSESSID=fixture'}, CurrentUsername: 'Viewer', + GM_info: {script: {version: 'test'}}, UtilityEnabled: () => false, + GM_xmlhttpRequest: options => { request = options; }, + console: {error: message => errors.push(message)}, + saveResponse: response => responses.push(response) + }); + vm.runInContext(api + '\nRequestAPI("GetUserInfo", {}, saveResponse);', context); + assert.equal(request.timeout, 30000); + request[event](); + request[event](); + request.onload({status: 200, responseText: '{"Success":true}'}); + assert.equal(errors.length, 1, 'duplicate transport events must log only once'); + assert.match(errors[0], /Request GetUserInfo/); + assert.equal(responses.length, 0, 'a late response must not revive a failed request'); + } +}); + +test('current account tools initialize before the monolithic page handler', () => { + const call = source.indexOf('InitializeAccountFeatures(logined'); + const main = source.indexOf('await main();'); + assert.ok(call >= 0); + assert.ok(main >= 0); + assert.ok(call < main); + assert.match(source, /const AccountRedirect = GetAccountSettingsRedirect\(\);/); +}); diff --git a/tests/chinese-language.test.cjs b/tests/chinese-language.test.cjs new file mode 100644 index 00000000..3db28d04 --- /dev/null +++ b/tests/chinese-language.test.cjs @@ -0,0 +1,190 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const {chromium} = require('playwright'); +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); +function Between(start, end) { + for (const marker of [start, end]) assert.equal(source.split(marker).length - 1, 1, 'expected unique marker: ' + marker); + assert.ok(source.indexOf(start) < source.indexOf(end)); + return source.slice(source.indexOf(start), source.indexOf(end)); +} +const language = Between('function InitializeChineseLanguage(', '// The /web application owns its DOM.'); +const route = Between('function IsContestWebApp(', 'function GetContestRoute('); + +// Check reload behavior without navigating a real page away from its assertions. +function Context({cookie = '', pathname = '/problem.php', english = false, fail = false} = {}) { + const storage = new Map(); + const requests = []; + const nodes = []; + let reloads = 0; + const location = new URL('https://www.xmoj.tech' + pathname + '?id=1000#samples'); + location.reload = () => { reloads++; }; + const scope = vm.createContext({ + location, console: {error() {}}, RevealPage() {}, AbortController, setTimeout, clearTimeout, + sessionStorage: {getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key)}, + fetch: async (url, options) => { + requests.push({url, options}); + if (fail) throw new Error('Network failed'); + return {ok: true}; + }, + document: { + cookie, documentElement: {}, head: {appendChild: node => nodes.push(node)}, body: {prepend: node => nodes.push(node)}, + getElementById: id => nodes.find(node => node.id === id), + querySelectorAll: () => [], querySelector: () => english ? {} : null, + createElement: () => ({appendChild() {}, addEventListener() {}}) + } + }); + vm.runInContext(route + language, scope); + return {scope, requests, storage, nodes, reloads: () => reloads}; +} + +test('forces the web cookie before requests and reloads an existing English app once', async () => { + const {scope, requests, reloads} = Context({cookie: 'PHPSESSID=fixture; XMOJ_LANG=en', pathname: '/web/contest/123/A'}); + assert.match(scope.document.cookie, /^XMOJ_LANG=zh; path=/); + assert.equal(await scope.EnsureChinesePage(), true); + assert.equal(reloads(), 1); + assert.equal(scope.location.href, 'https://www.xmoj.tech/web/contest/123/A?id=1000#samples'); + assert.equal(requests.length, 0); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(reloads(), 1, 'refusing the preference must not create a reload loop'); +}); + +test('classic English pages do not call the broken language endpoint or reload', async () => { + const {scope, requests, reloads} = Context({english: true}); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(scope.document.documentElement.lang, 'zh-CN'); + assert.equal(requests.length, 0); + assert.equal(reloads(), 0); +}); + +test('Chinese classic pages do not reload merely because html declares lang=en', async () => { + const {scope, requests, reloads, storage} = Context(); + scope.document.documentElement.lang = 'en'; + storage.set('UserScript-ChineseLanguageReload', scope.location.pathname + scope.location.search + scope.location.hash); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(scope.document.documentElement.lang, 'zh-CN'); + assert.equal(requests.length, 0); + assert.equal(reloads(), 0); + assert.equal(storage.size, 0); +}); + +test('an English web app refusing Chinese shows a retry message without reloading again', async () => { + const {scope, reloads, nodes, storage, requests} = Context({cookie: 'XMOJ_LANG=en', pathname: '/web/contest/123/A'}); + storage.set('UserScript-ChineseLanguageReload', scope.location.pathname + scope.location.search + scope.location.hash); + assert.equal(await scope.EnsureChinesePage(), false); + assert.equal(reloads(), 0); + assert.equal(requests.length, 0); + assert.match(nodes.find(node => node.role === 'alert').textContent, /切换中文失败/); +}); + +test('hides classic and Vue language selectors while preserving forms and Chinese content', {timeout: 60000}, async () => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + const page = await browser.newPage(); + try { + await page.route('**/*', request => request.fulfill({contentType: 'text/html; charset=utf-8', body: ` + English + +
+ English statement +
+
English content
+ `})); + await page.goto('https://chinese-language.test/problem.php'); + await page.evaluate(() => { + window.accountForm = document.querySelector('form'); + window.submits = 0; + accountForm.addEventListener('submit', event => { event.preventDefault(); submits++; }); + document.querySelector('.xmoj-lang-switch button:last-child').addEventListener('click', event => { + event.target.classList.add('hidden'); + document.getElementById('statement').textContent = '中文内容'; + window.chineseClicks = (window.chineseClicks || 0) + 1; + }); + }); + await page.addScriptTag({content: route + language}); + await page.evaluate(() => { EnforceChineseView(); EnforceChineseView(); }); + assert.equal(await page.locator('#lang_cn_to_en').isHidden(), true); + assert.equal(await page.locator('#lang_en_to_cn').isHidden(), true); + assert.equal(await page.locator('#xmoj-navbar li:last-child a').isHidden(), true); + assert.equal(await page.locator('#xmoj-navbar .dropdown-toggle').isVisible(), true, 'a username named English must remain visible'); + assert.equal(await page.locator('.xmoj-lang-switch').isHidden(), true); + assert.equal(await page.locator('.lang_cn').isVisible(), true); + assert.equal(await page.locator('.lang_en').isHidden(), true); + assert.equal(await page.locator('#statement').innerText(), '中文内容'); + assert.equal(await page.evaluate(() => chineseClicks), 1); + assert.equal(await page.evaluate(() => document.querySelector('form') === accountForm), true); + await page.getByRole('button', {name: 'Save account'}).click(); + assert.equal(await page.evaluate(() => submits), 1); + assert.equal(await page.locator('#UserScript-ChineseLanguage').count(), 1); + await page.evaluate(() => { + const item = document.createElement('li'); + item.innerHTML = '中文'; + document.querySelector('#xmoj-navbar ul').appendChild(item); + EnforceChineseView(document.getElementById('app')); + }); + assert.equal(await page.locator('#xmoj-navbar li:last-child a').isHidden(), true); + } finally { + await browser.close(); + } +}); + +test('hides the native account language group and submits Chinese with other account fields intact', {timeout: 60000}, async t => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + try { + for (const [layout, fields, value] of [ + ['radio row', '
', 'cn'], + ['fieldset with numeric values', '
界面语言
', '0'], + ['direct form children', '', 'zh'] + ]) { + await t.test(layout, async () => { + const page = await browser.newPage(); + await page.route('**/*', route => route.fulfill({contentType: 'text/html; charset=utf-8', body: '
' + fields + '
'})); + await page.goto('https://chinese-language.test/modify_user_info.php'); + await page.evaluate(() => { + window.accountForm = document.querySelector('form'); + window.nativeSubmits = 0; + accountForm.addEventListener('submit', event => { + event.preventDefault(); + nativeSubmits++; + window.submittedData = Object.fromEntries(new FormData(accountForm)); + }); + }); + await page.addScriptTag({content: route + language}); + await page.evaluate(() => { EnforceChineseView(); EnforceChineseView(); }); + assert.equal(await page.getByText('界面语言', {exact: true}).isHidden(), true); + assert.equal(await page.getByText('English', {exact: true}).isHidden(), true); + assert.equal(await page.getByText('中文', {exact: true}).isHidden(), true); + assert.equal(await page.locator('input[type="radio"]:checked').inputValue(), value); + assert.equal(await page.locator('input[type="radio"]').evaluateAll(inputs => inputs.every(input => !input.disabled)), true); + assert.equal(await page.locator('[name="nick"]').isVisible(), true); + assert.equal(await page.getByRole('button', {name: '提交'}).isVisible(), true); + assert.equal(await page.evaluate(() => document.querySelector('form') === accountForm), true); + // Native submit handlers and FormData must receive Chinese even if + // another script reselects English after initialization. + await page.evaluate(() => { document.querySelector('input[value="en"], input[value="1"]').checked = true; }); + await page.getByRole('button', {name: '提交'}).click(); + assert.equal(await page.evaluate(() => nativeSubmits), 1); + assert.deepEqual(await page.evaluate(() => submittedData), {nick: 'boomzero', csrf: 'token', lang: value}); + assert.equal(await page.evaluate(() => { + document.querySelector('input[value="en"], input[value="1"]').checked = true; + return new FormData(accountForm).get('lang'); + }), value); + await page.close(); + }); + } + } finally { await browser.close(); } +}); + +test('startup enforces Chinese before either UI initializer and Vue refreshes reapply it', () => { + const startup = Between('(async () => {\nif (GetContestWebRedirect()) return;', '//otherwise CurrentUsername might be undefined'); + const enforce = startup.indexOf('await EnsureChinesePage()'); + const initialize = startup.indexOf('await InitializeContestWebApp()'); + assert.ok(enforce >= 0, 'Chinese initialization must exist'); + assert.ok(initialize >= 0, 'Vue initialization must exist'); + assert.ok(enforce < initialize); + const enhance = Between('function Enhance() {', 'function ScheduleEnhance() {'); + assert.match(enhance, /EnforceChineseView\(root\)/); +}); diff --git a/tests/contest-web.test.cjs b/tests/contest-web.test.cjs index 9528d2bb..7b7d94f1 100644 --- a/tests/contest-web.test.cjs +++ b/tests/contest-web.test.cjs @@ -178,7 +178,8 @@ test('the load-time hide keeps innerText readable for the page handlers', () => }); test('web and classic pages load the same monochrome fonts before either Bootstrap path', () => { - const early = between('// Set to true by the early block', 'const CaptchaSiteKey'); + const early = between('function GetAccountSettingsRedirect(', 'function InitializeAccountFeatures(') + + between('// Set to true by the early block', 'const CaptchaSiteKey'); const fontURLs = new Set(); for (const pathname of ['/web/contest', '/problem.php?id=1000']) { for (const cachedBootstrap of [false, true]) { diff --git a/tests/fixtures/profile.html b/tests/fixtures/profile.html new file mode 100644 index 00000000..6c3812ab --- /dev/null +++ b/tests/fixtures/profile.html @@ -0,0 +1,35 @@ + +
+
+ + + + + + + + + + +
ProfileTarget--昵称--后缀短消息
Solved Problems List
解决2
Activity chart
提交20
正确8
答案错误12
Statistics
Old overlapping legend
Email:profile@example.test
+ + diff --git a/tests/profile-page.test.cjs b/tests/profile-page.test.cjs new file mode 100644 index 00000000..13674a5c --- /dev/null +++ b/tests/profile-page.test.cjs @@ -0,0 +1,306 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {chromium} = require('playwright'); +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); +const fixture = fs.readFileSync(path.join(__dirname, 'fixtures/profile.html'), 'utf8'); +function Between(start, end) { + for (const marker of [start, end]) assert.equal(source.split(marker).length - 1, 1, 'unique extraction marker: ' + marker); + assert.ok(source.indexOf(start) < source.indexOf(end)); + return source.slice(source.indexOf(start), source.indexOf(end)); +} +const profile = Between('function GetProfileSolvedProblems(', 'function IsAccountSettingsPage('); +const api = Between('let RequestAPI = (', 'let SyncSettingsToCloud = ('); +const relativeTime = Between('let GetRelativeTime = (', 'function compareVersions('); +const preprocessing = 'window.RunProfilePreprocessing = () => {' + Between( + '// Preserve native account listeners during page-wide customization.', + '// Bootstrap stylesheet and markup migration.') + '};'; + +test('profile page browser regressions', {timeout: 60000}, async t => { + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + async function Page(options = {}) { + const page = await browser.newPage(); + await page.route('**/*', route => route.fulfill({contentType: 'text/html; charset=utf-8', body: options.html ?? fixture})); + await page.goto('https://profile.test/userinfo.php?user=ProfileTarget' + (options.upload ? '&ByUserScript=1' : '')); + await page.evaluate(options => { + window.CurrentUsername = 'Viewer'; + window.GM_info = {script: {version: 'test'}}; + document.cookie = 'PHPSESSID=fixture; path=/'; + window.UtilityEnabled = name => ['Rating', 'ReplaceLinks', 'ReplaceXM'].includes(name) || (name === 'RemoveUseless' && options.removeActivity !== false); + window.CryptoJS = {MD5: email => { window.avatarEmail = email; return {toString: () => '123456789012345678901234567890abcf'}; }}; + window.GetUserInfo = window.GetUserBadge = () => { throw new Error('Profile must not wait for these APIs'); }; + window.onlineTimestamp = options.onlineTimestamp ?? 1234; + window.confirm = () => true; + window.apiCalls = []; + window.requests = {}; + window.badgeMode = options.badgeMode; + window.onlineMode = options.onlineMode; + window.badgeData = Object.hasOwn(options, 'badgeData') ? options.badgeData : {Content: '省一', BackgroundColor: '#112233', Color: '#ffffff'}; + window.actionMode = options.actionMode; + window.GM_xmlhttpRequest = request => { + const action = new URL(request.url).pathname.slice(1); + const data = JSON.parse(request.data).Data; + if (action === 'SendData') return; + apiCalls.push({action, data, timeout: request.timeout}); + requests[action] = request; + const mode = action === 'GetBadge' ? badgeMode : action === 'LastOnline' ? onlineMode : actionMode; + if (mode === 'pending') return; + if (mode === 'throw') throw new Error('Transport failure'); + if (['network', 'timeout', 'abort'].includes(mode)) { + request[{network: 'onerror', timeout: 'ontimeout', abort: 'onabort'}[mode]](); + request.onload({status: 200, responseText: '{"Success":true,"Data":{"Content":"late"}}'}); + return; + } + if (mode === 'invalid-json' || mode === 'http') { + request.onload({status: mode === 'http' ? 503 : 200, responseText: 'Error'}); + return; + } + if (!mode && action === 'NewBadge') window.badgeData = {Content: 'New badge'}; + if (!mode && action === 'DeleteBadge') window.badgeData = null; + request.onload({status: 200, responseText: JSON.stringify({Success: mode !== 'denied', Message: 'Denied', + Data: action === 'GetBadge' ? badgeData : action === 'LastOnline' ? {logintime: onlineTimestamp} : {}})}); + }; + window.nativeStats = document.getElementById('statics'); + window.nativePie = document.getElementById('PieDiv'); + window.nativeActivity = document.getElementById('submission'); + window.nativeCountLink = document.querySelector('a[href*="problem_id=1000"]'); + window.pieClicks = window.countClicks = 0; + nativePie?.addEventListener('click', () => { pieClicks++; }); + nativeCountLink?.addEventListener('click', event => { event.preventDefault(); countClicks++; }); + }, options); + await page.addScriptTag({content: api + relativeTime + profile + preprocessing}); + await page.evaluate(isAdmin => { + InitializeUserProfile(isAdmin); + RunProfilePreprocessing(); + }, !!options.admin); + return page; + } + const AssertCore = async page => { + assert.equal(await page.locator('#UserScriptProfile').isVisible(), true); + assert.equal(await page.title(), '用户 ProfileTarget 的个人中心'); + assert.equal(await page.getByText('用户名:ProfileTarget', {exact: true}).isVisible(), true); + assert.equal(await page.getByText('昵称:昵称--后缀', {exact: true}).isVisible(), true); + assert.equal(await page.getByText('评分:400', {exact: true}).isVisible(), true); + assert.equal(await page.locator('#UserScriptProfile img').isVisible(), true); + assert.match(await page.locator('#UserScriptProfile img').getAttribute('src'), /123456789012345678901234567890abcf/); + assert.equal(await page.evaluate(() => avatarEmail), 'profile@example.test'); + assert.deepEqual(await page.locator('#UserScriptProfileSolved a[href*="problem.php"]').allTextContents(), ['1000 ', '1010 ']); + assert.equal(await page.getByRole('link', {name: '短消息', exact: true}).getAttribute('href'), 'mail.php?to_user=ProfileTarget'); + assert.equal(await page.locator('#statics tr').count(), 6); + assert.equal(await page.locator('#statics a[href*="jresult=4"]').allTextContents().then(values => values.join(',')), '2,8'); + assert.equal(await page.evaluate(() => document.getElementById('statics') === nativeStats), true); + assert.equal(await page.evaluate(() => document.getElementById('PieDiv') === nativePie), true); + assert.equal(await page.evaluate(() => document.querySelector('a[href*="problem_id=1000"]') === nativeCountLink), true); + assert.equal(await page.evaluate(() => nativeProfileRuns), 1, 'never re-evaluate native document.write scripts'); + }; + try { + await t.test('restores identity, rating, badge, last online, solved links and statistics', async () => { + const page = await Page(); + await AssertCore(page); + assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), '省一'); + assert.match(await page.locator('#UserScriptProfileLastOnline').innerText(), /^最后在线:\d+年前$/); + assert.deepEqual(await page.evaluate(() => apiCalls), [ + {action: 'GetBadge', data: {UserID: 'ProfileTarget'}, timeout: 15000}, + {action: 'LastOnline', data: {Username: 'ProfileTarget'}, timeout: 15000} + ]); + assert.equal(await page.getByRole('button', {name: '添加标签'}).count(), 0); + await page.getByRole('link', {name: '7', exact: true}).click(); + await page.locator('#PieDiv').click(); + assert.deepEqual(await page.evaluate(() => [countClicks, pieClicks]), [1, 1]); + await page.evaluate(() => InitializeUserProfile()); + assert.equal(await page.locator('#UserScriptProfile').count(), 1); + assert.equal(await page.evaluate(() => apiCalls.length), 2); + await page.close(); + }); + await t.test('renders core content immediately while optional requests are pending', async () => { + const page = await Page({badgeMode: 'pending', onlineMode: 'pending'}); + await AssertCore(page); + assert.match(await page.getByRole('status').innerText(), /正在加载标签/); + await page.evaluate(() => requests.LastOnline.ontimeout()); + assert.equal(await page.locator('#UserScriptProfileLastOnline').innerText(), '最后在线:暂不可用'); + await page.close(); + }); + await t.test('renders relative timestamps as readable text with the formatter tooltip', async () => { + const timestamp = Date.now() - 90 * 60 * 1000; + const page = await Page({onlineTimestamp: timestamp}); + const lastOnline = page.locator('#UserScriptProfileLastOnline'); + assert.equal(await lastOnline.innerText(), '最后在线:1小时前'); + assert.equal(await lastOnline.locator('span').count(), 1); + assert.equal(await lastOnline.locator('span').getAttribute('title'), await page.evaluate(value => new Date(value).toLocaleString(), timestamp)); + await page.close(); + }); + for (const mode of ['network', 'timeout', 'abort', 'throw', 'invalid-json', 'http', 'denied']) { + await t.test('preserves the profile and retries a ' + mode + ' badge failure', async () => { + const page = await Page({badgeMode: mode, onlineMode: mode}); + await AssertCore(page); + assert.match(await page.getByRole('status').innerText(), /标签暂不可用/); + assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isVisible(), true); + await page.evaluate(() => { window.badgeMode = null; }); + await page.getByRole('button', {name: '重试加载标签'}).click(); + assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), '省一'); + assert.equal(await page.getByRole('button', {name: '重试加载标签'}).isHidden(), true); + await page.close(); + }); + } + for (const badgeData of [null, {}, {Content: null, BackgroundColor: '', Color: 'invalid'}]) { + await t.test('handles missing badge data: ' + JSON.stringify(badgeData), async () => { + const page = await Page({badgeData, admin: true}); + await AssertCore(page); + assert.equal(await page.locator('#UserScriptProfileBadge').isHidden(), true); + assert.equal(await page.getByRole('button', {name: '添加标签'}).isVisible(), true); + await page.close(); + }); + } + await t.test('admin badge actions target the viewed user and recover from failures', async () => { + const page = await Page({admin: true, actionMode: 'network'}); + await page.evaluate(() => { + localStorage.setItem('UserScript-User-ProfileTarget-Badge-Content', 'cached'); + localStorage.setItem('UserScript-User-Viewer-Badge-Content', 'keep'); + }); + await page.getByRole('button', {name: '删除标签'}).click(); + assert.equal(await page.getByRole('button', {name: '删除标签'}).isEnabled(), true); + assert.equal(await page.getByRole('status').innerText(), '网络错误,请重试'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-ProfileTarget-Badge-Content')), 'cached'); + await page.evaluate(() => { window.actionMode = null; }); + await page.getByRole('button', {name: '删除标签'}).click(); + assert.equal(await page.getByRole('button', {name: '添加标签'}).isVisible(), true); + await page.getByRole('button', {name: '添加标签'}).click(); + assert.equal(await page.locator('#UserScriptProfileBadge').innerText(), 'New badge'); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-ProfileTarget-Badge-Content')), null); + assert.equal(await page.evaluate(() => localStorage.getItem('UserScript-User-Viewer-Badge-Content')), 'keep'); + assert.equal(await page.evaluate(() => apiCalls.filter(call => ['NewBadge', 'DeleteBadge'].includes(call.action)).every(call => call.data.UserID === 'ProfileTarget')), true); + await page.close(); + }); + await t.test('uses query identity with no caption and tolerates missing email, history and header rows', async () => { + const html = '
正确0
提交0
'; + const page = await Page({html}); + assert.equal(await page.getByText('用户名:ProfileTarget', {exact: true}).isVisible(), true); + assert.equal(await page.getByText('评分:0', {exact: true}).isVisible(), true); + assert.equal(await page.locator('#statics tr').count(), 2); + assert.equal(await page.locator('#UserScriptProfileSolved a').count(), 0); + assert.match(await page.locator('#UserScriptProfile img').getAttribute('src'), /d=mp/); + await page.close(); + }); + for (const removeActivity of [false, true]) { + await t.test('draws submission history without Flot with cleanup=' + removeActivity, async () => { + const page = await Page({removeActivity}); + const graph = page.getByRole('img', {name: '提交与正确数量随时间的变化'}); + assert.equal(await graph.isVisible(), true); + assert.equal(await page.evaluate(() => document.getElementById('submission') === nativeActivity), true); + assert.equal(await page.locator('#submission canvas').count(), 0); + assert.equal(await page.evaluate(() => window.jQuery), undefined); + assert.equal(await page.evaluate(() => window.nativeHistoryRuns), undefined, 'never execute the broken native graph script'); + assert.deepEqual(await page.evaluate(() => GetProfileActivityData()), [ + [[1704067200000, 20], [1704153600000, 5], [1704240000000, 12]], + [[1704067200000, 8], [1704153600000, 2], [1704240000000, 4]] + ]); + assert.equal(await graph.locator('path[data-series="submitted"]').count(), 1); + assert.equal(await graph.locator('circle[data-series="submitted"]').count(), 3); + assert.equal(await graph.locator('rect[data-series="accepted"]').count(), 3); + assert.equal(await graph.locator('[data-panel]').count(), 0, 'both series belong on one graph'); + assert.deepEqual(await graph.locator('[data-layer]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-layer'))), ['accepted', 'submitted'], 'accepted bars must paint behind the submission line'); + assert.equal(await graph.locator('rect').evaluateAll(nodes => nodes.every(node => Number(node.getAttribute('fill-opacity')) < 1)), true, 'overlapping bars must be translucent'); + assert.ok(Number(await graph.locator('path').getAttribute('stroke-width')) < 1.5, 'dense submissions use a thin line'); + assert.ok(Number(await graph.locator('path').getAttribute('stroke-opacity')) < 1, 'the line must allow the underlying bars to show through'); + assert.equal(await graph.locator('circle').evaluateAll(nodes => nodes.every(node => node.getAttribute('fill') === 'transparent')), true, 'dense dates must not paint overlapping dot markers'); + const timeline = await graph.evaluate(svg => [...svg.querySelectorAll('circle')].map((dot, index) => { + const bar = svg.querySelectorAll('rect')[index]; + return Math.abs(Number(dot.getAttribute('cx')) - Number(bar.getAttribute('x')) - Number(bar.getAttribute('width')) / 2); + })); + assert.ok(timeline.every(offset => offset < 0.00001), 'matching dates must align on the shared timeline'); + const scales = await graph.evaluate(svg => [...svg.querySelectorAll('circle')].map((dot, index) => { + const bar = svg.querySelectorAll('rect')[index], bottom = Number(bar.getAttribute('y')) + Number(bar.getAttribute('height')); + const submitted = [20, 5, 12][index], accepted = [8, 2, 4][index]; + return Math.abs((bottom - Number(dot.getAttribute('cy'))) / submitted - Number(bar.getAttribute('height')) / accepted); + })); + assert.ok(scales.every(offset => offset < 0.00001), 'both series must share the same count scale and baseline'); + assert.deepEqual(await graph.locator('circle title').allTextContents(), ['2024-01-01 提交:20', '2024-01-02 提交:5', '2024-01-03 提交:12']); + assert.deepEqual(await graph.locator('rect title').allTextContents(), ['2024-01-01 正确:8', '2024-01-02 正确:2', '2024-01-03 正确:4']); + assert.ok((await graph.locator('path').getAttribute('d')).startsWith('M')); + await page.addStyleTag({content: '#UserScriptProfile {display:flex} #UserScriptProfile > div {width:50%;min-width:0}'}); + await page.setViewportSize({width: 640, height: 800}); + await page.waitForFunction(() => Number(document.querySelector('#submission svg').getAttribute('viewBox').split(' ')[2]) < 400); + const boxes = await graph.locator('text').evaluateAll(nodes => nodes.map(node => ({x: node.getBBox().x, right: node.getBBox().x + node.getBBox().width}))); + assert.ok(boxes.every(box => box.x >= 0 && box.right <= 320), 'axis labels must fit the narrow viewBox'); + assert.ok(await graph.locator('rect').evaluateAll(nodes => nodes.every(node => Number(node.getAttribute('height')) > 0))); + await page.close(); + }); + } + await t.test('a single history point is visible with one centered date label', async () => { + const html = fixture.replace(/1020bad', 'text/html'); + return GetProfileSolvedProblems(doc); + }); + assert.deepEqual(ids, [1020, 1000, 1010]); + assert.equal(await page.evaluate(() => shouldNeverRun), false); + await page.close(); + }); + await t.test('leaves missing-user and error pages intact', async () => { + const page = await Page({html: '

No such User!

'}); + assert.equal(await page.locator('h1').innerText(), 'No such User!'); + assert.equal(await page.locator('#UserScriptProfile').count(), 0); + assert.deepEqual(await page.evaluate(() => apiCalls), []); + await page.close(); + }); + } finally { await browser.close(); } +}); + +test('profile startup precedes the general page handler and standard uploads reuse solved parsing', () => { + const startup = source.indexOf('InitializeUserProfile(IsAdmin);'); + const main = source.indexOf('await main();'); + assert.ok(startup >= 0 && main >= 0 && startup < main); + assert.match(source, /ACList = GetProfileSolvedProblems\(ParsedDocument\);/); +});