@oneuptime/common 11.6.2 → 11.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/Models/AnalyticsModels/SloHistory.ts +6 -3
  2. package/Models/DatabaseModels/ServiceLevelObjective.ts +9 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.ts +30 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/ServiceLevelObjectiveService.ts +12 -4
  6. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +2 -2
  7. package/Tests/Types/DateUserTimezone.test.ts +205 -0
  8. package/Tests/Types/DateUserTimezoneOverridesBrowser.test.ts +73 -0
  9. package/Tests/Types/Monitor/MonitorCriteria.test.ts +155 -0
  10. package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +465 -0
  11. package/Tests/Types/Monitor/MonitorStep.test.ts +183 -0
  12. package/Tests/Types/Monitor/MonitorSteps.test.ts +183 -0
  13. package/Tests/UI/Components/TimePicker/TimePicker.test.tsx +11 -0
  14. package/Tests/Utils/Memory.test.ts +44 -0
  15. package/Tests/Utils/Slo/SloBurnRateRuleState.test.ts +124 -0
  16. package/Tests/Utils/Slo/SloDuration.test.ts +148 -0
  17. package/Tests/Utils/Slo/SloHealth.test.ts +334 -0
  18. package/Types/Date.ts +119 -50
  19. package/UI/Components/Forms/Fields/FormField.tsx +13 -3
  20. package/UI/Components/Input/Input.tsx +6 -1
  21. package/UI/Components/TimePicker/TimePicker.tsx +8 -4
  22. package/UI/Utils/User.ts +19 -0
  23. package/Utils/Slo/SloBurnRateRuleState.ts +91 -0
  24. package/Utils/Slo/SloDuration.ts +165 -0
  25. package/Utils/Slo/SloEvaluation.ts +22 -0
  26. package/Utils/Slo/SloHealth.ts +302 -0
  27. package/build/dist/Models/AnalyticsModels/SloHistory.js +6 -3
  28. package/build/dist/Models/AnalyticsModels/SloHistory.js.map +1 -1
  29. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +11 -1
  30. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  31. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js +21 -0
  32. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785066759532-MigrationName.js.map +1 -0
  33. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  34. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  35. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +10 -4
  36. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  37. package/build/dist/Types/Date.js +106 -35
  38. package/build/dist/Types/Date.js.map +1 -1
  39. package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -3
  40. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  41. package/build/dist/UI/Components/Input/Input.js +6 -1
  42. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  43. package/build/dist/UI/Components/TimePicker/TimePicker.js +4 -4
  44. package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
  45. package/build/dist/UI/Utils/User.js +19 -0
  46. package/build/dist/UI/Utils/User.js.map +1 -1
  47. package/build/dist/Utils/Slo/SloBurnRateRuleState.js +35 -0
  48. package/build/dist/Utils/Slo/SloBurnRateRuleState.js.map +1 -0
  49. package/build/dist/Utils/Slo/SloDuration.js +87 -0
  50. package/build/dist/Utils/Slo/SloDuration.js.map +1 -0
  51. package/build/dist/Utils/Slo/SloEvaluation.js +21 -0
  52. package/build/dist/Utils/Slo/SloEvaluation.js.map +1 -0
  53. package/build/dist/Utils/Slo/SloHealth.js +179 -0
  54. package/build/dist/Utils/Slo/SloHealth.js.map +1 -0
  55. package/package.json +1 -1
  56. package/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.ts +0 -79
  57. package/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.ts +0 -41
  58. package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +0 -17
  59. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js +0 -34
  60. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js.map +0 -1
  61. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js +0 -22
  62. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js.map +0 -1
  63. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +0 -12
  64. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +0 -1
