chore: add CI, lint config, login rate limit and split large modules
- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a4edcbfd
commit
bbbcc6a5d8
42 files changed
+678
-391
No files matched your search
@@ -1,84 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { api, uploadRaw, type Version } from "../api";
|
||||
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { useUi } from "../ui/Ui";
|
||||
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
|
||||
import {
|
||||
FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE,
|
||||
type GlossaryTerm, type Item, type LangStats, type LineState, type Overview, type Tab,
|
||||
} from "./translation-types";
|
||||
import "./translate.css";
|
||||
|
||||
type LineState = "untranslated" | "outdated" | "translated" | "fuzzy" | "machine" | "approved";
|
||||
|
||||
interface LangStats {
|
||||
code: string;
|
||||
name: string;
|
||||
font: string | null;
|
||||
lines: number;
|
||||
translated: number;
|
||||
progress: number;
|
||||
states: Record<LineState, number>;
|
||||
strings: number;
|
||||
stringsTranslated: number;
|
||||
}
|
||||
|
||||
interface Overview {
|
||||
sourceLang: string | null;
|
||||
editable: boolean;
|
||||
canApprove: boolean;
|
||||
lines: number;
|
||||
strings: number;
|
||||
modules: { path: string; lines: number }[];
|
||||
languages: LangStats[];
|
||||
knownLanguages: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
source: string;
|
||||
translation: string;
|
||||
state: LineState;
|
||||
// lines
|
||||
nodeId?: string;
|
||||
module?: string;
|
||||
label?: string;
|
||||
who?: string;
|
||||
prev?: string | null;
|
||||
next?: string | null;
|
||||
scene?: string | null;
|
||||
// strings
|
||||
usage?: string;
|
||||
}
|
||||
|
||||
interface GlossaryTerm {
|
||||
src: string;
|
||||
t: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
type Tab = "lines" | "strings" | "glossary";
|
||||
|
||||
const STATE_LABEL: Record<LineState, string> = {
|
||||
untranslated: "Не переведено",
|
||||
outdated: "Устарело",
|
||||
fuzzy: "Неточно",
|
||||
machine: "Машинный",
|
||||
translated: "Переведено",
|
||||
approved: "Одобрено",
|
||||
};
|
||||
const STATE_BADGE: Record<LineState, string> = {
|
||||
untranslated: "badge-plain",
|
||||
outdated: "badge-error",
|
||||
fuzzy: "badge-warn",
|
||||
machine: "badge-warn",
|
||||
translated: "badge-ok",
|
||||
approved: "badge-ok",
|
||||
};
|
||||
const FILTERS: [string, string][] = [
|
||||
["todo", "К работе"], ["all", "Все"], ["untranslated", "Не переведено"], ["outdated", "Устарело"],
|
||||
["fuzzy", "Неточно"], ["machine", "Машинный"], ["translated", "Переведено"], ["approved", "Одобрено"],
|
||||
];
|
||||
const PAGE = 40;
|
||||
const USAGE: Record<string, string> = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" };
|
||||
|
||||
export function TranslationEditor() {
|
||||
const { versionId } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
@@ -275,29 +207,6 @@ export function TranslationEditor() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ stats }: { stats: LangStats }) {
|
||||
const total = stats.lines || 1;
|
||||
const seg = (state: LineState, cls: string) =>
|
||||
stats.states[state] ? <div className={cls} style={{ width: `${(stats.states[state] / total) * 100}%` }}
|
||||
title={`${STATE_LABEL[state]}: ${stats.states[state]}`} /> : null;
|
||||
return (
|
||||
<div className="tr-progress">
|
||||
<div className="tr-bar">
|
||||
{seg("approved", "seg-approved")}
|
||||
{seg("translated", "seg-ok")}
|
||||
{seg("fuzzy", "seg-warn")}
|
||||
{seg("machine", "seg-warn")}
|
||||
{seg("outdated", "seg-error")}
|
||||
</div>
|
||||
<span className="muted small">
|
||||
{stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%)
|
||||
{stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""}
|
||||
{" · "}строк {stats.stringsTranslated}/{stats.strings}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntriesView({ versionId, lang, tab, ov, onStats }: {
|
||||
versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial<LangStats>) => void;
|
||||
}) {
|
||||
@@ -524,67 +433,3 @@ function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onB
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function highlight(text: string, terms: GlossaryTerm[]): ReactNode {
|
||||
if (!terms.length) return text;
|
||||
const escaped = terms.map((t) => t.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).sort((a, b) => b.length - a.length);
|
||||
const re = new RegExp(`(${escaped.join("|")})`, "gi");
|
||||
return text.split(re).map((part, i) => {
|
||||
const term = i % 2 ? terms.find((t) => t.src.toLowerCase() === part.toLowerCase()) : undefined;
|
||||
return term ? <mark key={i} title={`${term.t}${term.note ? ` — ${term.note}` : ""}`}>{part}</mark> : part;
|
||||
});
|
||||
}
|
||||
|
||||
function GlossaryEditor({ versionId, lang, editable, terms, onChange }: {
|
||||
versionId: string; lang: string; editable: boolean; terms: GlossaryTerm[]; onChange: (t: GlossaryTerm[]) => void;
|
||||
}) {
|
||||
const ui = useUi();
|
||||
const [rows, setRows] = useState<GlossaryTerm[]>(terms.length ? terms : [{ src: "", t: "", note: "" }]);
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.put(`/api/versions/${versionId}/i18n/${lang}`, { glossary: rows });
|
||||
onChange(rows.filter((r) => r.src.trim()));
|
||||
ui.toast("Глоссарий сохранён", "ok");
|
||||
} catch (e) {
|
||||
ui.toast((e as Error).message, "error");
|
||||
}
|
||||
};
|
||||
const set = (i: number, key: keyof GlossaryTerm, value: string) =>
|
||||
setRows((rs) => rs.map((r, n) => n === i ? { ...r, [key]: value } : r));
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="muted small" style={{ marginTop: 0 }}>
|
||||
Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются,
|
||||
при наведении виден принятый перевод.
|
||||
</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table tr-glossary">
|
||||
<thead><tr><th>Оригинал</th><th>Перевод</th><th>Заметка</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td><input value={r.src} readOnly={!editable} onChange={(e) => set(i, "src", e.target.value)} /></td>
|
||||
<td><input value={r.t} readOnly={!editable} onChange={(e) => set(i, "t", e.target.value)} lang={lang} /></td>
|
||||
<td><input value={r.note} readOnly={!editable} onChange={(e) => set(i, "note", e.target.value)} /></td>
|
||||
<td>
|
||||
{editable && (
|
||||
<button className="btn btn-sm btn-ghost btn-icon btn-danger" aria-label="Удалить термин"
|
||||
onClick={() => setRows((rs) => rs.filter((_, n) => n !== i))}><Icon name="x" size={14} /></button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="actions" style={{ marginTop: "var(--space-3)" }}>
|
||||
<button className="btn btn-sm" onClick={() => setRows((rs) => [...rs, { src: "", t: "", note: "" }])}>
|
||||
<Icon name="plus" size={16} />Термин
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void save()}><Icon name="check" size={16} />Сохранить</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { useUi } from "../ui/Ui";
|
||||
import { STATE_LABEL, type GlossaryTerm, type LangStats, type LineState } from "./translation-types";
|
||||
|
||||
export function ProgressBar({ stats }: { stats: LangStats }) {
|
||||
const total = stats.lines || 1;
|
||||
const seg = (state: LineState, cls: string) =>
|
||||
stats.states[state] ? <div className={cls} style={{ width: `${(stats.states[state] / total) * 100}%` }}
|
||||
title={`${STATE_LABEL[state]}: ${stats.states[state]}`} /> : null;
|
||||
return (
|
||||
<div className="tr-progress">
|
||||
<div className="tr-bar">
|
||||
{seg("approved", "seg-approved")}
|
||||
{seg("translated", "seg-ok")}
|
||||
{seg("fuzzy", "seg-warn")}
|
||||
{seg("machine", "seg-warn")}
|
||||
{seg("outdated", "seg-error")}
|
||||
</div>
|
||||
<span className="muted small">
|
||||
{stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%)
|
||||
{stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""}
|
||||
{" · "}строк {stats.stringsTranslated}/{stats.strings}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function highlight(text: string, terms: GlossaryTerm[]): ReactNode {
|
||||
if (!terms.length) return text;
|
||||
const escaped = terms.map((t) => t.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).sort((a, b) => b.length - a.length);
|
||||
const re = new RegExp(`(${escaped.join("|")})`, "gi");
|
||||
return text.split(re).map((part, i) => {
|
||||
const term = i % 2 ? terms.find((t) => t.src.toLowerCase() === part.toLowerCase()) : undefined;
|
||||
return term ? <mark key={i} title={`${term.t}${term.note ? ` — ${term.note}` : ""}`}>{part}</mark> : part;
|
||||
});
|
||||
}
|
||||
|
||||
export function GlossaryEditor({ versionId, lang, editable, terms, onChange }: {
|
||||
versionId: string; lang: string; editable: boolean; terms: GlossaryTerm[]; onChange: (t: GlossaryTerm[]) => void;
|
||||
}) {
|
||||
const ui = useUi();
|
||||
const [rows, setRows] = useState<GlossaryTerm[]>(terms.length ? terms : [{ src: "", t: "", note: "" }]);
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.put(`/api/versions/${versionId}/i18n/${lang}`, { glossary: rows });
|
||||
onChange(rows.filter((r) => r.src.trim()));
|
||||
ui.toast("Глоссарий сохранён", "ok");
|
||||
} catch (e) {
|
||||
ui.toast((e as Error).message, "error");
|
||||
}
|
||||
};
|
||||
const set = (i: number, key: keyof GlossaryTerm, value: string) =>
|
||||
setRows((rs) => rs.map((r, n) => n === i ? { ...r, [key]: value } : r));
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="muted small" style={{ marginTop: 0 }}>
|
||||
Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются,
|
||||
при наведении виден принятый перевод.
|
||||
</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table tr-glossary">
|
||||
<thead><tr><th>Оригинал</th><th>Перевод</th><th>Заметка</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td><input value={r.src} readOnly={!editable} onChange={(e) => set(i, "src", e.target.value)} /></td>
|
||||
<td><input value={r.t} readOnly={!editable} onChange={(e) => set(i, "t", e.target.value)} lang={lang} /></td>
|
||||
<td><input value={r.note} readOnly={!editable} onChange={(e) => set(i, "note", e.target.value)} /></td>
|
||||
<td>
|
||||
{editable && (
|
||||
<button className="btn btn-sm btn-ghost btn-icon btn-danger" aria-label="Удалить термин"
|
||||
onClick={() => setRows((rs) => rs.filter((_, n) => n !== i))}><Icon name="x" size={14} /></button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="actions" style={{ marginTop: "var(--space-3)" }}>
|
||||
<button className="btn btn-sm" onClick={() => setRows((rs) => [...rs, { src: "", t: "", note: "" }])}>
|
||||
<Icon name="plus" size={16} />Термин
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void save()}><Icon name="check" size={16} />Сохранить</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Shared types and labels of the translation editor.
|
||||
|
||||
export type LineState = "untranslated" | "outdated" | "translated" | "fuzzy" | "machine" | "approved";
|
||||
|
||||
export interface LangStats {
|
||||
code: string;
|
||||
name: string;
|
||||
font: string | null;
|
||||
lines: number;
|
||||
translated: number;
|
||||
progress: number;
|
||||
states: Record<LineState, number>;
|
||||
strings: number;
|
||||
stringsTranslated: number;
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
sourceLang: string | null;
|
||||
editable: boolean;
|
||||
canApprove: boolean;
|
||||
lines: number;
|
||||
strings: number;
|
||||
modules: { path: string; lines: number }[];
|
||||
languages: LangStats[];
|
||||
knownLanguages: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
key: string;
|
||||
source: string;
|
||||
translation: string;
|
||||
state: LineState;
|
||||
// lines
|
||||
nodeId?: string;
|
||||
module?: string;
|
||||
label?: string;
|
||||
who?: string;
|
||||
prev?: string | null;
|
||||
next?: string | null;
|
||||
scene?: string | null;
|
||||
// strings
|
||||
usage?: string;
|
||||
}
|
||||
|
||||
export interface GlossaryTerm {
|
||||
src: string;
|
||||
t: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export type Tab = "lines" | "strings" | "glossary";
|
||||
|
||||
export const STATE_LABEL: Record<LineState, string> = {
|
||||
untranslated: "Не переведено",
|
||||
outdated: "Устарело",
|
||||
fuzzy: "Неточно",
|
||||
machine: "Машинный",
|
||||
translated: "Переведено",
|
||||
approved: "Одобрено",
|
||||
};
|
||||
export const STATE_BADGE: Record<LineState, string> = {
|
||||
untranslated: "badge-plain",
|
||||
outdated: "badge-error",
|
||||
fuzzy: "badge-warn",
|
||||
machine: "badge-warn",
|
||||
translated: "badge-ok",
|
||||
approved: "badge-ok",
|
||||
};
|
||||
export const FILTERS: [string, string][] = [
|
||||
["todo", "К работе"], ["all", "Все"], ["untranslated", "Не переведено"], ["outdated", "Устарело"],
|
||||
["fuzzy", "Неточно"], ["machine", "Машинный"], ["translated", "Переведено"], ["approved", "Одобрено"],
|
||||
];
|
||||
export const PAGE = 40;
|
||||
export const USAGE: Record<string, string> = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" };
|
||||
@@ -0,0 +1,51 @@
|
||||
// Small shared pieces of the engine: save API types, settings and DOM/storage helpers.
|
||||
|
||||
import type { Volumes } from "./audio";
|
||||
import type { SaveData } from "./types";
|
||||
|
||||
export interface SaveMeta {
|
||||
slot: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SaveApi<T = SaveData> {
|
||||
list(): Promise<SaveMeta[]>;
|
||||
get(slot: string): Promise<T | null>;
|
||||
put(slot: string, title: string, data: T): Promise<void>;
|
||||
remove(slot: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Settings extends Volumes {
|
||||
cps: number; // characters per second, 0 = instant
|
||||
autoDelay: number; // seconds per 20 characters in auto mode
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 };
|
||||
export const SLOTS = 12;
|
||||
|
||||
export class Restart extends Error {}
|
||||
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
export function loadJSON<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? { ...fallback, ...JSON.parse(raw) } : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJSON(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,17 @@
|
||||
// PGVN interpreter + in-game UI.
|
||||
|
||||
import { AudioSystem, type Volumes } from "./audio";
|
||||
import { AudioSystem } from "./audio";
|
||||
import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python";
|
||||
import { Renderer, type ResolvedImage } from "./renderer";
|
||||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
import { naniText } from "./naninovel";
|
||||
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
|
||||
import type {
|
||||
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
|
||||
} from "./types";
|
||||
|
||||
export interface SaveMeta {
|
||||
slot: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SaveApi<T = SaveData> {
|
||||
list(): Promise<SaveMeta[]>;
|
||||
get(slot: string): Promise<T | null>;
|
||||
put(slot: string, title: string, data: T): Promise<void>;
|
||||
remove(slot: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface Settings extends Volumes {
|
||||
cps: number; // characters per second, 0 = instant
|
||||
autoDelay: number; // seconds per 20 characters in auto mode
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 };
|
||||
const SLOTS = 12;
|
||||
export type { SaveApi, SaveMeta } from "./engine-util";
|
||||
|
||||
interface LabelRef {
|
||||
label: Label;
|
||||
@@ -37,32 +19,6 @@ interface LabelRef {
|
||||
index: number;
|
||||
}
|
||||
|
||||
class Restart extends Error {}
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function loadJSON<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? { ...fallback, ...JSON.parse(raw) } : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function saveJSON(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private labels = new Map<string, LabelRef>();
|
||||
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
// Bundle React together with the code under test so that both share one React instance.
|
||||
async function load(entry) {
|
||||
const result = await build({
|
||||
stdin: { contents: entry, resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
|
||||
bundle: true, write: false, format: "esm", platform: "node", jsx: "automatic", logLevel: "silent",
|
||||
banner: { js: "import { createRequire } from \"node:module\"; const require = createRequire(process.cwd() + \"/package.json\");" },
|
||||
});
|
||||
const code = Buffer.from(result.outputFiles[0].text).toString("base64");
|
||||
return import(`data:text/javascript;base64,${code}`);
|
||||
}
|
||||
|
||||
const { renderToStaticMarkup, h, ProgressBar, highlight } = await load(`
|
||||
export { renderToStaticMarkup } from "react-dom/server";
|
||||
export { createElement as h } from "react";
|
||||
export { ProgressBar, highlight } from "./pages/TranslationParts";
|
||||
`);
|
||||
|
||||
const stats = {
|
||||
lines: 10, translated: 5, progress: 0.5, strings: 4, stringsTranslated: 2,
|
||||
states: { untranslated: 5, outdated: 1, translated: 3, fuzzy: 0, machine: 0, approved: 1 },
|
||||
};
|
||||
|
||||
test("ProgressBar renders counts and only non-empty segments", () => {
|
||||
const html = renderToStaticMarkup(h(ProgressBar, { stats }));
|
||||
assert.match(html, /5 из 10 реплик \(50%\)/);
|
||||
assert.match(html, /устарело 1/);
|
||||
assert.match(html, /seg-approved/);
|
||||
assert.doesNotMatch(html, /seg-warn/);
|
||||
});
|
||||
|
||||
test("highlight wraps glossary terms case-insensitively and escapes regex characters", () => {
|
||||
const terms = [{ src: "Eileen", t: "Эйлин", note: "" }, { src: "a+b", t: "x", note: "" }];
|
||||
const html = renderToStaticMarkup(h("p", null, highlight("eileen met a+b", terms)));
|
||||
assert.match(html, /<mark title="Эйлин">eileen<\/mark>/);
|
||||
assert.match(html, /<mark title="x">a\+b<\/mark>/);
|
||||
assert.equal(highlight("plain", []), "plain");
|
||||
});
|
||||
Reference in new issue
Block a user