chore: Replace console.warn with @tauri-apps/plugin-log

Replaces the raw `console.warn` in the evaluation engine (`src/engine/index.ts`) with the Tauri logging plugin (`warn` from `@tauri-apps/plugin-log`) for better production observability. Ensures tests mock this API.

Added necessary backend initialization in `src-tauri`.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-12 17:53:52 +00:00
parent 268ac9e81b
commit 303caf1cf3
8 changed files with 230 additions and 30 deletions

10
package-lock.json generated
View file

@ -19,6 +19,7 @@
"@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
@ -1244,6 +1245,15 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-log": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.9.0.tgz",
"integrity": "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",

View file

@ -25,6 +25,7 @@
"@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",

69
src-tauri/Cargo.lock generated
View file

@ -32,6 +32,23 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@ -941,6 +958,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -1004,6 +1031,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@ -2270,6 +2306,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
@ -3736,6 +3781,7 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
@ -4075,6 +4121,27 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-log"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [
"android_logger",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@ -4326,7 +4393,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",

View file

@ -21,6 +21,7 @@ serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
chrono = "0.4"
futures = "0.3.32"
tauri-plugin-log = "2.9.0"
[profile.release]
strip = true

View file

@ -985,6 +985,7 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_log::Builder::new().build())
.manage(DroppedFiles::default())
.manage(DataDirGate::default())
.invoke_handler(tauri::generate_handler![

View file

@ -1,6 +1,12 @@
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, vi } from "vitest";
import { SumEngine } from "./index";
vi.mock("@tauri-apps/plugin-log", () => ({
warn: vi.fn().mockResolvedValue(undefined),
error: vi.fn().mockResolvedValue(undefined),
info: vi.fn().mockResolvedValue(undefined),
}));
let eng: SumEngine;
beforeAll(() => {

View file

@ -1,5 +1,11 @@
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, vi } from "vitest";
import { SumEngine } from "./index";
vi.mock("@tauri-apps/plugin-log", () => ({
warn: vi.fn().mockResolvedValue(undefined),
error: vi.fn().mockResolvedValue(undefined),
info: vi.fn().mockResolvedValue(undefined),
}));
import { qty, EvalError, XRefError } from "./types";
import type { XRefResolution, EvalCtx } from "./evaluator";
import { evaluate } from "./evaluator";
@ -230,7 +236,13 @@ describe("variables and document context", () => {
});
it("sum / total", () => {
expect(calcDoc("10\n20\n30\nsum")).toEqual(["10", "20", "30", "60"]);
expect(calcDoc("10\n20\n\n30\nsum")).toEqual(["10", "20", null, "30", "30"]);
expect(calcDoc("10\n20\n\n30\nsum")).toEqual([
"10",
"20",
null,
"30",
"30",
]);
});
it("sum with units", () => {
expect(calcDoc("$10\n$20\ntotal")).toEqual(["$10", "$20", "$30"]);
@ -243,15 +255,31 @@ describe("variables and document context", () => {
});
it("count", () => {
expect(calcDoc("10\n20\n30\ncount")).toEqual(["10", "20", "30", "3"]);
expect(calcDoc("10\n20\n\n30\ncount")).toEqual(["10", "20", null, "30", "1"]);
expect(calcDoc("10\n20\n\n30\ncount")).toEqual([
"10",
"20",
null,
"30",
"1",
]);
});
it("min / max", () => {
expect(calcDoc("10\n20\n5\nmin")).toEqual(["10", "20", "5", "5"]);
expect(calcDoc("10\n20\n5\nmax")).toEqual(["10", "20", "5", "20"]);
});
it("min / max with units", () => {
expect(calcDoc("10 m\n5 m\n20 m\nmin")).toEqual(["10 m", "5 m", "20 m", "5 m"]);
expect(calcDoc("10 m\n5 m\n20 m\nmax")).toEqual(["10 m", "5 m", "20 m", "20 m"]);
expect(calcDoc("10 m\n5 m\n20 m\nmin")).toEqual([
"10 m",
"5 m",
"20 m",
"5 m",
]);
expect(calcDoc("10 m\n5 m\n20 m\nmax")).toEqual([
"10 m",
"5 m",
"20 m",
"20 m",
]);
});
it("product", () => {
expect(calcDoc("2\n3\n4\nproduct")).toEqual(["2", "3", "4", "24"]);
@ -332,18 +360,32 @@ describe("goal seek", () => {
describe("extension API", () => {
it("addUnit (horse from Sample.js)", () => {
eng.addUnit({ id: "horse", phrases: "horse, horses, hrs", baseUnitId: "meter", format: "hrs", ratio: 2.4 });
eng.addUnit({
id: "horse",
phrases: "horse, horses, hrs",
baseUnitId: "meter",
format: "hrs",
ratio: 2.4,
});
expect(calc("2 horses in meters")).toBe("4.8 m");
});
it("addUnit throws on id collision instead of silently corrupting unitsById", () => {
expect(() =>
eng.addUnit({ id: "horse", phrases: "nag", baseUnitId: "meter", format: "nag", ratio: 99 })
eng.addUnit({
id: "horse",
phrases: "nag",
baseUnitId: "meter",
format: "nag",
ratio: 99,
}),
).toThrow(/horse/);
// the original registration must survive the failed collision
expect(calc("2 horses in meters")).toBe("4.8 m");
});
it("addFunction (zum from Sample.js)", () => {
eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double }));
eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({
double: values[0].double + values[1].double,
}));
expect(calc("zum(2;3)")).toBe("5");
});
it("setVariable", () => {
@ -374,7 +416,8 @@ describe("cross-sheet references", () => {
const s = sheet.toLowerCase();
if (s === "budget") {
if (key === "total") return { ok: true, value: qty(150) };
if (key === "rent") return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) };
if (key === "rent")
return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) };
if (key === "last") return { ok: true, value: qty(7) };
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
}
@ -415,7 +458,10 @@ describe("cross-sheet references", () => {
expect(r[0].error).toBeUndefined();
});
it("propagates a resolver-reported circular reference", () => {
const cyc = (): XRefResolution => ({ ok: false, reason: "circular reference" });
const cyc = (): XRefResolution => ({
ok: false,
reason: "circular reference",
});
const r = eng.evaluateDocument("@A.total", cyc);
expect(r[0].value).toBeNull();
expect(r[0].error).toBe("circular reference");
@ -439,7 +485,9 @@ describe("evaluation errors", () => {
reg: eng.reg,
historicalRates: {},
} as unknown as EvalCtx;
expect(() => evaluate({ k: "var", name: "unknown" }, ctx)).toThrowError(EvalError);
expect(() => evaluate({ k: "var", name: "unknown" }, ctx)).toThrowError(
EvalError,
);
});
it("throws XRefError for unresolved xref", () => {
@ -449,6 +497,8 @@ describe("evaluation errors", () => {
historicalRates: {},
resolveXRef: () => ({ ok: false, reason: "xref error" }),
} as unknown as EvalCtx;
expect(() => evaluate({ k: "xref", sheet: "Sheet", key: "key" }, ctx)).toThrowError(XRefError);
expect(() =>
evaluate({ k: "xref", sheet: "Sheet", key: "key" }, ctx),
).toThrowError(XRefError);
});
});

View file

@ -1,6 +1,18 @@
// 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";
import { warn } from "@tauri-apps/plugin-log";
import {
DateOrder,
Decimal,
EngineSettings,
EvalError,
Quantity,
Unit,
Value,
XRefError,
defaultSettings,
qty,
} from "./types";
import { Registry, buildRegistry, Completion } from "./registry";
import { tokenize, Token } from "./tokenizer";
import { parseLine, ParsedLine } from "./parser";
@ -84,7 +96,8 @@ export class SumEngine {
updateSettings(patch: Partial<EngineSettings>): void {
this.settings = { ...this.settings, ...patch };
if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat);
if (patch.dateFormat)
this.dateOrder = resolveDateOrder(this.settings.dateFormat);
}
completions(): Completion[] {
@ -106,13 +119,25 @@ export class SumEngine {
if (trimmed.length === 0) {
const kind = commentStart >= 0 ? "comment" : "empty";
results.push({ text: null, value: null, kind, tokens: [], commentStart: commentStart >= 0 ? commentStart : null });
results.push({
text: null,
value: null,
kind,
tokens: [],
commentStart: commentStart >= 0 ? commentStart : null,
});
lineValues.push(null);
lineKinds.push("empty");
continue;
}
if (trimmed.startsWith("#")) {
results.push({ text: null, value: null, kind: "header", tokens: [], commentStart: commentStart >= 0 ? commentStart : null });
results.push({
text: null,
value: null,
kind: "header",
tokens: [],
commentStart: commentStart >= 0 ? commentStart : null,
});
lineValues.push(null);
lineKinds.push("header");
continue;
@ -148,7 +173,9 @@ export class SumEngine {
} else {
// Surface unexpected bugs/BigInt errors/RangeErrors and log them
lineError = e instanceof Error ? e.message : String(e);
console.warn("evaluate failed:", e);
warn(
`evaluate failed: ${e instanceof Error ? e.stack || e.message : String(e)}`,
).catch((err) => console.warn("logger failed", err));
}
value = null;
}
@ -158,7 +185,10 @@ export class SumEngine {
lineValues.push(value);
lineKinds.push("normal");
results.push({
text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null,
text:
value && value.kind !== "chart"
? formatValue(value, this.settings)
: null,
value,
kind: "normal",
tokens,
@ -216,9 +246,13 @@ export class SumEngine {
}
addUnit(spec: ExtensionUnitSpec): void {
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
if (this.reg.unitsById.has(spec.id)) throw new Error(`numi.addUnit: id "${spec.id}" is already registered`);
const base =
this.reg.unitsById.get(spec.baseUnitId) ??
this.currencyBase(spec.baseUnitId);
if (!base)
throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
if (this.reg.unitsById.has(spec.id))
throw new Error(`numi.addUnit: id "${spec.id}" is already registered`);
const unit: Unit = {
id: spec.id,
dimension: base.dimension,
@ -229,12 +263,24 @@ export class SumEngine {
this.reg.unitsById.set(unit.id, unit);
for (const p of spec.phrases.split(",")) {
const phrase = p.trim();
if (phrase) this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false });
if (phrase)
this.reg.addPhrase(
phrase,
{ t: "unit", unit },
{ caseSensitive: false },
);
}
this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" });
this.reg.completions.push({
label: spec.id,
type: "unit",
detail: "extension",
});
}
addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void {
addFunction(
spec: { id: string; phrases: string },
fn: (values: ExtensionValue[]) => ExtensionValue | number,
): void {
const name = spec.id;
this.reg.customFuncs.set(name, (args: Value[]) => {
const mapped = args.map((a) => this.fromValue(a));
@ -242,26 +288,42 @@ export class SumEngine {
});
for (const p of (spec.phrases || spec.id).split(",")) {
const phrase = p.trim();
if (phrase) this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false });
if (phrase)
this.reg.addPhrase(
phrase,
{ t: "func", name },
{ caseSensitive: false },
);
}
this.reg.completions.push({ label: name, type: "function", detail: "extension" });
this.reg.completions.push({
label: name,
type: "function",
detail: "extension",
});
}
private currencyBase(code: string): Unit | null {
return this.reg.currencyCodes.has(code) ? this.reg.makeCurrencyUnit(code) : null;
return this.reg.currencyCodes.has(code)
? this.reg.makeCurrencyUnit(code)
: null;
}
private toValue(v: number | ExtensionValue): Value {
if (typeof v === "number") return qty(v);
const unit = v.unitId
? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null
? (this.reg.unitsById.get(v.unitId) ??
this.currencyBase(v.unitId) ??
null)
: null;
return qty(new Decimal(v.double), unit);
}
private fromValue(v: Value): ExtensionValue {
if (v.kind === "quantity") {
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
return {
double: v.value.toNumber(),
...(v.unit ? { unitId: v.unit.id } : {}),
};
}
if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
if (v.kind === "date") return { double: v.ms };