From ca7880910a39f677c91336c4318d790ceec42725 Mon Sep 17 00:00:00 2001 From: Evenss <786007448@qq.com> Date: Fri, 24 Jul 2026 23:34:42 +0800 Subject: [PATCH] feat: add shareable full-line souvenir ticket --- app/MetroTyping.tsx | 727 ++++++++++++++++++++++++++++- app/globals.css | 196 ++++++-- lib/metro/souvenir-ticket.js | 44 ++ tests/completion-feedback.test.mjs | 19 +- tests/souvenir-ticket.test.mjs | 44 ++ 5 files changed, 976 insertions(+), 54 deletions(-) create mode 100644 lib/metro/souvenir-ticket.js create mode 100644 tests/souvenir-ticket.test.mjs diff --git a/app/MetroTyping.tsx b/app/MetroTyping.tsx index da9eb33..9b99d6a 100644 --- a/app/MetroTyping.tsx +++ b/app/MetroTyping.tsx @@ -10,6 +10,7 @@ import { type CSSProperties, type FormEvent, type KeyboardEvent as ReactKeyboardEvent, + type RefObject, } from "react"; import { cities, @@ -53,6 +54,13 @@ import { normalizeTypingCharacter, } from "../lib/metro/typing"; import { TYPING_ERROR_TONE } from "../lib/metro/typing-feedback"; +import { + createSouvenirTicketFileName, + createSouvenirTicketShareText, + shouldCreateSouvenirTicket, + SOUVENIR_TICKET_HEIGHT, + SOUVENIR_TICKET_WIDTH, +} from "../lib/metro/souvenir-ticket"; type Screen = "home" | "departing" | "game" | "completing" | "result"; type GameMode = "timed" | "line"; @@ -95,6 +103,17 @@ type ChimeNote = { gain: number; }; +type SouvenirTicketDetails = { + cityName: string; + cityNameEn: string; + lineName: string; + lineCode: string; + direction: string; + stationCount: number; + routeColor: string; + routeInk: string; +}; + type ResolvedRun = { id: string; nameZh: string; @@ -317,6 +336,64 @@ function getReadableTextColor(background: string) { return darkContrast >= lightContrast ? "#161619" : "#ffffff"; } +async function createSouvenirTicketPngFile( + svg: SVGSVGElement, + fileName: string, +) { + const serializedSvg = new XMLSerializer().serializeToString(svg); + const svgBlob = new Blob([serializedSvg], { + type: "image/svg+xml;charset=utf-8", + }); + const svgUrl = URL.createObjectURL(svgBlob); + + try { + const image = await new Promise((resolve, reject) => { + const nextImage = new Image(); + nextImage.onload = () => resolve(nextImage); + nextImage.onerror = () => reject( + new Error("纪念票图像载入失败"), + ); + nextImage.src = svgUrl; + }); + const canvas = document.createElement("canvas"); + canvas.width = SOUVENIR_TICKET_WIDTH; + canvas.height = SOUVENIR_TICKET_HEIGHT; + const context = canvas.getContext("2d"); + if (!context) throw new Error("当前浏览器无法生成纪念票"); + context.drawImage( + image, + 0, + 0, + SOUVENIR_TICKET_WIDTH, + SOUVENIR_TICKET_HEIGHT, + ); + const pngBlob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("纪念票 PNG 生成失败")); + }, "image/png"); + }); + + return new File([pngBlob], fileName, { + type: "image/png", + lastModified: Date.now(), + }); + } finally { + URL.revokeObjectURL(svgUrl); + } +} + +function downloadSouvenirTicket(file: File) { + const downloadUrl = URL.createObjectURL(file); + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = file.name; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 60_000); +} + function viewBoxesEqual(first: ViewBox, second: ViewBox) { return first.every((value, index) => Math.abs(value - second[index]) < 0.001); } @@ -1450,7 +1527,14 @@ function MetroTypingCity({ cityId }: { cityId: CityId }) { speedUnit={typingLanguage === "pinyin" ? "KPM" : "WPM"} accuracy={accuracy} completionReason={completionReason ?? "timed"} + gameMode={mode} + cityName={city.nameZh} + cityNameEn={city.nameEn} lineName={selectedLine?.lineName ?? ""} + lineCode={selectedLine + ? getLineBadgeLabel(selectedLine.lineId) + : ""} + directionLabel={selectedDirection?.labelZh ?? ""} stations={gameStations} isLoop={selectedRun?.kind === "loop"} routeColor={selectedLine?.color ?? "#f08c4a"} @@ -2510,6 +2594,441 @@ function Metric({ label, value, unit }: { label: string; value: number; unit: st return
{label}{value}{unit}
; } +function SouvenirTicket({ + details, + svgRef, +}: { + details: SouvenirTicketDetails; + svgRef: RefObject; +}) { + const { + cityName, + cityNameEn, + lineName, + lineCode, + direction, + stationCount, + routeColor, + routeInk, + } = details; + const lineFontSize = lineName.length > 10 + ? 40 + : lineName.length > 7 + ? 48 + : 56; + const directionFontSize = direction.length > 27 + ? 24 + : direction.length > 21 + ? 28 + : 32; + const lineCodeFontSize = lineCode.length > 3 ? 24 : 31; + + return ( +
+ + + {cityName}地铁{lineName}全线通关纪念票 + + + {`${cityName}地铁,${lineName},${direction},总站数 ${stationCount},全线通关。`} + + + + + + + + + + + + + + + + + + + + + + + METRO TYPING · FULL-LINE SOUVENIR + + + ADMIT ONE + + + + + {lineCode} + + + 线路 / LINE + + + {lineName} + + + {`${cityName}地铁 · ${cityNameEn.toUpperCase()} METRO`} + + + + 行驶方向 / DIRECTION + + + {direction} + + + + + {[91, 278, 465, 652, 838].map((x, index) => ( + + ))} + + + + 总站数 / TOTAL STATIONS + + + {String(stationCount).padStart(2, "0")} + + + STATIONS CLEARED + + + VALID FOR THIS JOURNEY + + + + + + + VALIDATED + + + + + ✓ + + + 全线通关 + + + FULL LINE COMPLETE + + + + {String(stationCount).padStart(2, "0")} + + + ALL STATIONS + + + {cityName} + + + METRO TYPING · 01 + + + + +
+ ); +} + function ResultScreen({ elapsedSeconds, completedStations, @@ -2517,7 +3036,12 @@ function ResultScreen({ speedUnit, accuracy, completionReason, + gameMode, + cityName, + cityNameEn, lineName, + lineCode, + directionLabel, stations, isLoop, routeColor, @@ -2530,7 +3054,12 @@ function ResultScreen({ speedUnit: string; accuracy: number; completionReason: CompletionReason; + gameMode: GameMode; + cityName: string; + cityNameEn: string; lineName: string; + lineCode: string; + directionLabel: string; stations: Station[]; isLoop: boolean; routeColor: string; @@ -2538,14 +3067,141 @@ function ResultScreen({ onRetry: () => void; }) { const headingRef = useRef(null); + const ticketSvgRef = useRef(null); + const [ticketFile, setTicketFile] = useState(null); + const [ticketPreparing, setTicketPreparing] = useState(false); + const [ticketStatus, setTicketStatus] = useState(""); const fullLineComplete = completionReason === "line"; + const showSouvenirTicket = shouldCreateSouvenirTicket( + completionReason, + gameMode, + ); const origin = stations[0]; const destination = stations.at(-1); + const routeDirection = isLoop + ? origin + ? `从 ${origin.nameZh} 出发` + : "" + : origin && destination + ? `${origin.nameZh} → ${destination.nameZh}` + : ""; + const direction = [directionLabel, routeDirection] + .filter(Boolean) + .join(" · "); + const ticketFileName = createSouvenirTicketFileName(cityName, lineName); + const shareText = createSouvenirTicketShareText({ + cityName, + lineName, + direction, + stationCount: stations.length, + }); + const ticketDetails: SouvenirTicketDetails = { + cityName, + cityNameEn, + lineName, + lineCode, + direction, + stationCount: stations.length, + routeColor, + routeInk: getReadableTextColor(routeColor), + }; useEffect(() => { headingRef.current?.focus({ preventScroll: true }); }, []); + useEffect(() => { + if (!showSouvenirTicket || !ticketSvgRef.current) return undefined; + let active = true; + setTicketFile(null); + setTicketPreparing(true); + setTicketStatus("正在制作可分享票图…"); + + void createSouvenirTicketPngFile( + ticketSvgRef.current, + ticketFileName, + ) + .then((file) => { + if (!active) return; + setTicketFile(file); + setTicketStatus(""); + }) + .catch(() => { + if (!active) return; + setTicketStatus("票图生成失败,请稍后重试。"); + }) + .finally(() => { + if (active) setTicketPreparing(false); + }); + + return () => { + active = false; + }; + }, [ + cityName, + cityNameEn, + direction, + lineCode, + lineName, + routeColor, + showSouvenirTicket, + stations.length, + ticketFileName, + ]); + + async function getTicketFile() { + if (ticketFile) return ticketFile; + if (!ticketSvgRef.current) throw new Error("纪念票尚未准备完成"); + setTicketPreparing(true); + try { + const file = await createSouvenirTicketPngFile( + ticketSvgRef.current, + ticketFileName, + ); + setTicketFile(file); + return file; + } finally { + setTicketPreparing(false); + } + } + + async function handleTicketDownload() { + setTicketStatus(""); + try { + const file = await getTicketFile(); + downloadSouvenirTicket(file); + setTicketStatus("纪念票 PNG 已下载。"); + } catch { + setTicketStatus("下载失败,请稍后重试。"); + } + } + + async function handleTicketShare() { + if (!ticketFile) return; + setTicketStatus(""); + const canShareTicket = + typeof navigator.share === "function" + && typeof navigator.canShare === "function" + && navigator.canShare({ files: [ticketFile] }); + + if (!canShareTicket) { + setTicketStatus("当前浏览器不支持直接分享票图,请下载 PNG 后发送。"); + return; + } + + try { + await navigator.share({ + title: `${cityName}地铁${lineName}全线通关纪念票`, + text: shareText, + files: [ticketFile], + }); + setTicketStatus("纪念票已分享。"); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + setTicketStatus("分享没有完成,请下载 PNG 后发送。"); + } + } + return (
-
- {fullLineComplete ? ( -
- -
- {isLoop ? "LOOP LINE COMPLETE" : "FULL LINE COMPLETE"} - {isLoop ? "环线全站完成" : "全线到达"} -
- {lineName} -
- ) : ( - TIME UP - )} +
+ + {fullLineComplete + ? isLoop + ? "LOOP LINE COMPLETE" + : "FULL LINE COMPLETE" + : "TIME UP"} +

- {fullLineComplete ? ( + {showSouvenirTicket ? ( +
+ +
+ + +
+

+ {ticketStatus} +

+
+ ) : null} + {fullLineComplete && !showSouvenirTicket ? (
{origin?.nameZh} @@ -2594,14 +3275,16 @@ function ResultScreen({ {stations.length}/{stations.length} 站
) : null} -
-
{completedStations}{fullLineComplete ? "全线站数" : "通过站数"}
-
{speed}平均 {speedUnit}
-
{accuracy}%正确率
-
-
- - +
+
+
{completedStations}{fullLineComplete ? "全线站数" : "通过站数"}
+
{speed}平均 {speedUnit}
+
{accuracy}%正确率
+
+
+ + +

diff --git a/app/globals.css b/app/globals.css index accf715..91d973f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -885,6 +885,7 @@ kbd { } .results { position: relative; min-height: calc(100vh - 74px); display: grid; place-items: center; overflow: hidden; padding: 60px 24px; } +.results.line-complete { padding-block: clamp(20px,4vh,40px); } .results.line-complete::before { position: absolute; width: min(920px,120vw); @@ -899,42 +900,68 @@ kbd { animation: result-rings-in .7s ease-out both; } .result-card { position: relative; width: min(680px,100%); padding: clamp(30px,6vw,62px); border: 1px solid var(--line); border-radius: 28px; background: var(--card); box-shadow: var(--shadow); text-align: center; } -.result-card.is-line-complete { padding-block: clamp(30px,4vw,42px); border-top: 6px solid var(--result-route); animation: result-card-in .45s cubic-bezier(.2,.8,.2,1) both; } +.result-card.is-line-complete { width: min(820px,100%); padding: clamp(28px,4vw,42px); border-top: 6px solid var(--result-route); animation: result-card-in .45s cubic-bezier(.2,.8,.2,1) both; } .result-kicker { color: color-mix(in srgb,var(--result-route) 58%,var(--ink)); font: 700 11px "DM Mono", monospace; letter-spacing: .18em; } .result-card h2 { margin: 15px 0 8px; outline: 0; font-size: clamp(32px,4.5vw,48px); text-wrap: balance; } .result-card > p { color: var(--muted); } -.completion-ticket { - width: min(500px,100%); - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 14px; - margin: 0 auto 24px; - padding: 14px 15px; - border: 1px dashed color-mix(in srgb,var(--result-route) 70%,var(--line)); - border-radius: 18px; - background: color-mix(in srgb,var(--result-route) 9%,var(--card)); - text-align: left; +.souvenir-ticket { margin: 28px auto 0; } +.souvenir-ticket-figure { + width: 100%; + margin: 0; + overflow: hidden; + border-radius: 22px; + background: #e9dfcc; + box-shadow: + 0 22px 46px color-mix(in srgb,var(--result-route) 16%,transparent), + 0 8px 20px rgba(42,40,36,.15); + transform: rotate(-.2deg); animation: completion-ticket-in .42s .18s cubic-bezier(.2,.8,.2,1) both; } -.completion-ticket-check { - width: 44px; - height: 44px; - display: grid; - place-items: center; - border-radius: 50%; +.souvenir-ticket-art { width: 100%; height: auto; display: block; } +.souvenir-ticket-mobile-details { display: none; } +.souvenir-ticket-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 15px; +} +.souvenir-ticket-actions button { + min-height: 46px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 0 17px; + border-radius: 13px; + cursor: pointer; + font-size: 12px; + font-weight: 900; + transition: transform .18s ease, opacity .18s ease, border-color .18s ease; +} +.souvenir-ticket-actions button:hover:not(:disabled) { transform: translateY(-1px); } +.souvenir-ticket-actions button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } +.souvenir-ticket-actions button:disabled { cursor: wait; opacity: .48; } +.souvenir-ticket-actions button > span { + font: 700 18px "DM Mono", monospace; + line-height: 1; +} +.souvenir-share-button { color: var(--result-route-ink,white); + border: 1px solid var(--result-route); background: var(--result-route); - font-size: 23px; - font-weight: 900; } -.completion-ticket div { min-width: 0; } -.completion-ticket div span, -.completion-ticket div strong { display: block; } -.completion-ticket div span { color: color-mix(in srgb,var(--result-route) 58%,var(--ink)); font: 700 9px "DM Mono", monospace; letter-spacing: .15em; } -.completion-ticket div strong { margin-top: 2px; font-size: 17px; } -.completion-ticket > b { padding: 7px 10px; border-radius: 999px; color: var(--result-route-ink,white); background: var(--result-route); font-size: 11px; white-space: nowrap; } -.result-card.is-line-complete .completion-ticket { margin-bottom: 18px; } +.souvenir-download-button { + color: var(--ink); + border: 1px solid var(--line); + background: var(--card); +} +.souvenir-ticket-status { + min-height: 20px; + margin: 8px 2px 0; + color: var(--muted); + font-size: 11px; + text-align: right; +} .result-route-summary { display: grid; grid-template-columns: minmax(72px,auto) minmax(80px,1fr) minmax(72px,auto); @@ -953,7 +980,7 @@ kbd { .result-route-track i:last-child { left: 100%; } .result-route-summary.is-loop .result-route-track { border-radius: 999px; } .result-card.is-line-complete .result-route-summary { margin-top: 20px; } -.result-card.is-line-complete .result-metrics { margin-block: 24px; } +.result-card.is-line-complete .result-metrics { margin-block: 26px 24px; } .result-card.is-line-complete .result-metrics div { padding-block: 20px; } @keyframes result-card-in { from { opacity: 0; transform: translateY(12px); } @@ -970,11 +997,75 @@ kbd { .result-metrics strong, .result-metrics span { display: block; } .result-metrics strong { font: 500 36px "DM Mono", monospace; } .result-metrics span { color: var(--muted); font-size: 11px; } +.result-finish-panel { display: contents; } .result-actions { display: flex; justify-content: center; gap: 12px; } +.result-actions button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } .secondary-button { min-height: 58px; padding: 0 24px; border: 1px solid var(--line); background: var(--card); } .dark .result-actions .start-button { color: #171719; background: #f2efe7; } .dark .result-actions .start-button b { color: #f2efe7; background: #171719; } +@media (min-width: 821px) { + .results.line-complete { padding-block: clamp(14px,2.2vh,24px); } + .result-card.is-line-complete.has-souvenir-ticket { + width: min(960px,100%); + display: grid; + grid-template-columns: minmax(0,580px) minmax(210px,1fr); + align-items: stretch; + column-gap: 22px; + padding: clamp(22px,2.4vw,28px); + } + .result-card.is-line-complete.has-souvenir-ticket > .result-kicker, + .result-card.is-line-complete.has-souvenir-ticket > h2, + .result-card.is-line-complete.has-souvenir-ticket > p { + grid-column: 1 / -1; + } + .result-card.is-line-complete.has-souvenir-ticket > h2 { + margin: 10px 0 4px; + font-size: clamp(34px,3.2vw,44px); + line-height: 1.08; + } + .result-card.is-line-complete.has-souvenir-ticket > p { + margin: 0; + font-size: 13px; + } + .result-card.is-line-complete.has-souvenir-ticket .souvenir-ticket { + width: min(100%,clamp(500px,70vh,560px)); + min-width: 0; + margin: 18px auto 0; + } + .result-card.is-line-complete.has-souvenir-ticket .result-finish-panel { + min-width: 0; + display: flex; + flex-direction: column; + margin-top: 18px; + } + .result-card.is-line-complete.has-souvenir-ticket .result-metrics { + grid-template-columns: 1fr; + margin: 0 0 14px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 16px; + } + .result-card.is-line-complete.has-souvenir-ticket .result-metrics div { + padding-block: 13px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .result-card.is-line-complete.has-souvenir-ticket .result-metrics div:last-child { border-bottom: 0; } + .result-card.is-line-complete.has-souvenir-ticket .result-metrics strong { + font-size: 30px; + line-height: 1.1; + } + .result-card.is-line-complete.has-souvenir-ticket .result-actions { + flex-direction: column; + margin-top: auto; + } + .result-card.is-line-complete.has-souvenir-ticket .result-actions button { + width: 100%; + min-height: 52px; + } +} + .loading, .data-error { min-height: calc(100vh - 74px); display: grid; place-content: center; justify-items: center; gap: 14px; color: var(--muted); font-size: 13px; text-align: center; } .loading span { width: 34px; height: 34px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } @@ -1064,8 +1155,50 @@ footer a:hover { color: var(--accent-dark); text-decoration-color: currentColor; .completion-card h2 { font-size: 32px; } .completion-check { width: 46px; height: 46px; font-size: 24px; } .result-metrics strong { font-size: 27px; } - .completion-ticket { grid-template-columns: auto 1fr; gap: 10px; padding: 12px; } - .completion-ticket > b { grid-column: 1 / -1; justify-self: center; } + .result-card.is-line-complete { padding-inline: 17px; } + .souvenir-ticket { margin-top: 22px; } + .souvenir-ticket-figure { border-radius: 15px; transform: none; } + .souvenir-ticket-mobile-details { + display: grid; + grid-template-columns: repeat(2,minmax(0,1fr)); + gap: 13px 16px; + margin: 0; + padding: 15px 16px 17px; + color: #25231f; + border-top: 1px dashed #9b8f7c; + background: #fffaf0; + text-align: left; + } + .souvenir-ticket-mobile-details div { min-width: 0; } + .souvenir-ticket-mobile-details dt { + margin-bottom: 3px; + color: #776e61; + font: 700 9px "DM Mono", monospace; + letter-spacing: .12em; + } + .souvenir-ticket-mobile-details dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 13px; + font-weight: 800; + line-height: 1.45; + } + .souvenir-ticket-mobile-direction { grid-column: 1 / -1; } + .souvenir-ticket-mobile-pass dd { color: var(--result-route); } + .souvenir-ticket-mobile-pass dd span { + display: inline-grid; + width: 19px; + height: 19px; + margin-right: 4px; + place-items: center; + border-radius: 50%; + color: var(--result-route-ink,white); + background: var(--result-route); + font-size: 11px; + } + .souvenir-ticket-actions { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; } + .souvenir-ticket-actions button { min-width: 0; padding-inline: 9px; } + .souvenir-ticket-status { text-align: center; } .result-route-summary { grid-template-columns: minmax(54px,auto) minmax(52px,1fr) minmax(54px,auto); gap: 8px; font-size: 11px; } .result-actions { flex-direction: column; } footer { flex-direction: column; align-items: flex-start; padding: 22px 20px 26px; } @@ -1149,6 +1282,7 @@ footer a:hover { color: var(--accent-dark); text-decoration-color: currentColor; .home-copy h1 { font-size: 42px; } .home-copy .lede { font-size: 12.5px; } .result-card { padding-inline: 22px; } + .result-card.is-line-complete { padding-inline: 13px; } .result-metrics strong { font-size: 24px; } } diff --git a/lib/metro/souvenir-ticket.js b/lib/metro/souvenir-ticket.js new file mode 100644 index 0000000..80e1d72 --- /dev/null +++ b/lib/metro/souvenir-ticket.js @@ -0,0 +1,44 @@ +export const SOUVENIR_TICKET_WIDTH = 1200; +export const SOUVENIR_TICKET_HEIGHT = 675; + +/** + * A timed run can also reach the final station, so the completion reason alone + * is not enough to decide whether this run earns a souvenir ticket. + * + * @param {"timed" | "line" | null} completionReason + * @param {"timed" | "line"} gameMode + */ +export function shouldCreateSouvenirTicket(completionReason, gameMode) { + return completionReason === "line" && gameMode === "line"; +} + +function sanitizeFileNamePart(value) { + return value + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * @param {string} cityName + * @param {string} lineName + */ +export function createSouvenirTicketFileName(cityName, lineName) { + const city = sanitizeFileNamePart(cityName) || "城市"; + const line = sanitizeFileNamePart(lineName) || "地铁线路"; + return `地铁打字-${city}-${line}-全线纪念票.png`; +} + +/** + * @param {{ + * cityName: string; + * lineName: string; + * direction: string; + * stationCount: number; + * }} ticket + */ +export function createSouvenirTicketShareText(ticket) { + return `我完成了${ticket.cityName}地铁${ticket.lineName}(${ticket.direction})的全部 ${ticket.stationCount} 站,获得全线通关纪念票。`; +} diff --git a/tests/completion-feedback.test.mjs b/tests/completion-feedback.test.mjs index 0d244f3..99ec6a8 100644 --- a/tests/completion-feedback.test.mjs +++ b/tests/completion-feedback.test.mjs @@ -83,12 +83,29 @@ test("renders a route-specific completion ceremony and persistent result", async assert.match(client, /playChime\(COMPLETION_CHIME_NOTES, false\)/); assert.match(client, /"--active-route-ink": getReadableTextColor\(line\.color\)/); assert.match(client, /"--result-route-ink": getReadableTextColor\(routeColor\)/); + assert.match(client, /gameMode=\{mode\}/); + assert.match(client, /cityName=\{city\.nameZh\}/); + assert.match(client, /directionLabel=\{selectedDirection\?\.labelZh \?\? ""\}/); + assert.match(client, /shouldCreateSouvenirTicket\(\s*completionReason,\s*gameMode/); + assert.match(client, /className="souvenir-ticket"/); + assert.match(client, /showSouvenirTicket \? " has-souvenir-ticket" : ""/); + assert.match(client, /className="result-finish-panel"[\s\S]{0,500}className="result-metrics"[\s\S]{0,500}className="result-actions"/); + assert.match(client, /行驶方向 \/ DIRECTION/); + assert.match(client, /总站数 \/ TOTAL STATIONS/); + assert.match(client, /全线通关/); + assert.match(client, /navigator\.canShare\(\{ files: \[ticketFile\] \}\)/); + assert.match(client, /navigator\.share\(/); + assert.match(client, /downloadSouvenirTicket\(file\)/); + assert.doesNotMatch(client, / { + assert.equal(shouldCreateSouvenirTicket("line", "line"), true); + assert.equal(shouldCreateSouvenirTicket("timed", "line"), false); + assert.equal(shouldCreateSouvenirTicket("timed", "timed"), false); + assert.equal( + shouldCreateSouvenirTicket("line", "timed"), + false, + "a timed run that reaches the final station must not create a ticket", + ); +}); + +test("creates a portable PNG filename for lines with aliases", () => { + assert.equal( + createSouvenirTicketFileName("深圳", "1号线/罗宝线"), + "地铁打字-深圳-1号线-罗宝线-全线纪念票.png", + ); + assert.doesNotMatch( + createSouvenirTicketFileName("重庆", '环线:*?"'), + /[\\/:*?"<>|]/, + ); +}); + +test("share copy carries every required ticket fact", () => { + const text = createSouvenirTicketShareText({ + cityName: "北京", + lineName: "2号线", + direction: "经鼓楼大街 · 从积水潭出发", + stationCount: 18, + }); + + assert.match(text, /北京/); + assert.match(text, /2号线/); + assert.match(text, /经鼓楼大街/); + assert.match(text, /18 站/); + assert.match(text, /全线通关纪念票/); +});