Files
openobserve/web/src/utils/dashboard/tableConfigUtils.spec.ts
T
Ashish Kolheandgithub-actions[bot] 46678ea8da fix: tighten input validation, auth and outbound request checks (#15054)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-08 00:54:05 +00:00

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();
});
});
});