@@ -0,0 +1,183 @@
1
+ import MonitorSteps from "../../../Types/Monitor/MonitorSteps";
2
+ import MonitorStep from "../../../Types/Monitor/MonitorStep";
3
+ import MonitorType from "../../../Types/Monitor/MonitorType";
4
+ import ObjectID from "../../../Types/ObjectID";
5
+ import { JSONObject, ObjectType } from "../../../Types/JSON";
6
+ import BadDataException from "../../../Types/Exception/BadDataException";
7
+
8
+ /*
9
+ * MonitorSteps is the top-level DatabaseProperty stored on a Monitor: an array
10
+ * of MonitorStep plus the default monitor status. These tests lock in the
11
+ * factory shape, toJSON/fromJSON round-trip (including the deliberately lenient
12
+ * fromJSON(undefined) -> empty instance), and the validation contract.
13
+ */
14
+
15
+ const buildDefaultSteps: () => MonitorSteps = (): MonitorSteps => {
16
+ return MonitorSteps.getDefaultMonitorSteps({
17
+ defaultMonitorStatusId: new ObjectID("100000000000000000000001"),
18
+ monitorType: MonitorType.Manual,
19
+ monitorName: "My Monitor",
20
+ onlineMonitorStatusId: new ObjectID("100000000000000000000002"),
21
+ offlineMonitorStatusId: new ObjectID("100000000000000000000003"),
22
+ defaultIncidentSeverityId: new ObjectID("100000000000000000000004"),
23
+ defaultAlertSeverityId: new ObjectID("100000000000000000000005"),
24
+ });
25
+ };
26
+
27
+ describe("MonitorSteps", () => {
28
+ describe("getNewMonitorStepsAsJSON", () => {
29
+ test("wraps a single default step under the MonitorSteps type", () => {
30
+ const json: JSONObject = MonitorSteps.getNewMonitorStepsAsJSON();
31
+ expect(json["_type"]).toBe(ObjectType.MonitorSteps);
32
+ const value: JSONObject = json["value"] as JSONObject;
33
+ expect(Array.isArray(value["monitorStepsInstanceArray"])).toBe(true);
34
+ expect(
35
+ (value["monitorStepsInstanceArray"] as Array<unknown>).length,
36
+ ).toBe(1);
37
+ expect(value["defaultMonitorStatusId"]).toBeUndefined();
38
+ });
39
+ });
40
+
41
+ describe("getDefaultMonitorSteps", () => {
42
+ test("creates one step and carries the default monitor status id", () => {
43
+ const steps: MonitorSteps = buildDefaultSteps();
44
+ expect(steps.data?.monitorStepsInstanceArray).toHaveLength(1);
45
+ expect(steps.data?.monitorStepsInstanceArray[0]).toBeInstanceOf(
46
+ MonitorStep,
47
+ );
48
+ expect(steps.data?.defaultMonitorStatusId?.toString()).toBe(
49
+ "100000000000000000000001",
50
+ );
51
+ });
52
+ });
53
+
54
+ describe("toJSON / fromJSON round-trip", () => {
55
+ test("serializes with the MonitorSteps type wrapper", () => {
56
+ const steps: MonitorSteps = buildDefaultSteps();
57
+ const json: JSONObject = steps.toJSON();
58
+ expect(json["_type"]).toBe(ObjectType.MonitorSteps);
59
+ expect((json["value"] as JSONObject)["defaultMonitorStatusId"]).toBe(
60
+ "100000000000000000000001",
61
+ );
62
+ });
63
+
64
+ test("round-trips the default status id and step count", () => {
65
+ const steps: MonitorSteps = buildDefaultSteps();
66
+ const restored: MonitorSteps = MonitorSteps.fromJSON(steps.toJSON());
67
+ expect(restored.data?.defaultMonitorStatusId?.toString()).toBe(
68
+ "100000000000000000000001",
69
+ );
70
+ expect(restored.data?.monitorStepsInstanceArray).toHaveLength(1);
71
+ expect(restored.data?.monitorStepsInstanceArray[0]).toBeInstanceOf(
72
+ MonitorStep,
73
+ );
74
+ });
75
+
76
+ test("toString returns a JSON string of the serialized value", () => {
77
+ const steps: MonitorSteps = buildDefaultSteps();
78
+ const asString: string = steps.toString();
79
+ const parsed: JSONObject = JSON.parse(asString) as JSONObject;
80
+ expect(parsed["_type"]).toBe(ObjectType.MonitorSteps);
81
+ });
82
+ });
83
+
84
+ describe("fromJSON leniency and validation", () => {
85
+ test("returns an empty instance for undefined input", () => {
86
+ const steps: MonitorSteps = MonitorSteps.fromJSON(undefined);
87
+ expect(steps).toBeInstanceOf(MonitorSteps);
88
+ });
89
+
90
+ test("returns the same instance when given a MonitorSteps", () => {
91
+ const steps: MonitorSteps = buildDefaultSteps();
92
+ expect(MonitorSteps.fromJSON(steps)).toBe(steps);
93
+ });
94
+
95
+ test("throws on a wrong _type", () => {
96
+ expect(() => {
97
+ return MonitorSteps.fromJSON({ _type: "Nope", value: {} });
98
+ }).toThrow(BadDataException);
99
+ });
100
+
101
+ test("throws when value is missing", () => {
102
+ expect(() => {
103
+ return MonitorSteps.fromJSON({ _type: "MonitorSteps" });
104
+ }).toThrow(BadDataException);
105
+ });
106
+
107
+ test("throws when monitorStepsInstanceArray is missing", () => {
108
+ expect(() => {
109
+ return MonitorSteps.fromJSON({
110
+ _type: "MonitorSteps",
111
+ value: { defaultMonitorStatusId: undefined },
112
+ });
113
+ }).toThrow(BadDataException);
114
+ });
115
+ });
116
+
117
+ describe("getValidationError", () => {
118
+ test("errors when data is missing", () => {
119
+ const steps: MonitorSteps = new MonitorSteps();
120
+ steps.data = undefined;
121
+ expect(MonitorSteps.getValidationError(steps, MonitorType.Manual)).toBe(
122
+ "Monitor Steps is required",
123
+ );
124
+ });
125
+
126
+ test("errors when the step array is empty", () => {
127
+ const steps: MonitorSteps = new MonitorSteps();
128
+ steps.data = {
129
+ monitorStepsInstanceArray: [],
130
+ defaultMonitorStatusId: new ObjectID("100000000000000000000001"),
131
+ };
132
+ expect(MonitorSteps.getValidationError(steps, MonitorType.Manual)).toBe(
133
+ "Monitor Steps is required",
134
+ );
135
+ });
136
+
137
+ test("errors when the default monitor status id is missing", () => {
138
+ const steps: MonitorSteps = buildDefaultSteps();
139
+ steps.data!.defaultMonitorStatusId = undefined;
140
+ expect(MonitorSteps.getValidationError(steps, MonitorType.Manual)).toBe(
141
+ "Default Monitor Status is required",
142
+ );
143
+ });
144
+ });
145
+
146
+ describe("clone and setters", () => {
147
+ test("clone produces an independent copy", () => {
148
+ const steps: MonitorSteps = buildDefaultSteps();
149
+ const cloned: MonitorSteps = MonitorSteps.clone(steps);
150
+ expect(cloned).not.toBe(steps);
151
+ expect(cloned.data?.defaultMonitorStatusId?.toString()).toBe(
152
+ steps.data?.defaultMonitorStatusId?.toString(),
153
+ );
154
+
155
+ cloned.setDefaultMonitorStatusId(
156
+ new ObjectID("200000000000000000000009"),
157
+ );
158
+ // Original must be unaffected by the clone's mutation.
159
+ expect(steps.data?.defaultMonitorStatusId?.toString()).toBe(
160
+ "100000000000000000000001",
161
+ );
162
+ });
163
+
164
+ test("setDefaultMonitorStatusId mutates and returns this", () => {
165
+ const steps: MonitorSteps = new MonitorSteps();
166
+ const returned: MonitorSteps = steps.setDefaultMonitorStatusId(
167
+ new ObjectID("300000000000000000000000"),
168
+ );
169
+ expect(returned).toBe(steps);
170
+ expect(steps.data?.defaultMonitorStatusId?.toString()).toBe(
171
+ "300000000000000000000000",
172
+ );
173
+ });
174
+
175
+ test("setMonitorStepsInstanceArray replaces the step list", () => {
176
+ const steps: MonitorSteps = buildDefaultSteps();
177
+ const newStep: MonitorStep = new MonitorStep();
178
+ steps.setMonitorStepsInstanceArray([newStep]);
179
+ expect(steps.data?.monitorStepsInstanceArray).toHaveLength(1);
180
+ expect(steps.data?.monitorStepsInstanceArray[0]).toBe(newStep);
181
+ });
182
+ });
183
+ });
@@ -89,6 +89,17 @@ jest.mock("../../../../Types/Date", () => {
89
89
  return base;
90
90
  },
91
91
  ),
