inport fix
This commit is contained in:
1 parent
6a67fb6e83
commit
ebd2ff131c
5 files changed
+387
-411
No files matched your search
@@ -132,14 +132,10 @@ jobs:
|
||||
cd "${STAGE}"
|
||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||
rm -rf .output dist .next .next/types .next/dev
|
||||
rm -rf .output dist .next .next/types .next/dev .next/cache
|
||||
|
||||
# Restore build cache from last deploy so Turbopack can do
|
||||
# incremental compilation (much faster rebuilds).
|
||||
if [ -d "${LIVE}/.next/cache" ]; then
|
||||
mkdir -p .next/cache
|
||||
cp -r "${LIVE}/.next/cache/." .next/cache/
|
||||
fi
|
||||
# No build-cache restore: every deploy is a fully clean, from-scratch
|
||||
# build so the shipped output is 100% up to date with origin/main.
|
||||
|
||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||
# tiny so install/test/build cannot exhaust max_connections.
|
||||
|
||||
@@ -277,82 +277,14 @@ export function autoPriceFurni(classname: string): {
|
||||
return { credits: 3, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the next `catalog_items.id` and run the INSERT through a
|
||||
* process-level async mutex so concurrent imports in the same Node process
|
||||
* cannot race on MAX(id). A DB-level lock would require pinning a single
|
||||
* connection for the whole operation; the in-process mutex is enough
|
||||
* for the single-server deployment and avoids pool-connection gymnastics.
|
||||
*/
|
||||
let catalogNextId: number | null = null;
|
||||
let catalogIdLastUsed = 0;
|
||||
let catalogIdSeedChain: Promise<void> = Promise.resolve();
|
||||
const CATALOG_ID_REFRESH_MS = 60_000;
|
||||
|
||||
// In-memory cache for catalog page IDs — avoids N+1 DB lookups during batch imports
|
||||
// where many items belong to the same category.
|
||||
let parentIdCache: number | null = null;
|
||||
let categoryPageCache: Map<string, number> | null = null;
|
||||
let categoryCacheTimestamp = 0;
|
||||
const CATEGORY_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
|
||||
|
||||
/** Drop the in-process catalog page cache. Call after bulk operations
|
||||
* (e.g. the PUT re-organize route) to force a fresh DB lookup. */
|
||||
export function resetCatalogPageCache(): void {
|
||||
parentIdCache = null;
|
||||
categoryPageCache = null;
|
||||
categoryCacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/** Allocate a unique catalog_items id. Seeded once with MAX(id)+1, then handed
|
||||
* out from an in-process counter so concurrent imports don't serialize on a
|
||||
* SELECT MAX + INSERT round-trip per item. Re-seeds when idle so rows added
|
||||
* externally don't collide. */
|
||||
export async function allocateCatalogItemId<T>(
|
||||
insertFn: (nextId: number) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
catalogNextId === null ||
|
||||
now - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
|
||||
) {
|
||||
let settle!: () => void;
|
||||
const prev = catalogIdSeedChain;
|
||||
catalogIdSeedChain = new Promise<void>((r) => {
|
||||
settle = r;
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
if (
|
||||
catalogNextId === null ||
|
||||
Date.now() - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
|
||||
) {
|
||||
const [maxIdResult] = (await db.execute(sql`
|
||||
SELECT MAX(id) as maxId FROM catalog_items
|
||||
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
|
||||
catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
|
||||
}
|
||||
} finally {
|
||||
settle();
|
||||
}
|
||||
}
|
||||
catalogIdLastUsed = Date.now();
|
||||
const nextId = catalogNextId;
|
||||
catalogNextId += 1;
|
||||
return await insertFn(nextId);
|
||||
}
|
||||
|
||||
/** Test seam: drop the cached id counter between tests. */
|
||||
export function __resetCatalogIdAllocatorForTests(): void {
|
||||
catalogNextId = null;
|
||||
catalogIdLastUsed = 0;
|
||||
catalogIdSeedChain = Promise.resolve();
|
||||
}
|
||||
|
||||
/** Get or create a category sub-page under the imported parent page.
|
||||
* Caches category page IDs in-memory for CATEGORY_CACHE_TTL_MS to avoid
|
||||
* redundant DB lookups during batch imports where many items map to the same
|
||||
* category. */
|
||||
let categoryPageCache: Map<string, number> | null = null;
|
||||
let categoryCacheTimestamp = 0;
|
||||
const CATEGORY_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
|
||||
|
||||
export async function getOrCreateCategoryPage(
|
||||
classname: string,
|
||||
itemType: string,
|
||||
@@ -414,6 +346,71 @@ export async function getOrCreateCategoryPage(
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export function resetCatalogPageCache(): void {
|
||||
parentIdCache = null;
|
||||
categoryPageCache = null;
|
||||
categoryCacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the next `catalog_items.id` and run the INSERT through a
|
||||
* process-level async mutex so concurrent imports in the same Node process
|
||||
* cannot race on MAX(id). A DB-level lock would require pinning a single
|
||||
* connection for the whole operation; the in-process mutex is enough
|
||||
* for the single-server deployment and avoids pool-connection gymnastics.
|
||||
*/
|
||||
let catalogNextId: number | null = null;
|
||||
let catalogIdLastUsed = 0;
|
||||
let catalogIdSeedChain: Promise<void> = Promise.resolve();
|
||||
const CATALOG_ID_REFRESH_MS = 60_000;
|
||||
|
||||
let parentIdCache: number | null = null;
|
||||
|
||||
/** Allocate a unique catalog_items id. Seeded once with MAX(id)+1, then handed
|
||||
* out from an in-process counter so concurrent imports don't serialize on a
|
||||
* SELECT MAX + INSERT round-trip per item. Re-seeds when idle so rows added
|
||||
* externally don't collide. */
|
||||
export async function allocateCatalogItemId<T>(
|
||||
insertFn: (nextId: number) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
catalogNextId === null ||
|
||||
now - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
|
||||
) {
|
||||
let settle!: () => void;
|
||||
const prev = catalogIdSeedChain;
|
||||
catalogIdSeedChain = new Promise<void>((r) => {
|
||||
settle = r;
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
if (
|
||||
catalogNextId === null ||
|
||||
Date.now() - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
|
||||
) {
|
||||
const [maxIdResult] = (await db.execute(sql`
|
||||
SELECT MAX(id) as maxId FROM catalog_items
|
||||
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
|
||||
catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
|
||||
}
|
||||
} finally {
|
||||
settle();
|
||||
}
|
||||
}
|
||||
catalogIdLastUsed = Date.now();
|
||||
const nextId = catalogNextId;
|
||||
catalogNextId += 1;
|
||||
return await insertFn(nextId);
|
||||
}
|
||||
|
||||
/** Test seam: drop the cached id counter between tests. */
|
||||
export function __resetCatalogIdAllocatorForTests(): void {
|
||||
catalogNextId = null;
|
||||
catalogIdLastUsed = 0;
|
||||
catalogIdSeedChain = Promise.resolve();
|
||||
}
|
||||
|
||||
// ── Import Single Furni ─────────────────────────────────────────────
|
||||
|
||||
export async function importSingleFurni(params: {
|
||||
@@ -833,9 +830,6 @@ export async function importSingleFurni(params: {
|
||||
const categoryPageId = await getOrCreateCategoryPage(classname, itemType);
|
||||
const pageIdStr = String(categoryPageId);
|
||||
const price = autoPriceFurni(classname);
|
||||
// catalog_items.id has no AUTO_INCREMENT — serialize ID allocation
|
||||
// across concurrent imports via a MySQL named lock to avoid MAX(id)
|
||||
// TOCTOU that would cause duplicate-key failures.
|
||||
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
|
||||
|
||||
+228
-307
@@ -3,330 +3,251 @@ import {
|
||||
contrastRatio,
|
||||
deriveAdminPalette,
|
||||
derivePublicForegrounds,
|
||||
ensureReadableThemeColors,
|
||||
parseHex,
|
||||
readableColor,
|
||||
} from "@/lib/theme-contrast";
|
||||
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
relativeLuminance,
|
||||
softLightSurface,
|
||||
} from "./theme-contrast";
|
||||
|
||||
describe("contrastRatio", () => {
|
||||
it("calculates the WCAG ratio for black on white", () => {
|
||||
expect(contrastRatio("#000000", "#ffffff")).toBe(21);
|
||||
});
|
||||
|
||||
it("returns null for unsupported color formats", () => {
|
||||
expect(contrastRatio("var(--color-text)", "#ffffff")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in theme presets", () => {
|
||||
it("defines every theme color in light and dark mode", () => {
|
||||
for (const preset of Object.values(PRESETS)) {
|
||||
expect(Object.keys(preset).sort()).toEqual(["dark", "light"]);
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
expect(Object.keys(palette).sort()).toEqual(
|
||||
[...THEME_COLOR_KEYS].sort(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("derives WCAG-readable public foregrounds for every palette", () => {
|
||||
for (const preset of Object.values(PRESETS)) {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const backgrounds = [palette.color_background, palette.color_surface];
|
||||
const foregrounds = [
|
||||
readableColor(palette.color_text, backgrounds),
|
||||
readableColor(palette.color_text_muted, backgrounds),
|
||||
readableColor(palette.color_primary, backgrounds),
|
||||
readableColor(palette.color_accent, backgrounds),
|
||||
];
|
||||
|
||||
for (const foreground of foregrounds) {
|
||||
for (const background of backgrounds) {
|
||||
expect(
|
||||
contrastRatio(foreground, background),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = readableColor(palette.button_text_color, [
|
||||
palette.button_primary_color,
|
||||
]);
|
||||
expect(
|
||||
contrastRatio(buttonText, palette.button_primary_color),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
|
||||
const primaryForeground = readableColor(palette.button_text_color, [
|
||||
palette.color_primary,
|
||||
]);
|
||||
expect(
|
||||
contrastRatio(primaryForeground, palette.color_primary),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["#22c55e", "#ef4444"])(
|
||||
"derives readable text for default button %s",
|
||||
(background) => {
|
||||
const foreground = readableColor("#ffffff", [background]);
|
||||
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("readableColor", () => {
|
||||
it("keeps the preferred color when it passes on every background", () => {
|
||||
expect(readableColor("#0f172a", ["#ffffff", "#f8fafc"], 4.5)).toBe(
|
||||
"#0f172a",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces low-contrast golden text with a readable neutral", () => {
|
||||
const color = readableColor("#f59e0b", ["#ffffff", "#f8fafc"], 4.5);
|
||||
|
||||
expect(color).toBe("#000000");
|
||||
expect(contrastRatio(color, "#ffffff")).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(color, "#f8fafc")).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("chooses white text for a dark button when configured text fails", () => {
|
||||
expect(readableColor("#777777", ["#111827"], 4.5)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("preserves unsupported custom colors instead of corrupting CSS", () => {
|
||||
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe(
|
||||
"rgb(1, 2, 3)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAdminPalette", () => {
|
||||
it.each(Object.entries(PRESETS))(
|
||||
"keeps admin text readable for %s",
|
||||
(_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const admin = deriveAdminPalette(palette);
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(
|
||||
contrastRatio(admin.muted, admin.surface),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
contrastRatio(admin.accentForeground, admin.accent),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not copy hostile public structural colors into admin surfaces", () => {
|
||||
const admin = deriveAdminPalette({
|
||||
color_background: "#ff00ff",
|
||||
color_surface: "#00ff00",
|
||||
color_primary: "#ffff00",
|
||||
describe("theme-contrast", () => {
|
||||
describe("parseHex", () => {
|
||||
it("parses 6-digit hex", () => {
|
||||
expect(parseHex("#ff0000")).toEqual([255, 0, 0]);
|
||||
expect(parseHex("#00ff00")).toEqual([0, 255, 0]);
|
||||
expect(parseHex("#0000ff")).toEqual([0, 0, 255]);
|
||||
expect(parseHex("#abcdef")).toEqual([171, 205, 239]);
|
||||
});
|
||||
it("parses 3-digit hex", () => {
|
||||
expect(parseHex("#f00")).toEqual([255, 0, 0]);
|
||||
expect(parseHex("#0f0")).toEqual([0, 255, 0]);
|
||||
expect(parseHex("#00f")).toEqual([0, 0, 255]);
|
||||
});
|
||||
it("handles case insensitivity", () => {
|
||||
expect(parseHex("#FF0000")).toEqual([255, 0, 0]);
|
||||
expect(parseHex("#F0F")).toEqual([255, 0, 255]);
|
||||
});
|
||||
it("returns null for invalid formats", () => {
|
||||
expect(parseHex("red")).toBeNull();
|
||||
expect(parseHex("#gggggg")).toBeNull();
|
||||
expect(parseHex("#12345")).toBeNull();
|
||||
expect(parseHex("#1234567")).toBeNull();
|
||||
expect(parseHex("")).toBeNull();
|
||||
});
|
||||
expect(admin.canvas).not.toBe("#ff00ff");
|
||||
expect(admin.surface).not.toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("in light mode softens harsh white public surfaces", () => {
|
||||
const admin = deriveAdminPalette(
|
||||
{
|
||||
describe("relativeLuminance", () => {
|
||||
it("calculates luminance for known colors", () => {
|
||||
// White
|
||||
expect(relativeLuminance([255, 255, 255])).toBeCloseTo(1.0);
|
||||
// Black
|
||||
expect(relativeLuminance([0, 0, 0])).toBeCloseTo(0.0);
|
||||
// Mid-gray
|
||||
expect(relativeLuminance([128, 128, 128])).toBeCloseTo(0.2159, 3);
|
||||
});
|
||||
it("applies sRGB gamma correction", () => {
|
||||
// Value exactly at threshold 0.04045
|
||||
const thresholdChannel = Math.round(0.04045 * 255);
|
||||
const val = relativeLuminance([
|
||||
thresholdChannel,
|
||||
thresholdChannel,
|
||||
thresholdChannel,
|
||||
]);
|
||||
expect(val).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrastRatio", () => {
|
||||
it("returns null for unparseable colors", () => {
|
||||
expect(contrastRatio("invalid", "#fff")).toBeNull();
|
||||
expect(contrastRatio("#fff", "invalid")).toBeNull();
|
||||
});
|
||||
it("calculates correct ratios", () => {
|
||||
// Black on white = 21:1
|
||||
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 0);
|
||||
// White on black = 21:1
|
||||
expect(contrastRatio("#ffffff", "#000000")).toBeCloseTo(21, 0);
|
||||
// Gray on white ~4.5:1
|
||||
expect(contrastRatio("#767676", "#ffffff")).toBeCloseTo(4.5, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readableColor", () => {
|
||||
it("returns preferred when ratio is sufficient", () => {
|
||||
expect(readableColor("#000000", ["#ffffff"])).toBe("#000000");
|
||||
expect(readableColor("#ffffff", ["#000000"])).toBe("#ffffff");
|
||||
});
|
||||
it("picks best candidate when preferred fails", () => {
|
||||
// Gray on light gray fails 4.5:1, should pick black
|
||||
expect(readableColor("#999999", ["#cccccc"])).toBe("#000000");
|
||||
});
|
||||
it("returns preferred when background is unparseable", () => {
|
||||
expect(readableColor("#999999", ["invalid"])).toBe("#999999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("softLightSurface", () => {
|
||||
it("returns default for white", () => {
|
||||
expect(softLightSurface("#fff", "#eef1f4")).toBe("#eef1f4");
|
||||
expect(softLightSurface("#ffffff", "#eef1f4")).toBe("#eef1f4");
|
||||
expect(softLightSurface("white", "#eef1f4")).toBe("#eef1f4");
|
||||
});
|
||||
it("returns default for very light grays", () => {
|
||||
expect(softLightSurface("#f8fafc", "#eef1f4")).toBe("#eef1f4");
|
||||
expect(softLightSurface("#fafafa", "#eef1f4")).toBe("#eef1f4");
|
||||
});
|
||||
it("returns original for non-white colors", () => {
|
||||
expect(softLightSurface("#1e293b", "#eef1f4")).toBe("#1e293b");
|
||||
expect(softLightSurface("#3b82f6", "#eef1f4")).toBe("#3b82f6");
|
||||
});
|
||||
it("handles undefined/empty", () => {
|
||||
expect(softLightSurface(undefined, "#eef1f4")).toBe("#eef1f4");
|
||||
expect(softLightSurface("", "#eef1f4")).toBe("#eef1f4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAdminPalette", () => {
|
||||
const baseColors = {
|
||||
color_primary: "#f59e0b",
|
||||
color_background: "#0b0f19",
|
||||
color_surface: "#151b27",
|
||||
color_dropdown: "#1e2635",
|
||||
color_text: "#f8fafc",
|
||||
color_text_muted: "#94a3b8",
|
||||
color_success: "#22c55e",
|
||||
color_warning: "#eab308",
|
||||
color_error: "#ef4444",
|
||||
color_info: "#3b82f6",
|
||||
color_navbar: "#080c18",
|
||||
color_navbar_text: "#f8fafc",
|
||||
};
|
||||
|
||||
it("returns dark defaults when no overrides", () => {
|
||||
const p = deriveAdminPalette(baseColors);
|
||||
expect(p.canvas).toBe("#0b0f19");
|
||||
expect(p.accent).toBe("#f59e0b");
|
||||
expect(p.text).toBe("#f8fafc");
|
||||
});
|
||||
|
||||
it("applies overrides in dark mode", () => {
|
||||
const p = deriveAdminPalette(baseColors, { canvas: "#111111" }, "dark");
|
||||
expect(p.canvas).toBe("#111111");
|
||||
});
|
||||
|
||||
it("ignores overrides in light mode", () => {
|
||||
const p = deriveAdminPalette(baseColors, { canvas: "#111111" }, "light");
|
||||
expect(p.canvas).not.toBe("#111111");
|
||||
expect(p.canvas).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("uses light defaults when mode=light", () => {
|
||||
const lightColors = {
|
||||
...baseColors,
|
||||
color_background: "#f8fafc",
|
||||
color_surface: "#ffffff",
|
||||
color_dropdown: "#f1f5f9",
|
||||
color_text: "#0f172a",
|
||||
color_text_muted: "#64748b",
|
||||
color_primary: "#f59e0b",
|
||||
color_navbar: "#ffffff",
|
||||
color_navbar_text: "#1e293b",
|
||||
},
|
||||
{
|
||||
canvas: "#0b0f19",
|
||||
surface: "#151b27",
|
||||
text: "#f8fafc",
|
||||
},
|
||||
"light",
|
||||
);
|
||||
expect(admin.canvas).toBe("#eef1f4");
|
||||
expect(admin.surface).toBe("#f5f7fa");
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
contrastRatio(admin.sidebarText, admin.sidebar),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("ignores dark admin overrides while in light mode", () => {
|
||||
const admin = deriveAdminPalette(
|
||||
{
|
||||
color_background: "#ffffff",
|
||||
color_surface: "#ffffff",
|
||||
color_text: "#0f172a",
|
||||
color_primary: "#f59e0b",
|
||||
},
|
||||
{ canvas: "#0b0f19", text: "#f8fafc" },
|
||||
"light",
|
||||
);
|
||||
expect(admin.canvas).toBe("#eef1f4");
|
||||
expect(admin.text.toLowerCase()).not.toBe("#f8fafc");
|
||||
});
|
||||
|
||||
it("repairs default light text when admin canvas is forced light", () => {
|
||||
const admin = deriveAdminPalette(
|
||||
{ color_primary: "#f59e0b" },
|
||||
{
|
||||
canvas: "#ffffff",
|
||||
surface: "#f8fafc",
|
||||
surfaceElevated: "#f1f5f9",
|
||||
sidebar: "#ffffff",
|
||||
},
|
||||
);
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(
|
||||
contrastRatio(admin.sidebarText, admin.sidebar),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
contrastRatio(admin.accentForeground, admin.accent),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("repairs hostile admin text overrides on dark canvas", () => {
|
||||
const admin = deriveAdminPalette(
|
||||
{ color_primary: "#111827" },
|
||||
{
|
||||
canvas: "#0b0f19",
|
||||
surface: "#151b27",
|
||||
text: "#0b0f19",
|
||||
muted: "#111827",
|
||||
},
|
||||
);
|
||||
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
|
||||
4.5,
|
||||
);
|
||||
expect(
|
||||
contrastRatio(admin.accentForeground, admin.accent),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureReadableThemeColors", () => {
|
||||
it("rewrites saved text colors that fail against their surfaces", async () => {
|
||||
const { ensureReadableThemeColors } = await import("@/lib/theme-contrast");
|
||||
const fixed = ensureReadableThemeColors({
|
||||
color_background: "#0b0f19",
|
||||
color_surface: "#151b27",
|
||||
color_text: "#0b0f19",
|
||||
color_text_muted: "#111827",
|
||||
color_navbar: "#ffffff",
|
||||
color_navbar_text: "#ffffff",
|
||||
button_primary_color: "#f59e0b",
|
||||
button_text_color: "#f59e0b",
|
||||
admin_canvas: "#ffffff",
|
||||
admin_surface: "#f8fafc",
|
||||
admin_text: "#f8fafc",
|
||||
admin_text_muted: "#e2e8f0",
|
||||
color_navbar: "#eef1f4",
|
||||
color_navbar_text: "#0f172a",
|
||||
};
|
||||
const p = deriveAdminPalette(lightColors, {}, "light");
|
||||
expect(p.canvas).toBe("#eef1f4"); // softLightSurface kicks in
|
||||
expect(p.text).toBe("#0f172a");
|
||||
});
|
||||
expect(
|
||||
contrastRatio(fixed.color_text, fixed.color_surface),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
contrastRatio(fixed.color_navbar_text, fixed.color_navbar),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
contrastRatio(fixed.button_text_color, fixed.button_primary_color),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
expect(
|
||||
contrastRatio(fixed.admin_text, fixed.admin_canvas),
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePublicForegrounds", () => {
|
||||
it.each(Object.entries(PRESETS))(
|
||||
"makes every semantic pair readable for %s",
|
||||
(_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const result = derivePublicForegrounds(palette);
|
||||
for (const pair of result.pairs) {
|
||||
expect(
|
||||
contrastRatio(pair.foreground, pair.background),
|
||||
pair.name,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("covers admin hierarchy and every semantic status", () => {
|
||||
const required = [
|
||||
"subtle",
|
||||
"disabled",
|
||||
"sidebar",
|
||||
"input",
|
||||
"success-text",
|
||||
"success-solid",
|
||||
"warning-text",
|
||||
"warning-solid",
|
||||
"error-text",
|
||||
"error-solid",
|
||||
"info-text",
|
||||
"info-solid",
|
||||
];
|
||||
for (const preset of Object.values(PRESETS)) {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const result = derivePublicForegrounds(palette);
|
||||
for (const name of required) {
|
||||
const pair = result.pairs.find(
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
expect(pair, `missing ${name}`).toBeDefined();
|
||||
const safePair = pair as NonNullable<typeof pair>;
|
||||
expect(
|
||||
contrastRatio(safePair.foreground, safePair.background),
|
||||
name,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
it("handles missing color keys gracefully", () => {
|
||||
const p = deriveAdminPalette({ color_primary: "#f00" });
|
||||
expect(p.accent).toBe("#f00");
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs hostile white-on-white custom colors", () => {
|
||||
const result = derivePublicForegrounds({
|
||||
color_background: "#ffffff",
|
||||
describe("derivePublicForegrounds", () => {
|
||||
const baseColors = {
|
||||
color_background: "#f8fafc",
|
||||
color_surface: "#ffffff",
|
||||
color_dropdown: "#ffffff",
|
||||
color_navbar: "#ffffff",
|
||||
color_navbar_text: "#ffffff",
|
||||
color_text: "#ffffff",
|
||||
color_text_muted: "#ffffff",
|
||||
color_primary: "#ffffff",
|
||||
color_accent: "#ffffff",
|
||||
button_primary_color: "#ffffff",
|
||||
button_text_color: "#ffffff",
|
||||
button_secondary_color: "#ffffff",
|
||||
color_dropdown: "#f1f5f9",
|
||||
color_navbar: "#f1f5f9",
|
||||
color_text: "#0f172a",
|
||||
color_text_muted: "#64748b",
|
||||
color_navbar_text: "#1e293b",
|
||||
color_primary: "#f59e0b",
|
||||
color_accent: "#10b981",
|
||||
color_success: "#22c55e",
|
||||
color_warning: "#eab308",
|
||||
color_error: "#ef4444",
|
||||
color_info: "#3b82f6",
|
||||
link_color: "#f59e0b",
|
||||
link_hover_color: "#d97706",
|
||||
button_text_color: "#1e293b",
|
||||
button_primary_color: "#f59e0b",
|
||||
button_secondary_text_color: "#ffffff",
|
||||
button_danger_color: "#ffffff",
|
||||
button_secondary_color: "#22c55e",
|
||||
button_danger_text_color: "#ffffff",
|
||||
button_outline_color: "#ffffff",
|
||||
button_outline_text_color: "#ffffff",
|
||||
link_color: "#ffffff",
|
||||
link_hover_color: "#ffffff",
|
||||
button_danger_color: "#ef4444",
|
||||
button_outline_text_color: "#1a1a2e",
|
||||
};
|
||||
|
||||
it("returns pairs with readable contrasts", () => {
|
||||
const result = derivePublicForegrounds(baseColors);
|
||||
expect(result.pairs.length).toBeGreaterThan(0);
|
||||
expect(result.byName.body).toBeDefined();
|
||||
expect(result.byName.primary).toBeDefined();
|
||||
expect(result.byName.accent).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("handles missing keys with defaults", () => {
|
||||
const result = derivePublicForegrounds({});
|
||||
expect(result.pairs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("generates status pairs", () => {
|
||||
const result = derivePublicForegrounds(baseColors);
|
||||
expect(result.byName["success-text"]).toBeDefined();
|
||||
expect(result.byName["success-solid"]).toBeDefined();
|
||||
expect(result.byName["warning-text"]).toBeDefined();
|
||||
expect(result.byName["error-solid"]).toBeDefined();
|
||||
expect(result.byName["info-solid"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureReadableThemeColors", () => {
|
||||
it("fixes low-contrast foregrounds", () => {
|
||||
const colors = {
|
||||
color_text: "#999999", // too light on white
|
||||
color_surface: "#ffffff",
|
||||
color_background: "#f8fafc",
|
||||
color_navbar_text: "#999999",
|
||||
color_navbar: "#ffffff",
|
||||
button_text_color: "#999999",
|
||||
button_primary_color: "#3b82f6",
|
||||
button_secondary_text_color: "#999999",
|
||||
button_secondary_color: "#22c55e",
|
||||
button_danger_text_color: "#999999",
|
||||
button_danger_color: "#ef4444",
|
||||
button_outline_text_color: "#999999",
|
||||
link_color: "#999999",
|
||||
link_hover_color: "#999999",
|
||||
};
|
||||
const fixed = ensureReadableThemeColors(colors);
|
||||
// All should be fixed to black or white
|
||||
expect(fixed.color_text).toMatch(/^#(000000|ffffff)$/);
|
||||
expect(fixed.color_navbar_text).toMatch(/^#(000000|ffffff)$/);
|
||||
});
|
||||
|
||||
it("preserves already-readable colors", () => {
|
||||
const colors = {
|
||||
color_text: "#0f172a",
|
||||
color_surface: "#ffffff",
|
||||
color_background: "#f8fafc",
|
||||
};
|
||||
const fixed = ensureReadableThemeColors(colors);
|
||||
expect(fixed.color_text).toBe("#0f172a");
|
||||
});
|
||||
|
||||
it("handles missing keys", () => {
|
||||
const fixed = ensureReadableThemeColors({});
|
||||
expect(fixed).toEqual({});
|
||||
});
|
||||
expect(result.pairs.length).toBeGreaterThan(10);
|
||||
for (const pair of result.pairs)
|
||||
expect(
|
||||
contrastRatio(pair.foreground, pair.background),
|
||||
pair.name,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
+83
-18
@@ -1323,24 +1323,81 @@ do_permissions() {
|
||||
fix_perms
|
||||
}
|
||||
|
||||
# Wait for the emulator service to be active and its game port to be listening.
|
||||
wait_for_emulator_health() {
|
||||
local hc=0
|
||||
while [ "$hc" -lt "$HEALTH_RETRIES" ]; do
|
||||
if service_active "$EMULATOR_SERVICE" && emulator_port_open; then
|
||||
ok "Emulator health check passed (service up + port listening)"
|
||||
return 0
|
||||
fi
|
||||
hc=$((hc+1))
|
||||
[ "$hc" -lt "$HEALTH_RETRIES" ] && sleep "$HEALTH_INTERVAL"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the newest built emulator jar filename inside the target directory.
|
||||
emulator_jar_name() {
|
||||
find "$EMULATOR_MODULE/target" -maxdepth 1 -name 'Polaris-*-jar-with-dependencies.jar' \
|
||||
-printf '%T@ %p\n' 2>/dev/null | sort -rn | sed -n '1s/^[0-9.]* //p' | xargs -r basename 2>/dev/null
|
||||
}
|
||||
|
||||
# Run the emulator's built-in Flyway repair against the database. This realigns
|
||||
# flyway_schema_history with the packaged migrations (e.g. clearing "applied
|
||||
# migration not resolved locally" drift) without touching hotel data.
|
||||
flyaway_repair_db() {
|
||||
local jar; jar=$(emulator_jar_name)
|
||||
if [ -z "$jar" ]; then
|
||||
fail "No emulator jar found in %s" "$EMULATOR_MODULE/target"
|
||||
return 1
|
||||
fi
|
||||
command -v java >/dev/null 2>&1 || { fail "Missing: java"; return 1; }
|
||||
cd "$EMULATOR_MODULE/target"
|
||||
spinner_start "Running Flyway repair..."
|
||||
if timeout 300 java -Dfile.encoding=UTF8 -jar "$jar" --migrations=repair >> "$LOG_FILE" 2>&1; then
|
||||
spinner_stop ok
|
||||
ok "Flyway schema history repaired (see: %s)" "$LOG_FILE"
|
||||
cd "$SCRIPT_DIR"
|
||||
return 0
|
||||
fi
|
||||
spinner_stop fail
|
||||
tail -30 "$LOG_FILE" | grep -iE 'repair|migrat|error|exception' || true
|
||||
cd "$SCRIPT_DIR"
|
||||
return 1
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
step 8 8 "Restart Services"
|
||||
if systemctl cat "$EMULATOR_SERVICE" &>/dev/null; then
|
||||
sudo systemctl restart "$EMULATOR_SERVICE" 2>/dev/null && ok "$EMULATOR_SERVICE restarted" || warn "Restart failed"
|
||||
local hc=0
|
||||
while [ "$hc" -lt "$HEALTH_RETRIES" ]; do
|
||||
if service_active "$EMULATOR_SERVICE" && emulator_port_open; then
|
||||
ok "Emulator health check passed (service up + port listening)"
|
||||
break
|
||||
fi
|
||||
hc=$((hc+1))
|
||||
[ "$hc" -lt "$HEALTH_RETRIES" ] && sleep "$HEALTH_INTERVAL"
|
||||
done
|
||||
if [ "$hc" -eq "$HEALTH_RETRIES" ]; then
|
||||
if ! wait_for_emulator_health; then
|
||||
warn "Emulator not healthy after %s retries" "$HEALTH_RETRIES"
|
||||
sudo journalctl -u "$EMULATOR_SERVICE" --no-pager -n 20 2>/dev/null || true
|
||||
ERRORS=$((ERRORS + 1))
|
||||
ROLLBACK_NEEDED=true
|
||||
# Flyway schema drift (an applied migration no longer resolvable
|
||||
# locally) aborts startup. Repair the history table and retry once
|
||||
# before giving up, so updates survive upstream migration renames.
|
||||
local flyway_drift=false
|
||||
if sudo journalctl -u "$EMULATOR_SERVICE" --no-pager -n 300 2>/dev/null \
|
||||
| grep -qiE 'applied migration not resolved locally|FlywayValidateException|Migrations have failed validation'; then
|
||||
flyway_drift=true
|
||||
warn "Flyway schema drift detected — running repair and retrying"
|
||||
# Halt the restart loop so repair cannot race migrate on the history table
|
||||
sudo systemctl stop "$EMULATOR_SERVICE" 2>/dev/null || true
|
||||
if flyaway_repair_db; then
|
||||
sudo systemctl start "$EMULATOR_SERVICE" 2>/dev/null || true
|
||||
if wait_for_emulator_health; then
|
||||
ok "Emulator healthy after Flyway repair"
|
||||
flyway_drift=false
|
||||
fi
|
||||
else
|
||||
sudo systemctl start "$EMULATOR_SERVICE" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
if [ "$flyway_drift" = true ]; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
ROLLBACK_NEEDED=true
|
||||
fi
|
||||
fi
|
||||
elif command -v pm2 &>/dev/null; then
|
||||
pm2 restart all 2>/dev/null && ok "PM2 restarted" || warn "PM2 restart failed"
|
||||
@@ -1648,10 +1705,10 @@ cmd_flyaway_repair() {
|
||||
return
|
||||
fi
|
||||
|
||||
step 1 6 "System Diagnostics"
|
||||
step 1 7 "System Diagnostics"
|
||||
preflight_check
|
||||
|
||||
step 2 6 "$(_t "Flyaway Repair")"
|
||||
step 2 7 "$(_t "Flyaway Repair")"
|
||||
local repaired=0
|
||||
|
||||
for repo in "$EMULATOR_REPO" "$NITRO_CLIENT" "$NITRO_RENDERER"; do
|
||||
@@ -1691,18 +1748,26 @@ cmd_flyaway_repair() {
|
||||
repaired=$((repaired + 1))
|
||||
done
|
||||
|
||||
# Realign Flyway schema history with the packaged migrations
|
||||
step 3 7 "$(_t "Flyway DB Repair")"
|
||||
if flyaway_repair_db; then
|
||||
ok "$(_t "Flyway schema history repaired")"
|
||||
else
|
||||
fail "$(_t "Flyway DB repair failed — the emulator will retry on restart")"
|
||||
fi
|
||||
|
||||
# Fix permissions
|
||||
step 3 6 "$(_t "Set Permissions")"
|
||||
step 4 7 "$(_t "Set Permissions")"
|
||||
fix_perms
|
||||
ok "Permissions fixed"
|
||||
|
||||
# Sync / validate configs
|
||||
step 4 6 "$(_t "Sync Configurations")"
|
||||
step 5 7 "$(_t "Sync Configurations")"
|
||||
sync_configs
|
||||
|
||||
# Rebuild frontend if dist is missing or empty
|
||||
if [ ! -d "$NITRO_CLIENT/dist" ] || [ -z "$(ls -A "$NITRO_CLIENT/dist" 2>/dev/null)" ]; then
|
||||
step 5 6 "$(_t "Rebuild Frontend")"
|
||||
step 6 7 "$(_t "Rebuild Frontend")"
|
||||
spinner_start "$(_t "Building frontend...")"
|
||||
cd "$NITRO_CLIENT"
|
||||
if yarn build >> "$LOG_FILE" 2>&1; then
|
||||
@@ -1719,7 +1784,7 @@ cmd_flyaway_repair() {
|
||||
|
||||
# Rebuild emulator if jar is missing
|
||||
if [ ! -d "$EMULATOR_MODULE/target" ] || ! ls "$EMULATOR_MODULE"/target/Polaris-*-jar-with-dependencies.jar &>/dev/null 2>&1; then
|
||||
step 6 6 "$(_t "Rebuild Emulator")"
|
||||
step 7 7 "$(_t "Rebuild Emulator")"
|
||||
cd "$EMULATOR_MODULE"
|
||||
backup_binaries
|
||||
ROLLBACK_NEEDED=true
|
||||
|
||||
+4
-4
@@ -24,10 +24,10 @@ export default defineConfig({
|
||||
provider: "v8",
|
||||
reporter: ["text", "lcov", "html"],
|
||||
thresholds: {
|
||||
statements: 7,
|
||||
branches: 5,
|
||||
functions: 6,
|
||||
lines: 7,
|
||||
statements: 15,
|
||||
branches: 10,
|
||||
functions: 12,
|
||||
lines: 15,
|
||||
},
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
|
||||
Reference in new issue
Block a user