From 05a8225a483176d2ecd732cd3448ccadf46fd6fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 17:08:23 +0000 Subject: [PATCH] fix(control-panel): treat Metric display names as literal text Gate check details were rewritten with String.replaceAll, which interprets $&, $', and $ in authored Metric names as replacement patterns. Use a replacer function so the display name stays byte-for-byte. Issue: SPL-587 Co-authored-by: Isaac Suttell --- .../src/lib/experiments/metric-names.test.ts | 42 +++++++++++++++++++ .../src/lib/experiments/metric-names.ts | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 apps/control-panel/src/lib/experiments/metric-names.test.ts diff --git a/apps/control-panel/src/lib/experiments/metric-names.test.ts b/apps/control-panel/src/lib/experiments/metric-names.test.ts new file mode 100644 index 00000000..73e4cb10 --- /dev/null +++ b/apps/control-panel/src/lib/experiments/metric-names.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { metricDisplayName, withMetricNames } from "./metric-names"; + +describe("withMetricNames", () => { + it("substitutes an ordinary Metric display name", () => { + expect(withMetricNames("metric_x failed", new Map([["metric_x", "Checkout conversion"]]))).toBe( + "Checkout conversion failed", + ); + }); + + it("treats $& as literal authored text, not the matched Metric id", () => { + expect(withMetricNames("metric_x failed", new Map([["metric_x", "Revenue $&"]]))).toBe( + "Revenue $& failed", + ); + }); + + it("treats $' as literal authored text, not the unmatched suffix", () => { + expect(withMetricNames("metric_x failed", new Map([["metric_x", "Revenue $'"]]))).toBe( + "Revenue $' failed", + ); + }); + + it("treats $ as literal authored text", () => { + expect(withMetricNames("metric_x failed", new Map([["metric_x", "Revenue $"]]))).toBe( + "Revenue $ failed", + ); + }); + + it("leaves a deleted Metric id in place when no display name remains", () => { + expect(withMetricNames("metric_x failed", new Map())).toBe("metric_x failed"); + }); +}); + +describe("metricDisplayName", () => { + it("falls back to the raw Metric id after the catalog entry is gone", () => { + expect(metricDisplayName("metric_x", new Map())).toBe("metric_x"); + }); + + it("returns the authored display name when the catalog still has it", () => { + expect(metricDisplayName("metric_x", new Map([["metric_x", "Revenue $&"]]))).toBe("Revenue $&"); + }); +}); diff --git a/apps/control-panel/src/lib/experiments/metric-names.ts b/apps/control-panel/src/lib/experiments/metric-names.ts index c87a7b4f..6416bea3 100644 --- a/apps/control-panel/src/lib/experiments/metric-names.ts +++ b/apps/control-panel/src/lib/experiments/metric-names.ts @@ -24,6 +24,6 @@ export function metricDisplayName(metricId: string, names: MetricNames): string /** Gate check details arrive as prose with raw Metric ids embedded; rewrite them. */ export function withMetricNames(text: string, names: MetricNames): string { let renamed = text; - for (const [id, name] of names) renamed = renamed.replaceAll(id, name); + for (const [id, name] of names) renamed = renamed.replaceAll(id, () => name); return renamed; }