249 lines
10 KiB
TypeScript
Vendored
249 lines
10 KiB
TypeScript
Vendored
import { describe, expect, it } from "vitest";
|
|
import { buildEditorFontThemeRules, buildSqlCompletionThemeRules, editorDiagnosticColors, editorThemeAppearanceFor, resolveCustomThemeBackgrounds, resolveEditorTheme, SQL_BUILTIN_HIGHLIGHT_TAG } from "@/lib/editor/editorThemes";
|
|
import { DEFAULT_APP_CUSTOM_UI_COLORS, wcagContrastRatio, type AppThemePalette } from "@/lib/app/appTheme";
|
|
import type { EditorTheme } from "@/stores/settingsStore";
|
|
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
|
import * as langSql from "@codemirror/lang-sql";
|
|
|
|
describe("resolveEditorTheme", () => {
|
|
it("maps only the follow-app editor theme to application IDE palettes", () => {
|
|
expect(resolveEditorTheme("app", "light", "xcode")).toBe("xcode");
|
|
expect(resolveEditorTheme("app", "dark", "xcode")).toBe("xcode-dark");
|
|
expect(resolveEditorTheme("app", "light", "cursor")).toBe("cursor-light");
|
|
expect(resolveEditorTheme("app", "dark", "cursor")).toBe("cursor-dark");
|
|
});
|
|
|
|
it("keeps explicit editor themes unchanged across application palettes", () => {
|
|
const explicitThemes: Array<Exclude<EditorTheme, "app">> = [
|
|
"one-dark",
|
|
"vscode-dark",
|
|
"vscode-light",
|
|
"nord",
|
|
"okaidia",
|
|
"material",
|
|
"duotone-light",
|
|
"duotone-dark",
|
|
"xcode",
|
|
"xcode-dark",
|
|
"idea-light",
|
|
"idea-dark",
|
|
"jetbrains-light",
|
|
"jetbrains-dark",
|
|
"cursor-light",
|
|
"cursor-dark",
|
|
"claude-light",
|
|
"claude-dark",
|
|
"custom",
|
|
];
|
|
const appPalettes: AppThemePalette[] = ["pearl", "vscode", "idea", "xcode", "jetbrains", "cursor", "claude"];
|
|
|
|
for (const theme of explicitThemes) {
|
|
for (const palette of appPalettes) {
|
|
expect(resolveEditorTheme(theme, "dark", palette)).toBe(theme);
|
|
expect(resolveEditorTheme(theme, "light", palette)).toBe(theme);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("custom editor theme backgrounds", () => {
|
|
it("uses an explicit dark background for the editor and gutter", () => {
|
|
expect(resolveCustomThemeBackgrounds({ background: "#10131a" }, true)).toEqual({
|
|
background: "#10131a",
|
|
gutterBackground: "#10131a",
|
|
});
|
|
});
|
|
|
|
it("uses an explicit light background for the editor and gutter", () => {
|
|
expect(resolveCustomThemeBackgrounds({ background: "#f5f3ee" }, false)).toEqual({
|
|
background: "#f5f3ee",
|
|
gutterBackground: "#f5f3ee",
|
|
});
|
|
});
|
|
|
|
it("keeps the existing custom defaults when background is omitted", () => {
|
|
expect(resolveCustomThemeBackgrounds(undefined, true)).toEqual({
|
|
background: "#1e1e2e",
|
|
gutterBackground: "#181825",
|
|
});
|
|
expect(resolveCustomThemeBackgrounds(undefined, false)).toEqual({
|
|
background: "#fafafa",
|
|
gutterBackground: "#181825",
|
|
});
|
|
});
|
|
|
|
it("routes the custom UI palette to verified dark/light editor themes by appearance", () => {
|
|
expect(resolveEditorTheme("app", "dark", "custom")).toBe("one-dark");
|
|
expect(resolveEditorTheme("app", "light", "custom")).toBe("vscode-light");
|
|
expect(resolveEditorTheme("one-dark", "light", "custom")).toBe("one-dark");
|
|
});
|
|
|
|
it("derives the follow-app editor appearance from the custom background at light/dark extremes", () => {
|
|
const darkBg = { ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#000000" };
|
|
const lightBg = { ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#ffffff" };
|
|
expect(editorThemeAppearanceFor("light", "custom", darkBg)).toBe("dark");
|
|
expect(editorThemeAppearanceFor("dark", "custom", lightBg)).toBe("light");
|
|
// Setting off or fixed palettes keep the previous mode-based behavior unchanged.
|
|
expect(editorThemeAppearanceFor("dark", "pearl")).toBe("dark");
|
|
expect(editorThemeAppearanceFor("light", "cobalt")).toBe("light");
|
|
expect(editorThemeAppearanceFor("light", "custom", undefined)).toBe("light");
|
|
});
|
|
|
|
it("keeps every major semantic token readable on the editor themes the custom palette routes to", () => {
|
|
// Token colors are the shipped values of the curated themes (one-dark from
|
|
// @codemirror/theme-one-dark, vscode-light from @uiw/codemirror-theme-vscode).
|
|
// The floors are WCAG AA large-text (3.0) for dimmed comments and a 4.0
|
|
// readable floor for body tokens; one-dark's variable sits at ~4.4 by design.
|
|
const curated: Array<{ bg: string; fg: string; comment: string; tokens: Record<string, string> }> = [
|
|
{
|
|
bg: "#282c34",
|
|
fg: "#abb2bf",
|
|
comment: "#7d8799",
|
|
tokens: {
|
|
keyword: "#c678dd",
|
|
string: "#98c379",
|
|
number: "#d19a66",
|
|
function: "#61afef",
|
|
type: "#e5c07b",
|
|
variable: "#e06c75",
|
|
operator: "#56b6c2",
|
|
},
|
|
},
|
|
{
|
|
bg: "#ffffff",
|
|
fg: "#383a42",
|
|
comment: "#008000",
|
|
tokens: {
|
|
keyword: "#383a42",
|
|
string: "#a31515",
|
|
number: "#383a42",
|
|
function: "#383a42",
|
|
type: "#383a42",
|
|
variable: "#383a42",
|
|
operator: "#383a42",
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const theme of curated) {
|
|
for (const [name, color] of Object.entries(theme.tokens)) {
|
|
expect(wcagContrastRatio(color, theme.bg), `${name} (${color}) on ${theme.bg}`).toBeGreaterThanOrEqual(4.0);
|
|
}
|
|
expect(wcagContrastRatio(theme.comment, theme.bg), `comment on ${theme.bg}`).toBeGreaterThanOrEqual(3.0);
|
|
}
|
|
});
|
|
|
|
it("uses light diagnostic markers on dark editors and dark markers on light editors", () => {
|
|
const dark = editorDiagnosticColors("dark");
|
|
const light = editorDiagnosticColors("light");
|
|
expect(wcagContrastRatio(dark.error, "#282c34")).toBeGreaterThanOrEqual(3.0);
|
|
expect(wcagContrastRatio(dark.warning, "#282c34")).toBeGreaterThanOrEqual(3.0);
|
|
expect(wcagContrastRatio(light.error, "#ffffff")).toBeGreaterThanOrEqual(3.0);
|
|
expect(wcagContrastRatio(light.warning, "#ffffff")).toBeGreaterThanOrEqual(3.0);
|
|
});
|
|
|
|
it("keeps selected text readable against the curated themes' selection backgrounds", () => {
|
|
// Selection backgrounds are part of the same verified themes; selected text
|
|
// (the theme foreground) must stay legible inside the selection highlight.
|
|
expect(wcagContrastRatio("#abb2bf", "#3E4451")).toBeGreaterThanOrEqual(3.0); // one-dark selection
|
|
expect(wcagContrastRatio("#383a42", "#add6ff")).toBeGreaterThanOrEqual(3.0); // vscode-light selection
|
|
});
|
|
});
|
|
|
|
describe("SQL completion theme", () => {
|
|
it("uses the configurable medium radius for the popup container", () => {
|
|
const rules = buildSqlCompletionThemeRules();
|
|
|
|
expect(rules[".cm-tooltip.cm-tooltip-autocomplete"]).toMatchObject({ borderRadius: "var(--dbx-radius-md)" });
|
|
expect(rules[".cm-tooltip.cm-tooltip-autocomplete > ul > li"]).toMatchObject({ borderRadius: "var(--dbx-radius-sm)" });
|
|
});
|
|
|
|
it("keeps completion labels ahead of long detail text", () => {
|
|
const rules = buildSqlCompletionThemeRules();
|
|
|
|
expect(rules[".cm-completionLabel"]).toMatchObject({ flex: "0 1 auto" });
|
|
expect(rules[".cm-completionDetail"]).toMatchObject({ flex: "1 1 0", minWidth: "0", textOverflow: "ellipsis" });
|
|
});
|
|
|
|
it("keeps the batch column insertion action fixed at the bottom of its menu", () => {
|
|
const rules = buildSqlCompletionThemeRules();
|
|
|
|
expect(rules[".cm-tooltip.cm-tooltip-autocomplete > ul > li.cm-batch-column-selection-action"]).toMatchObject({
|
|
bottom: "0",
|
|
position: "sticky",
|
|
zIndex: "1",
|
|
});
|
|
});
|
|
|
|
it("pins the completion icon glyph to its box so engines cannot clip it", () => {
|
|
const rules = buildSqlCompletionThemeRules();
|
|
|
|
// With `left`/`top` auto, the absolutely positioned pseudo element relies on
|
|
// engine-specific static positions inside the flex icon, and WebKit places it
|
|
// far enough left for `overflow: hidden` to cut off the left half of the glyph.
|
|
expect(rules[".cm-completionIcon:before"]).toMatchObject({
|
|
left: "0",
|
|
top: "0",
|
|
width: "15px",
|
|
height: "15px",
|
|
maskSize: "14px 14px",
|
|
maskPosition: "center",
|
|
});
|
|
expect(rules[".cm-completionIcon"]).toMatchObject({ width: "15px", height: "15px", overflow: "hidden" });
|
|
});
|
|
});
|
|
|
|
describe("SQL builtin highlight tag", () => {
|
|
// #7950: count/date_format/etc. were added to the dialect builtin word lists (#7222) but
|
|
// never actually rendered in a distinct color, because the theme's highlight rule matched
|
|
// standard(variableName) while @codemirror/lang-sql tags builtin words as standard(name) —
|
|
// variableName is a *child* tag of name, so a rule keyed on the child never matches the
|
|
// token's actual (parent) tag.
|
|
it("gives builtin SQL functions their own highlight class, distinct from plain identifiers and keywords", async () => {
|
|
const { highlightTree } = await import("@lezer/highlight");
|
|
const { HighlightStyle } = await import("@codemirror/language");
|
|
const { tags } = await import("@lezer/highlight");
|
|
const style = HighlightStyle.define([
|
|
{ tag: tags.keyword, color: "keyword" },
|
|
{ tag: [tags.name, tags.variableName], color: "variable" },
|
|
{ tag: SQL_BUILTIN_HIGHLIGHT_TAG, color: "builtin" },
|
|
]);
|
|
|
|
const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres", "postgres");
|
|
const doc = "select count(*) from t";
|
|
const tree = dialect.language.parser.parse(doc);
|
|
const classesByToken = new Map<string, string>();
|
|
highlightTree(tree, style, (from, to, cls) => classesByToken.set(doc.slice(from, to), cls));
|
|
|
|
expect(classesByToken.get("count")).toBeDefined();
|
|
expect(classesByToken.get("count")).not.toBe(classesByToken.get("t"));
|
|
expect(classesByToken.get("count")).not.toBe(classesByToken.get("select"));
|
|
});
|
|
});
|
|
|
|
describe("editor gutters", () => {
|
|
it("keeps single line numbers vertically centered in the base rule", () => {
|
|
const rules = buildEditorFontThemeRules();
|
|
|
|
expect(rules[".cm-lineNumbers .cm-gutterElement"]).toMatchObject({
|
|
alignItems: "center",
|
|
display: "flex",
|
|
justifyContent: "flex-end",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("editor font theme", () => {
|
|
it("disables ligatures on the editor content so repainted character runs stay stable", () => {
|
|
const rules = buildEditorFontThemeRules();
|
|
|
|
// Ligature fonts merge runs like `--`/`==` into one glyph and can race
|
|
// CodeMirror's per-keystroke span patching (dbx#7900); dropping either
|
|
// declaration would reintroduce unpainted characters in the query editor.
|
|
expect(rules[".cm-content"]).toMatchObject({
|
|
fontVariantLigatures: "none",
|
|
fontFeatureSettings: '"liga" 0, "calt" 0',
|
|
});
|
|
});
|
|
});
|