92
+ /*
93
+ * The picker reads the wall-clock through OneUptimeDate so it resolves in
94
+ * the user's configured timezone; the stubbed dates above already carry
95
+ * the hour/minute the test intends.
96
+ */
97
+ getLocalHours: jest.fn((d: HourMinuteMock) => {
98
+ return d.getHours();
99
+ }),
100
+ getLocalMinutes: jest.fn((d: HourMinuteMock) => {
101
+ return d.getMinutes();
102
+ }),
92
103
  getCurrentTimezoneString: jest.fn(() => {
93
104
  return "UTC";
94
105
  }),
@@ -0,0 +1,44 @@
1
+ import MemoryUtil from "../../Utils/Memory";
2
+
3
+ /*
4
+ * MemoryUtil.convertToGb turns a raw byte count into gibibytes (bytes / 1024^3)
5
+ * rounded to two decimal places. These tests pin the unit (GiB, not GB), the
6
+ * rounding, and the boundary/edge behaviour.
7
+ */
8
+ describe("MemoryUtil.convertToGb", () => {
9
+ const GIB: number = 1024 * 1024 * 1024;
10
+
11
+ test("converts exactly one GiB to 1", () => {
12
+ expect(MemoryUtil.convertToGb(GIB)).toBe(1);
13
+ });
14
+
15
+ test("converts whole GiB multiples", () => {
16
+ expect(MemoryUtil.convertToGb(4 * GIB)).toBe(4);
17
+ expect(MemoryUtil.convertToGb(16 * GIB)).toBe(16);
18
+ });
19
+
20
+ test("returns 0 for 0 bytes", () => {
21
+ expect(MemoryUtil.convertToGb(0)).toBe(0);
22
+ });
23
+
24
+ test("rounds to two decimal places", () => {
25
+ // 1.5 GiB -> 1.5
26
+ expect(MemoryUtil.convertToGb(1.5 * GIB)).toBe(1.5);
27
+ // 1.234 GiB rounds to 1.23
28
+ expect(MemoryUtil.convertToGb(1.234 * GIB)).toBe(1.23);
29
+ // 1.235 GiB rounds up to 1.24 (round-half-up)
30
+ expect(MemoryUtil.convertToGb(1.235 * GIB)).toBe(1.24);
31
+ });
32
+
33
+ test("handles sub-GiB values", () => {
34
+ // 512 MiB is half a GiB.
35
+ expect(MemoryUtil.convertToGb(512 * 1024 * 1024)).toBe(0.5);
36
+ // A single byte rounds down to 0.
37
+ expect(MemoryUtil.convertToGb(1)).toBe(0);
38
+ });
39
+
40
+ test("uses GiB (1024^3), not GB (1000^3)", () => {
41
+ // One billion bytes is < 1 GiB, so it must not round to 1.
42
+ expect(MemoryUtil.convertToGb(1_000_000_000)).toBe(0.93);
43
+ });
44
+ });
@@ -0,0 +1,124 @@
1
+ import SloStatus from "../../../Types/ServiceLevelObjective/SloStatus";
2
+ import {
3
+ canSloFireBurnRateAlerts,
4
+ isBurnRateRuleFiring,
5
+ } from "../../../Utils/Slo/SloBurnRateRuleState";
6
+
7
+ const FIRED_AT: Date = new Date("2026-07-25T10:00:00.000Z");
8
+
9
+ describe("SloBurnRateRuleState", () => {
10
+ describe("isBurnRateRuleFiring", () => {
11
+ it("is false for a rule that has never fired", () => {
12
+ expect(isBurnRateRuleFiring({})).toBe(false);
13
+ expect(
14
+ isBurnRateRuleFiring({
15
+ lastAlertCreatedAt: null,
16
+ lastAlertResolvedAt: null,
17
+ }),
18
+ ).toBe(false);
19
+ });
20
+
21
+ it("is true when a fire has never been resolved", () => {
22
+ expect(isBurnRateRuleFiring({ lastAlertCreatedAt: FIRED_AT })).toBe(true);
23
+ });
24
+
25
+ it("is false when the resolve is newer than the fire", () => {
26
+ expect(
27
+ isBurnRateRuleFiring({
28
+ lastAlertCreatedAt: FIRED_AT,
29
+ lastAlertResolvedAt: new Date("2026-07-25T10:30:00.000Z"),
30
+ }),
31
+ ).toBe(false);
32
+ });
33
+
34
+ it("is true when the rule re-fired after an older resolve", () => {
35
+ expect(
36
+ isBurnRateRuleFiring({
37
+ lastAlertCreatedAt: FIRED_AT,
38
+ lastAlertResolvedAt: new Date("2026-07-25T09:00:00.000Z"),
39
+ }),
40
+ ).toBe(true);
41
+ });
42
+
43
+ it("treats an equal-timestamp pair as resolved, not firing", () => {
44
+ /*
45
+ * The worker always writes the resolve strictly after the fire, so
46
+ * an equal pair can only be one lifecycle — leaving a red "Firing"
47
+ * pill on a rule with nothing open would be the worse error.
48
+ */
49
+ expect(
50
+ isBurnRateRuleFiring({
51
+ lastAlertCreatedAt: FIRED_AT,
52
+ lastAlertResolvedAt: new Date(FIRED_AT.getTime()),
53
+ }),
54
+ ).toBe(false);
55
+ });
56
+
57
+ it("is false when only a resolve is recorded", () => {
58
+ // Defensive: a resolve with no fire is not a firing state.
59
+ expect(isBurnRateRuleFiring({ lastAlertResolvedAt: FIRED_AT })).toBe(
60
+ false,
61
+ );
62
+ });
63
+ });
64
+
65
+ describe("canSloFireBurnRateAlerts", () => {
66
+ it("is true for a healthy, enabled SLO", () => {
67
+ expect(
68
+ canSloFireBurnRateAlerts({
69
+ isEnabled: true,
70
+ sloStatus: SloStatus.Healthy,
71
+ }),
72
+ ).toBe(true);
73
+ });
74
+
75
+ it("is true while the budget is at risk or exhausted — that is when rules fire", () => {
76
+ expect(
77
+ canSloFireBurnRateAlerts({
78
+ isEnabled: true,
79
+ sloStatus: SloStatus.AtRisk,
80
+ }),
81
+ ).toBe(true);
82
+ expect(
83
+ canSloFireBurnRateAlerts({
84
+ isEnabled: true,
85
+ sloStatus: SloStatus.BudgetExhausted,
86
+ }),
87
+ ).toBe(true);
88
+ });
89
+
90
+ it("is false for a disabled SLO", () => {
91
+ /*
92
+ * Disabling resolves every open alert without stamping the rule's
93
+ * lastAlertResolvedAt, so the rule columns alone would still read as
94
+ * firing.
95
+ */
96
+ expect(
97
+ canSloFireBurnRateAlerts({
98
+ isEnabled: false,
99
+ sloStatus: SloStatus.AtRisk,
100
+ }),
101
+ ).toBe(false);
102
+ });
103
+
104
+ it("is false for the two guard statuses that resolve without stamping", () => {
105
+ expect(
106
+ canSloFireBurnRateAlerts({
107
+ isEnabled: true,
108
+ sloStatus: SloStatus.Misconfigured,
109
+ }),
110
+ ).toBe(false);
111
+ expect(
112
+ canSloFireBurnRateAlerts({
113
+ isEnabled: true,
114
+ sloStatus: SloStatus.Paused,
115
+ }),
116
+ ).toBe(false);
117
+ });
118
+
119
+ it("stays optimistic when the fields were not loaded", () => {
120
+ // Never hide a genuinely firing rule because a fetch has not landed.
121
+ expect(canSloFireBurnRateAlerts({})).toBe(true);
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,148 @@
1
+ import {
2
+ formatDurationCompact,
3
+ formatErrorBudgetRemaining,
4
+ formatErrorBudgetRemainingOfTotal,
5
+ } from "../../../Utils/Slo/SloDuration";
6
+
7
+ const MINUS_SIGN: string = "−";
8
+
9
+ describe("SloDuration", () => {
10
+ describe("formatDurationCompact", () => {
11
+ it("renders seconds under a minute", () => {
12
+ expect(formatDurationCompact(45)).toBe("45s");
13
+ expect(formatDurationCompact(1)).toBe("1s");
14
+ });
15
+
16
+ it("renders zero as 0s rather than an empty string", () => {
17
+ expect(formatDurationCompact(0)).toBe("0s");
18
+ });
19
+
20
+ it("renders minutes and seconds", () => {
21
+ // 99.9% over 30 days — the canonical error budget.
22
+ expect(formatDurationCompact(2592)).toBe("43m 12s");
23
+ });
24
+
25
+ it("drops a zero remainder instead of padding it", () => {
26
+ expect(formatDurationCompact(120)).toBe("2m");
27
+ expect(formatDurationCompact(7200)).toBe("2h");
28
+ expect(formatDurationCompact(172800)).toBe("2d");
29
+ });
30
+
31
+ it("renders hours and minutes", () => {
32
+ // 99% over 90 days.
33
+ expect(formatDurationCompact(77760)).toBe("21h 36m");
34
+ expect(formatDurationCompact(7860)).toBe("2h 11m");
35
+ });
36
+
37
+ it("renders days and hours", () => {
38
+ expect(formatDurationCompact(190800)).toBe("2d 5h");
39
+ });
40
+
41
+ it("shows at most the two most significant units", () => {
42
+ // 2d 5h 13m 7s — the minutes and seconds are dropped.
43
+ expect(formatDurationCompact(191587)).toBe("2d 5h");
44
+ });
45
+
46
+ it("skips leading zero units", () => {
47
+ // 0d 0h 5m 3s.
48
+ expect(formatDurationCompact(303)).toBe("5m 3s");
49
+ });
50
+
51
+ it("skips an interior zero unit and moves on to the next", () => {
52
+ /*
53
+ * Exactly 2 days + 30 seconds: hours and minutes are both zero, so
54
+ * the second slot is filled by seconds rather than padded with "0h".
55
+ */
56
+ expect(formatDurationCompact(172830)).toBe("2d 30s");
57
+ });
58
+
59
+ it("floors fractional seconds instead of rounding up", () => {
60
+ // A 59.9s budget must never read as a full minute it does not have.
61
+ expect(formatDurationCompact(59.9)).toBe("59s");
62
+ expect(formatDurationCompact(0.4)).toBe("0s");
63
+ });
64
+
65
+ it("renders the magnitude of a negative input (sign is the caller's job)", () => {
66
+ expect(formatDurationCompact(-2592)).toBe("43m 12s");
67
+ });
68
+ });
69
+
70
+ describe("formatErrorBudgetRemaining", () => {
71
+ it("labels a positive remainder as left", () => {
72
+ expect(formatErrorBudgetRemaining(2592)).toBe("43m 12s left");
73
+ });
74
+
75
+ it("renders an exhausted-but-not-overspent budget as 0s left", () => {
76
+ expect(formatErrorBudgetRemaining(0)).toBe("0s left");
77
+ });
78
+
79
+ it("renders an overspent budget with a minus sign and over budget", () => {
80
+ expect(formatErrorBudgetRemaining(-750)).toBe(
81
+ `${MINUS_SIGN}12m 30s over budget`,
82
+ );
83
+ });
84
+
85
+ it("returns null when the SLO has not been evaluated", () => {
86
+ expect(formatErrorBudgetRemaining(null)).toBeNull();
87
+ expect(formatErrorBudgetRemaining(undefined)).toBeNull();
88
+ });
89
+
90
+ it("returns null for non-finite values", () => {
91
+ expect(formatErrorBudgetRemaining(Number.NaN)).toBeNull();
92
+ expect(formatErrorBudgetRemaining(Number.POSITIVE_INFINITY)).toBeNull();
93
+ });
94
+
95
+ it("rejects a numeric string rather than coercing it", () => {
96
+ expect(
97
+ formatErrorBudgetRemaining("2592" as unknown as number),
98
+ ).toBeNull();
99
+ });
100
+ });
101
+
102
+ describe("formatErrorBudgetRemainingOfTotal", () => {
103
+ it("renders remaining alongside the total", () => {
104
+ expect(
105
+ formatErrorBudgetRemainingOfTotal({
106
+ remainingSeconds: 750,
107
+ totalSeconds: 2592,
108
+ }),
109
+ ).toBe("12m 30s left of 43m 12s");
110
+ });
111
+
112
+ it("keeps the total visible when the budget is overspent", () => {
113
+ expect(
114
+ formatErrorBudgetRemainingOfTotal({
115
+ remainingSeconds: -750,
116
+ totalSeconds: 2592,
117
+ }),
118
+ ).toBe(`${MINUS_SIGN}12m 30s over budget of 43m 12s`);
119
+ });
120
+
121
+ it("omits the total when it is unknown", () => {
122
+ expect(
123
+ formatErrorBudgetRemainingOfTotal({
124
+ remainingSeconds: 750,
125
+ totalSeconds: null,
126
+ }),
127
+ ).toBe("12m 30s left");
128
+ });
129
+
130
+ it("omits a zero total — a brand-new SLO has no elapsed window yet", () => {
131
+ expect(
132
+ formatErrorBudgetRemainingOfTotal({
133
+ remainingSeconds: 0,
134
+ totalSeconds: 0,
135
+ }),
136
+ ).toBe("0s left");
137
+ });
138
+
139
+ it("returns null when the SLO has not been evaluated", () => {
140
+ expect(
141
+ formatErrorBudgetRemainingOfTotal({
142
+ remainingSeconds: null,
143
+ totalSeconds: 2592,
144
+ }),
145
+ ).toBeNull();
146
+ });
147
+ });
148
+ });