mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 18:01:59 +03:00
Compare commits
18 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66abc595b | ||
|
|
25dcb3b427 | ||
|
|
3b639e8bf0 | ||
|
|
493cd5efc9 | ||
|
|
0eab6d80af | ||
|
|
8794a81e90 | ||
|
|
d89ed69c63 | ||
|
|
f84de44bc2 | ||
|
|
c7d3cc5493 | ||
|
|
b559c94ffa | ||
|
|
445f965c8e | ||
|
|
b215efdda0 | ||
|
|
e4c1dccc2a | ||
|
|
418f6a8482 | ||
|
|
21161e4afe | ||
|
|
31c8eb86d5 | ||
|
|
7bbe666840 | ||
|
|
ba4ebe2d5c |
23 changed files with 669 additions and 71 deletions
|
|
@ -30,7 +30,7 @@
|
|||
</div>
|
||||
<div id="statusbar">
|
||||
<span id="rates-info" title=""></span>
|
||||
<span id="market-info" title="" style="display:none"></span>
|
||||
<span id="market-info" title="" class="d-none"></span>
|
||||
<span class="spacer"></span>
|
||||
<div id="export-wrap">
|
||||
<button id="export-btn" title="Export" aria-label="Export">⤓</button>
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "summarum",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "summarum",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "summarum",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"description": "Summarum — natural language calculator notebook for Windows",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -3724,7 +3724,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||
|
||||
[[package]]
|
||||
name = "summarum"
|
||||
version = "1.2.5"
|
||||
version = "1.2.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "summarum"
|
||||
version = "1.2.5"
|
||||
version = "1.2.6"
|
||||
description = "Summarum — natural language calculator notebook for Windows"
|
||||
edition = "2021"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Summarum",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"identifier": "app.summarum.calc",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ describe("numeral systems", () => {
|
|||
describe("variables and document context", () => {
|
||||
it("sequence error recovery", () => {
|
||||
eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => {
|
||||
throw new Error("test error");
|
||||
throw new EvalError("test error");
|
||||
});
|
||||
expect(calc("throw_err(1) 2 hours")).toBe("2 h");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "../logger";
|
||||
// Public facade: evaluates whole documents line by line and exposes the
|
||||
// extension API (numi.addUnit / addFunction / setVariable).
|
||||
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
|
||||
|
|
@ -137,7 +138,6 @@ export class SumEngine {
|
|||
historicalRates: this.historicalRates,
|
||||
resolveXRef,
|
||||
};
|
||||
// a line without a result beats a dead sheet.
|
||||
value = evaluate(parsed.expr, ctx);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -146,10 +146,11 @@ export class SumEngine {
|
|||
} else if (e instanceof EvalError) {
|
||||
// Expected evaluation failure; remain silent
|
||||
} else {
|
||||
// Surface unexpected bugs/BigInt errors/RangeErrors and log them
|
||||
// Capture unexpected execution anomalies like BigInt or Range errors
|
||||
lineError = e instanceof Error ? e.message : String(e);
|
||||
console.warn("evaluate failed:", e);
|
||||
logger.warn("evaluate failed:", e);
|
||||
}
|
||||
// a line without a result beats a dead sheet.
|
||||
value = null;
|
||||
}
|
||||
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
|
||||
|
|
|
|||
41
src/engine/types.test.ts
Normal file
41
src/engine/types.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { qty, pct, Decimal } from "./types";
|
||||
|
||||
describe("types", () => {
|
||||
describe("qty", () => {
|
||||
it("creates a quantity with default arguments", () => {
|
||||
const result = qty(42);
|
||||
expect(result.kind).toBe("quantity");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toNumber()).toBe(42);
|
||||
expect(result.unit).toBeNull();
|
||||
expect(result.repr).toBe("decimal");
|
||||
});
|
||||
|
||||
it("creates a quantity with all arguments provided", () => {
|
||||
const mockUnit = { id: "meter", dimension: "length", ratio: new Decimal(1), format: "m" } as any;
|
||||
const result = qty("3.14", mockUnit, "hex");
|
||||
expect(result.kind).toBe("quantity");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toString()).toBe("3.14");
|
||||
expect(result.unit).toBe(mockUnit);
|
||||
expect(result.repr).toBe("hex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pct", () => {
|
||||
it("creates a percent value from number", () => {
|
||||
const result = pct(25);
|
||||
expect(result.kind).toBe("percent");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toNumber()).toBe(25);
|
||||
});
|
||||
|
||||
it("creates a percent value from string", () => {
|
||||
const result = pct("12.5");
|
||||
expect(result.kind).toBe("percent");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toString()).toBe("12.5");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "./logger";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { makeApi, runExtensions } from "./extensions";
|
||||
import { SumEngine } from "./engine";
|
||||
|
|
@ -41,8 +42,8 @@ describe("extensions", () => {
|
|||
let consoleWarnSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -185,6 +186,8 @@ describe("extensions", () => {
|
|||
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
|
||||
}));
|
||||
const { runExtensions: run } = await import("./extensions");
|
||||
const { logger } = await import("./logger");
|
||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
|
||||
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "./logger";
|
||||
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
||||
import {
|
||||
|
|
@ -75,7 +76,7 @@ export async function runExtensions(
|
|||
try {
|
||||
await load(engine, scripts);
|
||||
} catch (e) {
|
||||
console.warn("extensions disabled: QuickJS sandbox unavailable:", e);
|
||||
logger.warn("extensions disabled: QuickJS sandbox unavailable:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +201,7 @@ function evaluateScripts(
|
|||
}
|
||||
res.value.dispose();
|
||||
} catch (e) {
|
||||
console.error(`extension ${s.name} failed:`, e);
|
||||
logger.error(`extension ${s.name} failed:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
src/logger.ts
Normal file
5
src/logger.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const logger = {
|
||||
info: (...args: any[]) => console.info(...args),
|
||||
warn: (...args: any[]) => console.warn(...args),
|
||||
error: (...args: any[]) => console.error(...args),
|
||||
};
|
||||
37
src/main.ts
37
src/main.ts
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "./logger";
|
||||
/** App bootstrap: engine + editor + documents + settings + Tauri glue. */
|
||||
import "./ui/app.css";
|
||||
import { SumEngine } from "./engine";
|
||||
|
|
@ -112,13 +113,6 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
|
|||
const startY = e.clientY;
|
||||
let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!dragging) {
|
||||
if (Math.abs(ev.clientY - startY) < 4) return;
|
||||
dragging = true;
|
||||
suppressClick = true;
|
||||
el.classList.add("dragging");
|
||||
document.body.style.cursor = "grabbing";
|
||||
}
|
||||
if (!cachedItems) {
|
||||
const domItems = [...list.querySelectorAll<HTMLElement>(".doc-item")];
|
||||
cachedItems = domItems.map((item) => {
|
||||
|
|
@ -126,6 +120,13 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
|
|||
return { el: item, top: rect.top, bottom: rect.bottom };
|
||||
});
|
||||
}
|
||||
if (!dragging) {
|
||||
if (Math.abs(ev.clientY - startY) < 4) return;
|
||||
dragging = true;
|
||||
suppressClick = true;
|
||||
el.classList.add("dragging");
|
||||
document.body.style.cursor = "grabbing";
|
||||
}
|
||||
let over: HTMLElement | undefined;
|
||||
for (const item of cachedItems) {
|
||||
item.el.classList.remove("drag-over");
|
||||
|
|
@ -436,7 +437,7 @@ function bindDataDirSettings(): void {
|
|||
await flushAppData();
|
||||
await migrateDataDir(settings.dataDir, picked, strategy);
|
||||
} catch (e) {
|
||||
console.warn("migrate failed", e);
|
||||
logger.warn("migrate failed", e);
|
||||
toast(t("folderError"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -537,7 +538,7 @@ async function registerHotkey(old: string | null, combo: string): Promise<boolea
|
|||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("hotkey registration failed", e);
|
||||
logger.warn("hotkey registration failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -549,14 +550,14 @@ async function applyAutostart(enabled: boolean): Promise<void> {
|
|||
if (enabled) await auto.enable();
|
||||
else await auto.disable();
|
||||
} catch (e) {
|
||||
console.warn("autostart failed", e);
|
||||
logger.warn("autostart failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => console.warn("always-on-top failed", e));
|
||||
await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => logger.warn("always-on-top failed", e));
|
||||
}
|
||||
|
||||
async function refreshRates(force = false): Promise<void> {
|
||||
|
|
@ -596,7 +597,7 @@ function applyAllRates(): void {
|
|||
async function refreshMarket(force = false): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const info = $("#market-info") as HTMLElement;
|
||||
info.style.display = "";
|
||||
info.classList.remove("d-none");
|
||||
info.classList.add("spin");
|
||||
info.textContent = t("market");
|
||||
const prices = await fetchMarketData(MARKET_SYMBOLS);
|
||||
|
|
@ -636,8 +637,8 @@ async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
|||
|
||||
function renderMarketInfo(): void {
|
||||
const el = $("#market-info") as HTMLElement;
|
||||
if (marketFetchedAt === 0) { el.style.display = "none"; return; }
|
||||
el.style.display = "";
|
||||
if (marketFetchedAt === 0) { el.classList.add("d-none"); return; }
|
||||
el.classList.remove("d-none");
|
||||
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
|
||||
el.title = t("refreshRates");
|
||||
}
|
||||
|
|
@ -655,15 +656,15 @@ function renderTotal(): void {
|
|||
}
|
||||
const total = engine.totalOf(results);
|
||||
if (total) {
|
||||
line.innerHTML = "";
|
||||
line.replaceChildren();
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "total-label";
|
||||
lbl.textContent = label;
|
||||
line.append(lbl, document.createTextNode(total));
|
||||
line.dataset.value = total;
|
||||
line.style.display = "";
|
||||
line.classList.remove("d-none");
|
||||
} else {
|
||||
line.style.display = "none";
|
||||
line.classList.add("d-none");
|
||||
delete line.dataset.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -872,7 +873,7 @@ async function bindWindowControls(): Promise<void> {
|
|||
$("#win-min").addEventListener("click", () => void win.minimize());
|
||||
$("#win-close").addEventListener("click", () => void win.hide());
|
||||
} else {
|
||||
$("#win-controls").style.display = "none";
|
||||
$("#win-controls").classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "./logger";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
|
@ -14,7 +15,7 @@ describe('storage', () => {
|
|||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('console', { warn: vi.fn() });
|
||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ describe('storage', () => {
|
|||
it('falls back to defaultSettingsData if localStorage throws', async () => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
||||
const settings = await loadSettings();
|
||||
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(settings).toEqual(defaultSettingsData);
|
||||
});
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ describe('storage', () => {
|
|||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
const settings = await loadSettings();
|
||||
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(settings).toEqual(defaultSettingsData);
|
||||
});
|
||||
|
||||
|
|
@ -75,7 +76,7 @@ describe('storage', () => {
|
|||
it('falls back to defaultSettingsData if JSON is invalid', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||
const settings = await loadSettings();
|
||||
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(settings).toEqual(defaultSettingsData);
|
||||
});
|
||||
});
|
||||
|
|
@ -124,7 +125,7 @@ describe('storage', () => {
|
|||
it('returns null if localStorage throws', async () => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
||||
const data = await loadAppData('');
|
||||
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -132,7 +133,7 @@ describe('storage', () => {
|
|||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
const data = await loadAppData('');
|
||||
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -145,7 +146,7 @@ describe('storage', () => {
|
|||
it('returns null if JSON is invalid', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||
const data = await loadAppData('');
|
||||
expect(console.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -223,7 +224,7 @@ describe('storage', () => {
|
|||
|
||||
await flushAppData();
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||
});
|
||||
|
||||
it('handles invoke throw gracefully in flushAppData', async () => {
|
||||
|
|
@ -234,7 +235,7 @@ describe('storage', () => {
|
|||
|
||||
await flushAppData();
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -255,7 +256,7 @@ describe('storage', () => {
|
|||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
await runBackups('dir', 10);
|
||||
expect(console.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -281,7 +282,7 @@ describe('storage', () => {
|
|||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
await backupDeletedSheet('dir', 'title', 'contents');
|
||||
expect(console.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -369,7 +370,7 @@ describe('storage', () => {
|
|||
it('returns null if fetch fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
const result = await fetchRates();
|
||||
expect(console.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -404,7 +405,7 @@ describe('storage', () => {
|
|||
it('returns null if fetch fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
const result = await fetchHistoricalRates('2024-01-01');
|
||||
expect(console.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "./logger";
|
||||
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
||||
|
||||
export interface DocMeta {
|
||||
|
|
@ -79,7 +80,7 @@ export async function loadSettings(): Promise<SettingsData> {
|
|||
: localStorage.getItem("summarum.settings");
|
||||
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
||||
} catch (e) {
|
||||
console.warn("loadSettings failed", e);
|
||||
logger.warn("loadSettings failed", e);
|
||||
}
|
||||
return { ...defaultSettingsData };
|
||||
}
|
||||
|
|
@ -99,7 +100,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
|
|||
: localStorage.getItem("summarum.documents");
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.warn("loadAppData failed", e);
|
||||
logger.warn("loadAppData failed", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -132,7 +133,7 @@ export async function flushAppData(): Promise<void> {
|
|||
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
|
||||
else localStorage.setItem("summarum.documents", raw);
|
||||
} catch (e) {
|
||||
console.warn("saveAppData failed", e);
|
||||
logger.warn("saveAppData failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ export async function runBackups(dataDir: string, retentionDays: number): Promis
|
|||
try {
|
||||
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
||||
} catch (e) {
|
||||
console.warn("runBackups failed", e);
|
||||
logger.warn("runBackups failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +160,7 @@ export async function backupDeletedSheet(dataDir: string, title: string, content
|
|||
try {
|
||||
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
||||
} catch (e) {
|
||||
console.warn("backupDeletedSheet failed", e);
|
||||
logger.warn("backupDeletedSheet failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +206,7 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
|
|||
return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("fetchRates failed", e);
|
||||
logger.warn("fetchRates failed", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -227,7 +228,7 @@ export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record
|
|||
);
|
||||
return results;
|
||||
} catch (e) {
|
||||
console.warn("fetchHistoricalRatesBatch failed", e);
|
||||
logger.warn("fetchHistoricalRatesBatch failed", e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -246,7 +247,7 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
|
|||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.warn("fetchHistoricalRates failed", date, e);
|
||||
logger.warn("fetchHistoricalRates failed", date, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -265,7 +266,7 @@ export async function fetchMarketData(symbols: string[]): Promise<Record<string,
|
|||
try {
|
||||
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
||||
} catch (e) {
|
||||
console.warn("fetchMarketData failed", e);
|
||||
logger.warn("fetchMarketData failed", e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -277,7 +278,7 @@ export async function loadExtensionScripts(): Promise<{ name: string; code: stri
|
|||
try {
|
||||
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
||||
} catch (e) {
|
||||
console.warn("loadExtensions failed", e);
|
||||
logger.warn("loadExtensions failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +315,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
|
|||
const content = await invoke<string>("read_text_file", { path });
|
||||
cb(content);
|
||||
} catch (e) {
|
||||
console.warn("file drop rejected", path, e);
|
||||
logger.warn("file drop rejected", path, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -654,3 +654,4 @@ body {
|
|||
}
|
||||
.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); }
|
||||
.cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
|
||||
.d-none { display: none !important; }
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ const highlightField = StateField.define<DecorationSet>({
|
|||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
const varHighlightReCache = new Map<string, { assignment: RegExp; highlight: RegExp }>();
|
||||
|
||||
/** Highlights every occurrence of the variable under the cursor. */
|
||||
function buildVarHighlight(state: EditorState): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
|
|
@ -90,11 +92,21 @@ function buildVarHighlight(state: EditorState): DecorationSet {
|
|||
if (a === b) return builder.finish();
|
||||
const word = text.slice(a, b);
|
||||
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
|
||||
|
||||
let cachedRe = varHighlightReCache.get(word);
|
||||
if (!cachedRe) {
|
||||
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
cachedRe = {
|
||||
assignment: new RegExp(`^\\s*${esc}\\s*=`, "mu"),
|
||||
highlight: new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu"),
|
||||
};
|
||||
if (varHighlightReCache.size > 1000) varHighlightReCache.clear();
|
||||
varHighlightReCache.set(word, cachedRe);
|
||||
}
|
||||
|
||||
// only highlight if the word is actually assigned somewhere in the document
|
||||
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) return builder.finish();
|
||||
const re = new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu");
|
||||
for (const m of text.matchAll(re)) {
|
||||
if (!cachedRe.assignment.test(text)) return builder.finish();
|
||||
for (const m of text.matchAll(cachedRe.highlight)) {
|
||||
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
||||
}
|
||||
return builder.finish();
|
||||
|
|
|
|||
171
src/ui/export.test.ts
Normal file
171
src/ui/export.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { exportSheetImage } from './export';
|
||||
import { writeImageFile } from '../storage';
|
||||
|
||||
vi.mock('../storage', () => ({
|
||||
writeImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('exportSheetImage', () => {
|
||||
let mockContext2D: any;
|
||||
let mockCanvas: any;
|
||||
let toastMock: any;
|
||||
let tMock: any;
|
||||
|
||||
beforeEach(() => {
|
||||
toastMock = vi.fn();
|
||||
tMock = vi.fn((key: string) => `trans_${key}`);
|
||||
|
||||
mockContext2D = {
|
||||
measureText: vi.fn().mockReturnValue({ width: 10 }),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
};
|
||||
|
||||
mockCanvas = {
|
||||
getContext: vi.fn().mockReturnValue(mockContext2D),
|
||||
toBlob: vi.fn((cb) => cb(new Blob(['fake-image-data'], { type: 'image/png' }))),
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
vi.stubGlobal('document', {
|
||||
documentElement: {},
|
||||
createElement: vi.fn((tag) => {
|
||||
if (tag === 'canvas') return mockCanvas;
|
||||
return {};
|
||||
})
|
||||
});
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 0
|
||||
});
|
||||
|
||||
vi.stubGlobal('getComputedStyle', vi.fn().mockReturnValue({
|
||||
getPropertyValue: vi.fn((prop) => {
|
||||
if (prop === '--bg') return '';
|
||||
if (prop === '--fg') return '';
|
||||
if (prop === '--result') return '';
|
||||
if (prop === '--mono') return '';
|
||||
return '';
|
||||
})
|
||||
}));
|
||||
|
||||
vi.stubGlobal('Blob', class Blob {
|
||||
content: any[];
|
||||
options: any;
|
||||
constructor(content: any[], options: any) {
|
||||
this.content = content;
|
||||
this.options = options;
|
||||
}
|
||||
async arrayBuffer() {
|
||||
return new Uint8Array([1, 2, 3]).buffer;
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('ClipboardItem', class ClipboardItem {
|
||||
constructor(data: any) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: {
|
||||
write: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('btoa', (str: string) => Buffer.from(str, 'binary').toString('base64'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a canvas and copy it to clipboard successfully', async () => {
|
||||
await exportSheetImage({
|
||||
lines: ['1 + 1'],
|
||||
results: [{ text: '2' }],
|
||||
fontSize: 14,
|
||||
toast: toastMock,
|
||||
t: tMock
|
||||
});
|
||||
|
||||
expect(document.createElement).toHaveBeenCalledWith('canvas');
|
||||
expect(mockCanvas.getContext).toHaveBeenCalledWith('2d');
|
||||
expect(mockContext2D.fillText).toHaveBeenCalledWith('1 + 1', expect.any(Number), expect.any(Number));
|
||||
expect(mockContext2D.fillText).toHaveBeenCalledWith('2', expect.any(Number), expect.any(Number));
|
||||
|
||||
expect(navigator.clipboard.write).toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith('trans_imageCopied');
|
||||
});
|
||||
|
||||
it('should handle blob failure', async () => {
|
||||
mockCanvas.toBlob = vi.fn((cb) => cb(null));
|
||||
|
||||
await exportSheetImage({
|
||||
lines: ['test'],
|
||||
results: [{ text: null }],
|
||||
fontSize: 14,
|
||||
toast: toastMock,
|
||||
t: tMock
|
||||
});
|
||||
|
||||
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
|
||||
expect(navigator.clipboard.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to writeImageFile when clipboard write fails', async () => {
|
||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
||||
vi.mocked(writeImageFile).mockResolvedValue(true);
|
||||
|
||||
await exportSheetImage({
|
||||
lines: ['fallback'],
|
||||
results: [{ text: 'res' }],
|
||||
fontSize: 14,
|
||||
toast: toastMock,
|
||||
t: tMock
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.write).toHaveBeenCalled();
|
||||
expect(writeImageFile).toHaveBeenCalled();
|
||||
expect(writeImageFile).toHaveBeenCalledWith('AQID');
|
||||
expect(toastMock).toHaveBeenCalledWith('trans_saved');
|
||||
});
|
||||
|
||||
it('should handle writeImageFile failure in fallback', async () => {
|
||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
||||
vi.mocked(writeImageFile).mockRejectedValue(new Error('save fail'));
|
||||
|
||||
await exportSheetImage({
|
||||
lines: ['fail'],
|
||||
results: [{ text: 'res' }],
|
||||
fontSize: 14,
|
||||
toast: toastMock,
|
||||
t: tMock
|
||||
});
|
||||
|
||||
expect(writeImageFile).toHaveBeenCalled();
|
||||
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
|
||||
});
|
||||
|
||||
it('should handle writeImageFile returning false', async () => {
|
||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
||||
vi.mocked(writeImageFile).mockResolvedValue(false);
|
||||
|
||||
await exportSheetImage({
|
||||
lines: ['fail'],
|
||||
results: [{ text: 'res' }],
|
||||
fontSize: 14,
|
||||
toast: toastMock,
|
||||
t: tMock
|
||||
});
|
||||
|
||||
expect(writeImageFile).toHaveBeenCalled();
|
||||
expect(toastMock).not.toHaveBeenCalledWith('trans_saved');
|
||||
});
|
||||
});
|
||||
243
src/ui/search.test.ts
Normal file
243
src/ui/search.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SumEngine } from "../engine";
|
||||
import { parseResultQuery, searchAllSheets, SearchDeps, SearchDoc } from "./search";
|
||||
import { Workspace } from "../workspace";
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
describe("parseResultQuery", () => {
|
||||
const engine = new SumEngine();
|
||||
|
||||
it("parses '>=' operator correctly", () => {
|
||||
const res = parseResultQuery(engine, ">= 100");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.op).toBe(">=");
|
||||
expect(res?.threshold.kind).toBe("quantity");
|
||||
if (res?.threshold.kind === "quantity") {
|
||||
expect(res.threshold.value.eq(new Decimal(100))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses '<=' operator correctly", () => {
|
||||
const res = parseResultQuery(engine, "<= 50");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.op).toBe("<=");
|
||||
expect(res?.threshold.kind).toBe("quantity");
|
||||
if (res?.threshold.kind === "quantity") {
|
||||
expect(res.threshold.value.eq(new Decimal(50))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses '>' operator correctly", () => {
|
||||
const res = parseResultQuery(engine, "> 0");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.op).toBe(">");
|
||||
expect(res?.threshold.kind).toBe("quantity");
|
||||
if (res?.threshold.kind === "quantity") {
|
||||
expect(res.threshold.value.eq(new Decimal(0))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses '<' operator correctly", () => {
|
||||
const res = parseResultQuery(engine, "< -10");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.op).toBe("<");
|
||||
expect(res?.threshold.kind).toBe("quantity");
|
||||
if (res?.threshold.kind === "quantity") {
|
||||
expect(res.threshold.value.eq(new Decimal(-10))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses '=' operator correctly", () => {
|
||||
const res = parseResultQuery(engine, "= 42.5");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.op).toBe("=");
|
||||
expect(res?.threshold.kind).toBe("quantity");
|
||||
if (res?.threshold.kind === "quantity") {
|
||||
expect(res.threshold.value.eq(new Decimal(42.5))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses '~' operator correctly", () => {
|
||||
const res = parseResultQuery(engine, "~ 3.14");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.op).toBe("~");
|
||||
expect(res?.threshold.kind).toBe("quantity");
|
||||
if (res?.threshold.kind === "quantity") {
|
||||
expect(res.threshold.value.eq(new Decimal(3.14))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles whitespace correctly", () => {
|
||||
const res = parseResultQuery(engine, " >= 200 ");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.op).toBe(">=");
|
||||
expect(res?.threshold.kind).toBe("quantity");
|
||||
if (res?.threshold.kind === "quantity") {
|
||||
expect(res.threshold.value.eq(new Decimal(200))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for invalid operator", () => {
|
||||
const res = parseResultQuery(engine, "!= 100");
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid value string", () => {
|
||||
// "abc" cannot be evaluated to a quantity
|
||||
const res = parseResultQuery(engine, ">= abc");
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
const res = parseResultQuery(engine, "");
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no expression matches after operator", () => {
|
||||
const res = parseResultQuery(engine, ">=");
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchAllSheets", () => {
|
||||
it("returns empty array for empty query", () => {
|
||||
const deps = {} as SearchDeps;
|
||||
expect(searchAllSheets(deps, " ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("performs full-text search across multiple sheets", () => {
|
||||
const docs: SearchDoc[] = [
|
||||
{ id: "1", title: "Doc 1", text: "Hello world\nThis is a test" },
|
||||
{ id: "2", title: "Doc 2", text: "Another test\nNothing here" },
|
||||
];
|
||||
const deps = {
|
||||
docs: () => docs,
|
||||
engine: new SumEngine(),
|
||||
} as SearchDeps;
|
||||
|
||||
const hits = searchAllSheets(deps, "test");
|
||||
expect(hits).toHaveLength(2);
|
||||
expect(hits[0]).toMatchObject({ docId: "1", docTitle: "Doc 1", line: 2, text: "This is a test" });
|
||||
expect(hits[1]).toMatchObject({ docId: "2", docTitle: "Doc 2", line: 1, text: "Another test" });
|
||||
});
|
||||
|
||||
it("limits results to 200 hits for text search", () => {
|
||||
const lines = Array(250).fill("test string");
|
||||
const docs: SearchDoc[] = [
|
||||
{ id: "1", title: "Doc 1", text: lines.join("\n") },
|
||||
];
|
||||
const deps = {
|
||||
docs: () => docs,
|
||||
engine: new SumEngine(),
|
||||
} as SearchDeps;
|
||||
|
||||
const hits = searchAllSheets(deps, "test");
|
||||
expect(hits).toHaveLength(200);
|
||||
});
|
||||
|
||||
describe("numeric threshold search", () => {
|
||||
const engine = new SumEngine();
|
||||
|
||||
const createMockWorkspace = (results: any[]) => {
|
||||
return {
|
||||
getCachedResults: vi.fn().mockReturnValue(results)
|
||||
} as unknown as Workspace;
|
||||
};
|
||||
|
||||
it("matches exact value (=)", () => {
|
||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100" }];
|
||||
const workspace = createMockWorkspace([{
|
||||
value: { kind: "quantity", value: new Decimal(100) },
|
||||
text: "100"
|
||||
}]);
|
||||
|
||||
const deps = {
|
||||
docs: () => docs,
|
||||
engine,
|
||||
workspace,
|
||||
} as unknown as SearchDeps;
|
||||
|
||||
const hits = searchAllSheets(deps, "= 100");
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]).toMatchObject({ docId: "1", line: 1, result: "100" });
|
||||
|
||||
const noHits = searchAllSheets(deps, "= 99");
|
||||
expect(noHits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("matches greater than (>)", () => {
|
||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "150\n50" }];
|
||||
const workspace = createMockWorkspace([
|
||||
{ value: { kind: "quantity", value: new Decimal(150) }, text: "150" },
|
||||
{ value: { kind: "quantity", value: new Decimal(50) }, text: "50" }
|
||||
]);
|
||||
|
||||
const deps = {
|
||||
docs: () => docs,
|
||||
engine,
|
||||
workspace,
|
||||
} as unknown as SearchDeps;
|
||||
|
||||
const hits = searchAllSheets(deps, "> 100");
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].line).toBe(1);
|
||||
});
|
||||
|
||||
it("matches approximate value (~)", () => {
|
||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100.5\n99.5\n105" }];
|
||||
const workspace = createMockWorkspace([
|
||||
{ value: { kind: "quantity", value: new Decimal(100.5) }, text: "100.5" },
|
||||
{ value: { kind: "quantity", value: new Decimal(99.5) }, text: "99.5" },
|
||||
{ value: { kind: "quantity", value: new Decimal(105) }, text: "105" }
|
||||
]);
|
||||
|
||||
const deps = {
|
||||
docs: () => docs,
|
||||
engine,
|
||||
workspace,
|
||||
} as unknown as SearchDeps;
|
||||
|
||||
const hits = searchAllSheets(deps, "~ 100");
|
||||
expect(hits).toHaveLength(2); // 100.5 and 99.5 are within 1% of 100
|
||||
expect(hits[0].line).toBe(1);
|
||||
expect(hits[1].line).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores non-quantity results during numeric search", () => {
|
||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100\nnot a number" }];
|
||||
const workspace = createMockWorkspace([
|
||||
{ value: { kind: "quantity", value: new Decimal(100) }, text: "100" },
|
||||
{ value: null, text: null }
|
||||
]);
|
||||
|
||||
const deps = {
|
||||
docs: () => docs,
|
||||
engine,
|
||||
workspace,
|
||||
} as unknown as SearchDeps;
|
||||
|
||||
const hits = searchAllSheets(deps, "> 50");
|
||||
expect(hits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("limits results to 200 hits for numeric search", () => {
|
||||
const lines = Array(250).fill("100");
|
||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: lines.join("\n") }];
|
||||
|
||||
const results = Array(250).fill({
|
||||
value: { kind: "quantity", value: new Decimal(100) },
|
||||
text: "100"
|
||||
});
|
||||
const workspace = createMockWorkspace(results);
|
||||
|
||||
const deps = {
|
||||
docs: () => docs,
|
||||
engine,
|
||||
workspace,
|
||||
} as unknown as SearchDeps;
|
||||
|
||||
const hits = searchAllSheets(deps, "> 50");
|
||||
expect(hits).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -32,7 +32,7 @@ export interface SearchController {
|
|||
|
||||
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
|
||||
|
||||
function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
|
||||
export function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
|
||||
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
||||
if (!m) return null;
|
||||
const v = engine.evaluateExpression(m[2].trim());
|
||||
|
|
@ -40,7 +40,7 @@ function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold
|
|||
return { op: m[1], threshold: v };
|
||||
}
|
||||
|
||||
function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||
export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
const docs = deps.docs();
|
||||
|
|
|
|||
115
src/ui/sparkline.test.ts
Normal file
115
src/ui/sparkline.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildSparkline } from "./sparkline";
|
||||
|
||||
describe("buildSparkline", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", {
|
||||
createElementNS: vi.fn((ns, tag) => {
|
||||
const attributes: Record<string, string> = {};
|
||||
const classes: string[] = [];
|
||||
const children: any[] = [];
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
tagName: tag,
|
||||
namespaceURI: ns,
|
||||
setAttribute: vi.fn((key, val) => {
|
||||
attributes[key] = val;
|
||||
}),
|
||||
getAttribute: vi.fn((key) => attributes[key]),
|
||||
classList: {
|
||||
add: vi.fn((cls) => {
|
||||
classes.push(cls);
|
||||
}),
|
||||
contains: vi.fn((cls) => classes.includes(cls)),
|
||||
},
|
||||
style,
|
||||
appendChild: vi.fn((child) => {
|
||||
children.push(child);
|
||||
}),
|
||||
_getAttributes: () => attributes,
|
||||
_getClasses: () => classes,
|
||||
_getChildren: () => children,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("creates a base SVG element with correct attributes and style", () => {
|
||||
const pts = [10, 20, 30];
|
||||
const svg = buildSparkline(pts, 150) as any;
|
||||
|
||||
expect(document.createElementNS).toHaveBeenCalledWith("http://www.w3.org/2000/svg", "svg");
|
||||
expect(svg.tagName).toBe("svg");
|
||||
expect(svg._getAttributes()).toMatchObject({
|
||||
width: "80",
|
||||
height: "20",
|
||||
viewBox: "0 0 80 20",
|
||||
});
|
||||
expect(svg._getClasses()).toContain("result-sparkline");
|
||||
expect(svg.style.top).toBe("150px");
|
||||
});
|
||||
|
||||
it("appends a straight line when all points are equal (min === max)", () => {
|
||||
const pts = [10, 10, 10];
|
||||
const svg = buildSparkline(pts, 0) as any;
|
||||
|
||||
const children = svg._getChildren();
|
||||
expect(children.length).toBe(1);
|
||||
|
||||
const line = children[0];
|
||||
expect(line.tagName).toBe("line");
|
||||
expect(line._getAttributes()).toMatchObject({
|
||||
x1: "2",
|
||||
y1: "10",
|
||||
x2: "78",
|
||||
y2: "10",
|
||||
stroke: "var(--result)",
|
||||
"stroke-width": "1.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends a polyline and a circle for varying points", () => {
|
||||
const pts = [10, 20, 30]; // min=10, max=30
|
||||
const svg = buildSparkline(pts, 0) as any;
|
||||
|
||||
const children = svg._getChildren();
|
||||
expect(children.length).toBe(2);
|
||||
|
||||
const polyline = children[0];
|
||||
expect(polyline.tagName).toBe("polyline");
|
||||
expect(polyline._getAttributes()).toMatchObject({
|
||||
fill: "none",
|
||||
stroke: "var(--result)",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
});
|
||||
|
||||
// Check points calculation
|
||||
const pointsStr = polyline._getAttributes().points;
|
||||
expect(typeof pointsStr).toBe("string");
|
||||
const points = pointsStr.split(" ");
|
||||
expect(points.length).toBe(3);
|
||||
|
||||
// First point should be at x=2 (pad), y=18 (H-pad because value is min)
|
||||
expect(points[0]).toBe("2.0,18.0");
|
||||
// Middle point should be at x=40 (pad + W/2), y=10 (pad + H/2 because value is mid)
|
||||
expect(points[1]).toBe("40.0,10.0");
|
||||
// Last point should be at x=78 (W-pad), y=2 (pad because value is max)
|
||||
expect(points[2]).toBe("78.0,2.0");
|
||||
|
||||
const circle = children[1];
|
||||
expect(circle.tagName).toBe("circle");
|
||||
expect(circle._getAttributes()).toMatchObject({
|
||||
cx: "78.0",
|
||||
cy: "2.0",
|
||||
r: "2",
|
||||
fill: "var(--result)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "./logger";
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { checkForUpdate } from './updater';
|
||||
import * as storage from './storage';
|
||||
|
|
@ -22,8 +23,8 @@ vi.mock('@tauri-apps/plugin-process', () => ({
|
|||
describe('checkForUpdate', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(logger, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should return null if not in Tauri', async () => {
|
||||
|
|
@ -39,7 +40,7 @@ describe('checkForUpdate', () => {
|
|||
|
||||
const result = await checkForUpdate();
|
||||
expect(result).toBe('error');
|
||||
expect(console.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
|
||||
expect(logger.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
|
||||
});
|
||||
|
||||
it('should return null if no update is available', async () => {
|
||||
|
|
@ -52,7 +53,7 @@ describe('checkForUpdate', () => {
|
|||
|
||||
const result = await checkForUpdate();
|
||||
expect(result).toBeNull();
|
||||
expect(console.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release");
|
||||
expect(logger.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release");
|
||||
});
|
||||
|
||||
it('should return an update object if an update is available', async () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { logger } from "./logger";
|
||||
import { isTauri } from "./storage";
|
||||
|
||||
interface AvailableUpdate {
|
||||
|
|
@ -19,10 +20,10 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
|
|||
// visible in devtools console — the fastest way to tell "already on
|
||||
// the latest published release" apart from "the check silently failed"
|
||||
const { getVersion } = await import("@tauri-apps/api/app");
|
||||
console.info(`no update: running ${await getVersion()}, that's the latest published release`);
|
||||
logger.info(`no update: running ${await getVersion()}, that's the latest published release`);
|
||||
return null;
|
||||
}
|
||||
console.info(`update available: ${update.currentVersion} -> ${update.version}`);
|
||||
logger.info(`update available: ${update.currentVersion} -> ${update.version}`);
|
||||
return {
|
||||
version: update.version,
|
||||
async install() {
|
||||
|
|
@ -32,7 +33,7 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
|
|||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("update check failed", e);
|
||||
logger.warn("update check failed", e);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue