mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
632 lines
25 KiB
TypeScript
632 lines
25 KiB
TypeScript
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { RE2JS } from "re2js";
|
|
import {
|
|
OVERRIDE_CONFIG_TYPES,
|
|
parseRegexPattern,
|
|
valueMappingPatternError,
|
|
buildValueMappingCache,
|
|
lookupValueMapping,
|
|
lookupValueMappingFull,
|
|
parseTimestampValue,
|
|
parseOverrideConfigs,
|
|
applyColumnOverrides,
|
|
formatNumericValue,
|
|
resolveIsNumber,
|
|
resolveMetricValueStyle,
|
|
} from "./tableConfigUtils";
|
|
|
|
describe("tableConfigUtils", () => {
|
|
describe("parseRegexPattern", () => {
|
|
it("extracts pattern + flags from /pattern/flags syntax", () => {
|
|
expect(parseRegexPattern("/foo/gi")).toEqual({ pattern: "foo", flags: "gi" });
|
|
});
|
|
it("treats a bare string as the pattern with no flags", () => {
|
|
expect(parseRegexPattern("bar")).toEqual({ pattern: "bar", flags: "" });
|
|
});
|
|
it("handles a pattern containing slashes when no flags", () => {
|
|
// "a/b" has no trailing /flags, so it's used verbatim
|
|
expect(parseRegexPattern("a/b")).toEqual({ pattern: "a/b", flags: "" });
|
|
});
|
|
});
|
|
|
|
describe("buildValueMappingCache", () => {
|
|
it("returns null for empty / non-array input", () => {
|
|
expect(buildValueMappingCache(null)).toBeNull();
|
|
expect(buildValueMappingCache(undefined)).toBeNull();
|
|
expect(buildValueMappingCache([])).toBeNull();
|
|
});
|
|
it("skips mappings that have neither text nor color", () => {
|
|
expect(buildValueMappingCache([{ type: "value", value: "5" }])).toBeNull();
|
|
});
|
|
it("includes a mapping that has only a color", () => {
|
|
const cache = buildValueMappingCache([{ type: "value", value: "5", color: "#f00" }]);
|
|
expect(cache).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("lookupValueMapping / lookupValueMappingFull", () => {
|
|
it("returns null when there is no cache", () => {
|
|
expect(lookupValueMapping(5, null)).toBeNull();
|
|
expect(lookupValueMappingFull(5, null, "color")).toBeNull();
|
|
});
|
|
|
|
it("matches an exact value mapping (number ↔ string coercion)", () => {
|
|
const cache = buildValueMappingCache([{ type: "value", value: "5", text: "five" }]);
|
|
expect(lookupValueMapping(5, cache)).toBe("five"); // numeric cell
|
|
expect(lookupValueMapping("5", cache)).toBe("five"); // string cell
|
|
expect(lookupValueMapping(6, cache)).toBeNull();
|
|
});
|
|
|
|
it("matches a value mapping that carries empty-string from/to/pattern siblings (real popup shape)", () => {
|
|
// ValueMappingPopUp writes every sibling key (from/to/pattern = "") on each
|
|
// mapping. Routing must use `type` — presence checks would mis-route "value"
|
|
// mappings into the range branch and they would never match.
|
|
const cache = buildValueMappingCache([
|
|
{
|
|
type: "value",
|
|
value: "1",
|
|
from: "",
|
|
to: "",
|
|
pattern: "",
|
|
color: "#aaaaaa",
|
|
text: "No Logs",
|
|
},
|
|
{
|
|
type: "value",
|
|
value: "22",
|
|
from: "",
|
|
to: "",
|
|
pattern: "",
|
|
color: "#e02f44",
|
|
text: "High",
|
|
},
|
|
]);
|
|
expect([...(cache?.keys() ?? [])]).toEqual(["1", "22"]);
|
|
expect(lookupValueMapping(1, cache)).toBe("No Logs");
|
|
expect(lookupValueMapping("1", cache)).toBe("No Logs");
|
|
expect(lookupValueMapping(22, cache)).toBe("High");
|
|
expect(lookupValueMapping(99, cache)).toBeNull();
|
|
});
|
|
|
|
it("keeps multiple value mappings distinct in the real popup shape (no __range__ collision)", () => {
|
|
// The bug routed both into the range branch under the same key "__range__",
|
|
// so the second overwrote the first and neither matched.
|
|
const cache = buildValueMappingCache([
|
|
{ type: "value", value: "1", from: "", to: "", pattern: "", text: "one" },
|
|
{ type: "value", value: "2", from: "", to: "", pattern: "", text: "two" },
|
|
{ type: "value", value: "3", from: "", to: "", pattern: "", text: "three" },
|
|
]);
|
|
expect(cache?.size).toBe(3);
|
|
expect(lookupValueMapping(1, cache)).toBe("one");
|
|
expect(lookupValueMapping(2, cache)).toBe("two");
|
|
expect(lookupValueMapping(3, cache)).toBe("three");
|
|
});
|
|
|
|
it("routes a range mapping by type even with empty value/pattern siblings", () => {
|
|
const cache = buildValueMappingCache([
|
|
{ type: "range", value: "", from: "0", to: "10", pattern: "", text: "low" },
|
|
]);
|
|
expect(lookupValueMapping(0, cache)).toBe("low");
|
|
expect(lookupValueMapping(5, cache)).toBe("low");
|
|
expect(lookupValueMapping(11, cache)).toBeNull();
|
|
});
|
|
|
|
it("routes a regex mapping by type even with empty value/from/to siblings", () => {
|
|
const cache = buildValueMappingCache([
|
|
{ type: "regex", value: "", from: "", to: "", pattern: "^err", text: "error" },
|
|
]);
|
|
expect(lookupValueMapping("err_500", cache)).toBe("error");
|
|
expect(lookupValueMapping("ok", cache)).toBeNull();
|
|
});
|
|
|
|
it("recovers a color-only value mapping in the real popup shape", () => {
|
|
const cache = buildValueMappingCache([
|
|
{ type: "value", value: "22", from: "", to: "", pattern: "", color: "#e02f44", text: "" },
|
|
]);
|
|
expect(lookupValueMapping(22, cache)).toBeNull(); // no text
|
|
expect(lookupValueMappingFull(22, cache, "color")?.color).toBe("#e02f44");
|
|
});
|
|
|
|
it("infers type for legacy untyped mappings (treating '' as absent)", () => {
|
|
const value = buildValueMappingCache([{ value: "5", text: "five" }]);
|
|
expect(lookupValueMapping(5, value)).toBe("five");
|
|
const range = buildValueMappingCache([{ from: "0", to: "10", text: "low" }]);
|
|
expect(lookupValueMapping(5, range)).toBe("low");
|
|
const regex = buildValueMappingCache([{ pattern: "^err", text: "error" }]);
|
|
expect(lookupValueMapping("err1", regex)).toBe("error");
|
|
});
|
|
|
|
it("matches a numeric range, including a from of 0", () => {
|
|
const cache = buildValueMappingCache([{ type: "range", from: "0", to: "10", text: "low" }]);
|
|
expect(lookupValueMapping(0, cache)).toBe("low");
|
|
expect(lookupValueMapping(5, cache)).toBe("low");
|
|
expect(lookupValueMapping("5", cache)).toBe("low"); // numeric string cell
|
|
expect(lookupValueMapping(11, cache)).toBeNull();
|
|
});
|
|
|
|
it("matches a regex mapping", () => {
|
|
const cache = buildValueMappingCache([{ type: "regex", pattern: "^err", text: "error" }]);
|
|
expect(lookupValueMapping("err_500", cache)).toBe("error");
|
|
expect(lookupValueMapping("ok", cache)).toBeNull();
|
|
});
|
|
|
|
it("recovers color via lookupValueMappingFull even when there is no text", () => {
|
|
const cache = buildValueMappingCache([{ type: "value", value: "5", color: "#ff0000" }]);
|
|
// colour-only mapping → no text
|
|
expect(lookupValueMapping(5, cache)).toBeNull();
|
|
// but the colour is recoverable
|
|
expect(lookupValueMappingFull(5, cache, "color")?.color).toBe("#ff0000");
|
|
});
|
|
|
|
it("is field-aware: a colour-only direct match does not shadow a text range match", () => {
|
|
const cache = buildValueMappingCache([
|
|
{ type: "value", value: "5", color: "#ff0000" }, // colour only
|
|
{ type: "range", from: "0", to: "10", text: "low" }, // text via range
|
|
]);
|
|
expect(lookupValueMapping(5, cache)).toBe("low"); // finds the text
|
|
expect(lookupValueMappingFull(5, cache, "color")?.color).toBe("#ff0000");
|
|
});
|
|
});
|
|
|
|
describe("value-mapping regex safety", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("matches a backtracking-prone pattern on a 1024-character value in linear time", () => {
|
|
const cache = buildValueMappingCache([
|
|
{ type: "regex", pattern: "(a|aa)+$", text: "alt" },
|
|
{ type: "regex", pattern: "(.*){12}!", text: "fixed" },
|
|
{ type: "regex", pattern: "^(a+)+$", text: "nested" },
|
|
]);
|
|
const start = performance.now();
|
|
|
|
expect(lookupValueMappingFull("a".repeat(1023) + "!", cache)?.text).toBe("fixed");
|
|
expect(lookupValueMappingFull("b".repeat(1024), cache)).toBeNull();
|
|
expect(performance.now() - start).toBeLessThan(500);
|
|
});
|
|
|
|
it("accepts and matches common email, host, version, path and pod-name patterns", () => {
|
|
const cases: [string, string, string][] = [
|
|
["^[\\w.+-]+@([\\w-]+\\.)+[a-z]{2,}$", "ops@mail.example.com", "ops@example"],
|
|
["^[a-z0-9-]+(\\.[a-z0-9-]+)*$", "api.eu-1.example.com", "api..example"],
|
|
["^v?\\d+(\\.\\d+)*$", "v1.20.3", "v1.x"],
|
|
["^(/[^/]+)+$", "/api/v1/logs", "/api//logs"],
|
|
["^([a-z0-9]+-)+[a-z0-9]{5}$", "ingester-7f9c4-abcde", "ingester"],
|
|
["^(\\d{1,3}\\.){3}\\d{1,3}$", "10.0.12.7", "10.0.12"],
|
|
];
|
|
for (const [pattern, hit, miss] of cases) {
|
|
const cache = buildValueMappingCache([{ type: "regex", pattern, text: "hit" }]);
|
|
expect(lookupValueMapping(hit, cache)).toBe("hit");
|
|
expect(lookupValueMapping(miss, cache)).toBeNull();
|
|
expect(valueMappingPatternError(pattern)).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("refuses lookaround and backreferences, which the linear-time engine cannot run", () => {
|
|
for (const pattern of ["^(?=.*err).*$", "(?<!x)err", "err(?!or)", "(a)\\1", "/(?=a)a/i"]) {
|
|
expect(valueMappingPatternError(pattern)).toBe("unsupported");
|
|
}
|
|
});
|
|
|
|
it("never matches an unsupported pattern at render and warns once per pattern", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const mappings = [{ type: "regex", pattern: "^(?=.*err).*$", text: "lookahead" }];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
expect(lookupValueMapping("server error", buildValueMappingCache(mappings))).toBeNull();
|
|
}
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps legacy /pattern/flags mappings working", () => {
|
|
expect(valueMappingPatternError("/^err/i")).toBeNull();
|
|
const cache = buildValueMappingCache([
|
|
{ type: "regex", pattern: "/^ERR/i", text: "error" },
|
|
{ type: "regex", pattern: "/^warn.end$/s", text: "warn" },
|
|
{ type: "regex", pattern: "/^line2$/m", text: "multi" },
|
|
{ type: "regex", pattern: "/\\/api\\/(?<ver>v\\d)/", text: "api" },
|
|
]);
|
|
|
|
expect(lookupValueMapping("err_500", cache)).toBe("error");
|
|
expect(lookupValueMapping("warn\nend", cache)).toBe("warn");
|
|
expect(lookupValueMapping("line1\nline2", cache)).toBe("multi");
|
|
expect(lookupValueMapping("GET /api/v2/x", cache)).toBe("api");
|
|
});
|
|
|
|
it("rejects a pattern longer than 256 characters at save and skips it at render", () => {
|
|
const long = `${"a".repeat(300)}|^err`;
|
|
|
|
expect(valueMappingPatternError("a".repeat(256))).toBeNull();
|
|
expect(valueMappingPatternError(long)).toBe("tooLong");
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
expect(
|
|
lookupValueMapping(
|
|
"err_500",
|
|
buildValueMappingCache([{ type: "regex", pattern: long, text: "long" }]),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not run a regex against a value longer than 1024 characters", () => {
|
|
const cache = buildValueMappingCache([{ type: "regex", pattern: "^a", text: "a-prefixed" }]);
|
|
|
|
expect(lookupValueMapping("a".repeat(1024), cache)).toBe("a-prefixed");
|
|
expect(lookupValueMapping("a".repeat(1025), cache)).toBeNull();
|
|
});
|
|
|
|
it("compiles each pattern when the cache is built, not per lookup", () => {
|
|
const compile = vi.spyOn(RE2JS, "compile");
|
|
const cache = buildValueMappingCache([{ type: "regex", pattern: "^err", text: "error" }]);
|
|
const afterBuild = compile.mock.calls.length;
|
|
|
|
for (let i = 0; i < 50; i++) lookupValueMapping(`err_${i}`, cache);
|
|
|
|
expect(afterBuild).toBe(1);
|
|
expect(compile.mock.calls.length).toBe(afterBuild);
|
|
});
|
|
|
|
it("gives the same answer on every lookup for a pattern with the g flag", () => {
|
|
const cache = buildValueMappingCache([{ type: "regex", pattern: "/err/g", text: "error" }]);
|
|
|
|
expect([1, 2, 3].map(() => lookupValueMapping("err", cache))).toEqual([
|
|
"error",
|
|
"error",
|
|
"error",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("parseTimestampValue", () => {
|
|
it("returns null for empty inputs", () => {
|
|
expect(parseTimestampValue(null, "UTC")).toBeNull();
|
|
expect(parseTimestampValue(undefined, "UTC")).toBeNull();
|
|
expect(parseTimestampValue("", "UTC")).toBeNull();
|
|
});
|
|
it("returns an already-formatted timestamp string as-is", () => {
|
|
expect(parseTimestampValue("2026-06-10 14:54:30", "UTC")).toBe("2026-06-10 14:54:30");
|
|
});
|
|
});
|
|
|
|
describe("parseOverrideConfigs", () => {
|
|
it("returns empty maps for undefined input", () => {
|
|
const maps = parseOverrideConfigs(undefined);
|
|
expect(maps.colorConfigMap).toEqual({});
|
|
expect(maps.unitConfigMap).toEqual({});
|
|
expect(maps.styleConfigMap).toEqual({});
|
|
expect(maps.conditionalRulesMap).toEqual({});
|
|
});
|
|
|
|
it("parses every config type, keyed by lower-cased alias", () => {
|
|
const maps = parseOverrideConfigs([
|
|
{
|
|
field: { value: "Count" },
|
|
config: [
|
|
{ type: OVERRIDE_CONFIG_TYPES.UNIT, value: { unit: "bytes", customUnit: "" } },
|
|
{ type: OVERRIDE_CONFIG_TYPES.ALIGNMENT, value: "center" },
|
|
{ type: OVERRIDE_CONFIG_TYPES.TEXT_COLOR, value: "#111" },
|
|
{ type: OVERRIDE_CONFIG_TYPES.BACKGROUND_COLOR, value: "#eee" },
|
|
{ type: OVERRIDE_CONFIG_TYPES.UNIQUE_VALUE_COLOR, autoColor: true },
|
|
{
|
|
type: OVERRIDE_CONFIG_TYPES.CONDITIONAL_STYLES,
|
|
rules: [{ operator: ">", threshold: 5, textColor: "#f00", bgColor: "#fee" }],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
expect(maps.unitConfigMap.count).toEqual({ unit: "bytes", customUnit: "" });
|
|
expect(maps.styleConfigMap.count.alignment).toBe("center");
|
|
expect(maps.styleConfigMap.count.textColor).toBe("#111");
|
|
expect(maps.styleConfigMap.count.bgColor).toBe("#eee");
|
|
expect(maps.colorConfigMap.count.autoColor).toBe(true);
|
|
expect(maps.conditionalRulesMap.count[0]).toMatchObject({
|
|
operator: ">",
|
|
threshold: 5,
|
|
});
|
|
});
|
|
|
|
it("coerces a string threshold to a number", () => {
|
|
const maps = parseOverrideConfigs([
|
|
{
|
|
field: { value: "x" },
|
|
config: [
|
|
{
|
|
type: OVERRIDE_CONFIG_TYPES.CONDITIONAL_STYLES,
|
|
rules: [{ operator: ">", threshold: "7.5" }],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
expect(maps.conditionalRulesMap.x[0].threshold).toBe(7.5);
|
|
});
|
|
|
|
it("skips entries with no field alias", () => {
|
|
const maps = parseOverrideConfigs([{ config: [{ type: "alignment", value: "left" }] }]);
|
|
expect(maps.styleConfigMap).toEqual({});
|
|
});
|
|
|
|
it("leaves unitConfigMap untouched when a column has no unit item (unit='Default')", () => {
|
|
// Picking the 'Default' unit drops the unit config item entirely (the
|
|
// serializer's `if (c.unit)` guard), so no unitConfigMap key is created.
|
|
// The renderer's `if (unitConfigMap[alias]) … else panel unit` then falls
|
|
// back to the panel-level unit — the column inherits it (e.g. rupee)
|
|
// instead of forcing 'no unit', exactly like a brand-new field.
|
|
const maps = parseOverrideConfigs([
|
|
{
|
|
field: { value: "amount" },
|
|
config: [{ type: OVERRIDE_CONFIG_TYPES.ALIGNMENT, value: "right" }],
|
|
},
|
|
]);
|
|
expect("amount" in maps.unitConfigMap).toBe(false);
|
|
expect(maps.unitConfigMap.amount).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("applyColumnOverrides", () => {
|
|
const maps = parseOverrideConfigs([
|
|
{
|
|
field: { value: "c" },
|
|
config: [
|
|
{ type: "alignment", value: "center" },
|
|
{ type: "unique_value_color", autoColor: true },
|
|
{ type: "text_color", value: "#111" },
|
|
{ type: "background_color", value: "#eee" },
|
|
{ type: "conditional_styles", rules: [{ operator: ">", threshold: 5 }] },
|
|
],
|
|
},
|
|
]);
|
|
|
|
it("applies all override props onto the column object", () => {
|
|
const col: any = {};
|
|
applyColumnOverrides(col, "c", maps, "right");
|
|
expect(col.align).toBe("center"); // override beats default
|
|
expect(col.colorMode).toBe("auto");
|
|
expect(col.textColor).toBe("#111");
|
|
expect(col.bgColor).toBe("#eee");
|
|
expect(col.conditionalRules).toHaveLength(1);
|
|
});
|
|
|
|
it("falls back to the default alignment when none is set", () => {
|
|
const col: any = {};
|
|
applyColumnOverrides(col, "missing", maps, "right");
|
|
expect(col.align).toBe("right");
|
|
expect(col.colorMode).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("formatNumericValue", () => {
|
|
it("returns the missing-value placeholder for null/undefined/empty", () => {
|
|
expect(formatNumericValue(null, null, "", "", 2)).toBe("");
|
|
expect(formatNumericValue(undefined, null, "", "", 2)).toBe("");
|
|
expect(formatNumericValue("", null, "", "", 2)).toBe("");
|
|
expect(formatNumericValue(null, null, "", "", 2, "N/A")).toBe("N/A");
|
|
});
|
|
|
|
it("value mapping wins over numeric formatting", () => {
|
|
const cache = buildValueMappingCache([{ type: "value", value: "5", text: "five" }]);
|
|
expect(formatNumericValue(5, cache, "bytes", "", 2)).toBe("five");
|
|
});
|
|
|
|
it("formats a plain number to a non-empty string", () => {
|
|
const out = formatNumericValue(5, null, "", "", 2);
|
|
expect(typeof out).toBe("string");
|
|
expect(out).toContain("5");
|
|
});
|
|
});
|
|
|
|
describe("OVERRIDE_CONFIG_TYPES", () => {
|
|
it("matches the persisted discriminants", () => {
|
|
expect(OVERRIDE_CONFIG_TYPES).toEqual({
|
|
UNIT: "unit",
|
|
UNIQUE_VALUE_COLOR: "unique_value_color",
|
|
ALIGNMENT: "alignment",
|
|
TEXT_COLOR: "text_color",
|
|
BACKGROUND_COLOR: "background_color",
|
|
CONDITIONAL_STYLES: "conditional_styles",
|
|
FIELD_TYPE: "field_type",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveIsNumber", () => {
|
|
it("keeps the detected value for 'auto'/absent", () => {
|
|
expect(resolveIsNumber(true, "auto")).toBe(true);
|
|
expect(resolveIsNumber(false, "auto")).toBe(false);
|
|
expect(resolveIsNumber(true, undefined)).toBe(true);
|
|
expect(resolveIsNumber(false, undefined)).toBe(false);
|
|
});
|
|
it("forces numeric/text regardless of detection", () => {
|
|
expect(resolveIsNumber(false, "num")).toBe(true);
|
|
expect(resolveIsNumber(true, "text")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseOverrideConfigs field type", () => {
|
|
it("populates fieldTypeMap from a field_type item", () => {
|
|
const maps = parseOverrideConfigs([
|
|
{
|
|
field: { value: "Amount" },
|
|
config: [{ type: OVERRIDE_CONFIG_TYPES.FIELD_TYPE, value: "text" }],
|
|
},
|
|
]);
|
|
expect(maps.fieldTypeMap.amount).toBe("text");
|
|
});
|
|
});
|
|
|
|
describe("value mapping — threshold ops + colors", () => {
|
|
it("matches gt/lt/gte/lte threshold mappings", () => {
|
|
const cache = buildValueMappingCache([
|
|
{ type: "gt", value: "90", text: "High", color: "#f00" },
|
|
]);
|
|
expect(lookupValueMapping(95, cache)).toBe("High");
|
|
expect(lookupValueMapping(50, cache)).toBeNull();
|
|
expect(lookupValueMappingFull(95, cache)?.color).toBe("#f00");
|
|
});
|
|
it("caches a mapping that only has a text color (no text/color)", () => {
|
|
const cache = buildValueMappingCache([{ type: "value", value: "1", textColor: "#0f0" }]);
|
|
expect(cache).not.toBeNull();
|
|
expect(lookupValueMappingFull(1, cache)?.textColor).toBe("#0f0");
|
|
});
|
|
it("resolves overlapping thresholds with last-match-wins (column-formatting parity)", () => {
|
|
// Both `>400` and `>1000` match 2301; the later mapping must win.
|
|
const cache = buildValueMappingCache([
|
|
{ type: "gt", value: "400", text: "Warn", color: "#fa0" },
|
|
{ type: "gt", value: "1000", text: "Critical", color: "#f00" },
|
|
]);
|
|
expect(lookupValueMapping(2301, cache)).toBe("Critical");
|
|
expect(lookupValueMappingFull(2301, cache)?.color).toBe("#f00");
|
|
// Only the first threshold matches 500 → it wins by default.
|
|
expect(lookupValueMapping(500, cache)).toBe("Warn");
|
|
});
|
|
});
|
|
|
|
describe("resolveMetricValueStyle (mappings)", () => {
|
|
const base = {
|
|
mappings: undefined as any,
|
|
unit: "",
|
|
customUnit: "",
|
|
decimals: 2,
|
|
panelBackground: "",
|
|
};
|
|
|
|
it("falls back to formatted value, no text color, panel background", () => {
|
|
const r = resolveMetricValueStyle(100, { ...base, panelBackground: "#eee" });
|
|
expect(r.text).toContain("100");
|
|
expect(r.textColor).toBeUndefined();
|
|
expect(r.bgColor).toBe("#eee");
|
|
});
|
|
|
|
it("maps a value to a string with text color + background (color)", () => {
|
|
const r = resolveMetricValueStyle(1, {
|
|
...base,
|
|
mappings: [{ type: "value", value: "1", text: "Up", textColor: "#fff", color: "#0f0" }],
|
|
});
|
|
expect(r.text).toBe("Up");
|
|
expect(r.textColor).toBe("#fff");
|
|
expect(r.bgColor).toBe("#0f0");
|
|
});
|
|
|
|
it("applies a threshold mapping's background (color) to a metric value", () => {
|
|
const r = resolveMetricValueStyle(95, {
|
|
...base,
|
|
mappings: [{ type: "gte", value: "90", text: "High", color: "#900" }],
|
|
});
|
|
expect(r.text).toBe("High");
|
|
expect(r.bgColor).toBe("#900");
|
|
});
|
|
|
|
it("matches an exact mapping written against the displayed (rounded) value", () => {
|
|
// User sees "54.83" (decimals: 2) and maps Equals 54.83; raw value keeps
|
|
// full precision. The display-precision retry must bridge the gap.
|
|
const r = resolveMetricValueStyle(54.8347391, {
|
|
...base,
|
|
mappings: [{ type: "value", value: "54.83", text: "Mapped", color: "#0a0" }],
|
|
});
|
|
expect(r.text).toBe("Mapped");
|
|
expect(r.bgColor).toBe("#0a0");
|
|
});
|
|
|
|
it("matches the padded displayed spelling (48.40) for a raw 48.4", () => {
|
|
const r = resolveMetricValueStyle(48.4, {
|
|
...base,
|
|
mappings: [{ type: "value", value: "48.40", text: "Padded" }],
|
|
});
|
|
expect(r.text).toBe("Padded");
|
|
});
|
|
|
|
it("matches the normalized spelling (48.4) for a raw 48.4000001", () => {
|
|
const r = resolveMetricValueStyle(48.4000001, {
|
|
...base,
|
|
mappings: [{ type: "value", value: "48.4", text: "Normalized" }],
|
|
});
|
|
expect(r.text).toBe("Normalized");
|
|
});
|
|
|
|
it("does not false-positive when the rounded value still differs", () => {
|
|
const r = resolveMetricValueStyle(54.9, {
|
|
...base,
|
|
mappings: [{ type: "value", value: "54.83", text: "Mapped" }],
|
|
});
|
|
expect(r.text).not.toBe("Mapped");
|
|
expect(r.text).toContain("54.9");
|
|
});
|
|
|
|
it("prefers the raw exact match over the rounded retry", () => {
|
|
const r = resolveMetricValueStyle(100, {
|
|
...base,
|
|
mappings: [
|
|
{ type: "value", value: "100", text: "RawHit" },
|
|
{ type: "value", value: "100.00", text: "RoundedHit" },
|
|
],
|
|
});
|
|
expect(r.text).toBe("RawHit");
|
|
});
|
|
});
|
|
|
|
describe("backward compatibility (legacy mappings)", () => {
|
|
it("caches and matches a legacy value+color mapping (old requireField 'color')", () => {
|
|
const cache = buildValueMappingCache([
|
|
{ type: "value", value: "1", text: "Up", color: "#16a34a" },
|
|
]);
|
|
expect(cache).not.toBeNull();
|
|
expect(lookupValueMapping(1, cache)).toBe("Up");
|
|
// The pre-existing call shape (requireField: "color") still resolves.
|
|
expect(lookupValueMappingFull(1, cache, "color")?.color).toBe("#16a34a");
|
|
});
|
|
|
|
it("still matches legacy range and regex mappings", () => {
|
|
const range = buildValueMappingCache([{ type: "range", from: "0", to: "10", text: "Low" }]);
|
|
expect(lookupValueMapping(5, range)).toBe("Low");
|
|
const regex = buildValueMappingCache([{ type: "regex", pattern: "/err/i", text: "Error" }]);
|
|
expect(lookupValueMapping("server error", regex)).toBe("Error");
|
|
});
|
|
|
|
it("resolveMetricValueStyle with no mappings reproduces legacy metric output", () => {
|
|
// Old metric: unit-formatted value, background = panel background, no explicit
|
|
// text color (caller applies auto-contrast).
|
|
const r = resolveMetricValueStyle(7, {
|
|
mappings: undefined,
|
|
unit: "",
|
|
customUnit: "",
|
|
decimals: 2,
|
|
panelBackground: "#123456",
|
|
});
|
|
expect(r.text).toContain("7");
|
|
expect(r.textColor).toBeUndefined();
|
|
expect(r.bgColor).toBe("#123456");
|
|
});
|
|
|
|
it("resolveMetricValueStyle applies a legacy color mapping as background, no text color", () => {
|
|
const r = resolveMetricValueStyle(1, {
|
|
mappings: [{ type: "value", value: "1", text: "Up", color: "#16a34a" }],
|
|
unit: "",
|
|
customUnit: "",
|
|
decimals: 2,
|
|
panelBackground: "",
|
|
});
|
|
expect(r.text).toBe("Up");
|
|
expect(r.bgColor).toBe("#16a34a");
|
|
expect(r.textColor).toBeUndefined();
|
|
});
|
|
|
|
it("ignores a mapping that has neither text nor any color (unchanged)", () => {
|
|
expect(buildValueMappingCache([{ type: "value", value: "5" }])).toBeNull();
|
|
});
|
|
});
|
|
});
|