Skip to content

Commit 548a270

Browse files
committed
v0.3.3: add unit tests for core calculation logic
1 parent c8f9602 commit 548a270

2 files changed

Lines changed: 184 additions & 70 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@adametherzlab/tank-level",
3-
"version": "0.3.2",
3+
"version": "0.3.3",
44
"description": "Tank level calculator — volume from height for cylindrical, rectangular, conical & spherical vessels with strapping tables, fill rate, alarms & inventory",
55
"type": "module",
66
"main": "src/index.ts",

tests/index.test.ts

Lines changed: 183 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ describe("strappingTable", () => {
162162
});
163163

164164
it("should throw for steps < 1", () => {
165-
expect(() => strappingTable(cylinder, { steps: 0 })).toThrow("Steps must be at least 1");
165+
expect(() => strappingTable(cylinder, { steps: 0 })).toThrow("Steps must be between 1 and 10000");
166166
});
167167

168168
it("should work for spherical vessels", () => {
@@ -175,6 +175,15 @@ describe("strappingTable", () => {
175175
expect(table[0].percentage).toBe(0);
176176
expect(table[10].percentage).toBeCloseTo(100, 1);
177177
});
178+
179+
it("should handle max steps", () => {
180+
const table = strappingTable(cylinder, { steps: 10000 });
181+
expect(table.length).toBe(10001);
182+
});
183+
184+
it("should throw for steps > 10000", () => {
185+
expect(() => strappingTable(cylinder, { steps: 10001 })).toThrow("Steps must be between 1 and 10000");
186+
});
178187
});
179188

180189
describe("fillRate", () => {
@@ -194,110 +203,187 @@ describe("fillRate", () => {
194203
expect(result.volumeChange).toBeGreaterThan(0);
195204
expect(result.ratePerMinute).toBeGreaterThan(0);
196205
expect(result.ratePerHour).toBeCloseTo(result.ratePerMinute * 60, 2);
206+
expect(result.percentBefore).toBe(20);
207+
expect(result.percentAfter).toBe(40);
197208
});
198209

199210
it("should detect draining", () => {
200211
const result = fillRate({
201212
vessel: tank,
202213
heightBefore: 8,
203-
heightAfter: 5,
214+
heightAfter: 3,
204215
timeMinutes: 30,
205216
});
206217
expect(result.direction).toBe("draining");
207218
expect(result.volumeChange).toBeLessThan(0);
208-
expect(result.minutesToEmpty).not.toBeNull();
219+
expect(result.ratePerMinute).toBeLessThan(0);
220+
expect(result.ratePerHour).toBeCloseTo(result.ratePerMinute * 60, 2);
221+
expect(result.percentBefore).toBe(80);
222+
expect(result.percentAfter).toBe(30);
209223
});
210224

211-
it("should detect stable level", () => {
225+
it("should detect stable", () => {
212226
const result = fillRate({
213227
vessel: tank,
214228
heightBefore: 5,
215229
heightAfter: 5,
216-
timeMinutes: 60,
230+
timeMinutes: 10,
217231
});
218232
expect(result.direction).toBe("stable");
219233
expect(result.volumeChange).toBe(0);
234+
expect(result.ratePerMinute).toBe(0);
235+
expect(result.ratePerHour).toBe(0);
236+
expect(result.minutesToFull).toBeNull();
237+
expect(result.minutesToEmpty).toBeNull();
220238
});
221239

222-
it("should calculate ETA to full", () => {
240+
it("should calculate minutes to full for filling tank", () => {
223241
const result = fillRate({
224242
vessel: tank,
225-
heightBefore: 0,
243+
heightBefore: 4,
226244
heightAfter: 5,
227-
timeMinutes: 60,
245+
timeMinutes: 10,
228246
});
229-
expect(result.direction).toBe("filling");
230-
expect(result.minutesToFull).not.toBeNull();
231-
// At this rate (5m in 60min), 5 more meters needed => 60 more min
232-
expect(result.minutesToFull).toBeCloseTo(60, 0);
247+
// 1m height change in 10 min. Total height 10m. Remaining 5m. So 50 min.
248+
expect(result.minutesToFull).toBeCloseTo(50, 2);
249+
expect(result.minutesToEmpty).toBeCloseTo(50, 2); // 5m to drain at 1m/10min
233250
});
234251

235-
it("should throw for zero time", () => {
252+
it("should calculate minutes to empty for draining tank", () => {
253+
const result = fillRate({
254+
vessel: tank,
255+
heightBefore: 7,
256+
heightAfter: 6,
257+
timeMinutes: 5,
258+
});
259+
// 1m height change in 5 min. Remaining 6m. So 30 min.
260+
expect(result.minutesToEmpty).toBeCloseTo(30, 2);
261+
expect(result.minutesToFull).toBeCloseTo(20, 2); // 4m to fill at 1m/5min
262+
});
263+
264+
it("should handle zero timeMinutes", () => {
236265
expect(() => fillRate({
237266
vessel: tank,
238267
heightBefore: 2,
239268
heightAfter: 4,
240269
timeMinutes: 0,
241-
})).toThrow("Time must be positive");
270+
})).toThrow("timeMinutes must be a positive number");
242271
});
243272

244-
it("should report before/after percentages", () => {
245-
const result = fillRate({
273+
it("should handle negative timeMinutes", () => {
274+
expect(() => fillRate({
246275
vessel: tank,
247276
heightBefore: 2,
248-
heightAfter: 8,
249-
timeMinutes: 60,
277+
heightAfter: 4,
278+
timeMinutes: -10,
279+
})).toThrow("timeMinutes must be a positive number");
280+
});
281+
282+
it("should handle invalid heightBefore/After", () => {
283+
expect(() => fillRate({
284+
vessel: tank,
285+
heightBefore: -1,
286+
heightAfter: 2,
287+
timeMinutes: 10,
288+
})).toThrow("Liquid height cannot be negative");
289+
290+
expect(() => fillRate({
291+
vessel: tank,
292+
heightBefore: 2,
293+
heightAfter: 11,
294+
timeMinutes: 10,
295+
})).toThrow("Liquid height cannot exceed vessel height");
296+
});
297+
298+
it("should work with different vessel types (e.g., cylindrical)", () => {
299+
const cylinder: VesselConfig = {
300+
type: "cylindrical",
301+
dimensions: { diameter: 2, height: 10 },
302+
orientation: "vertical",
303+
};
304+
const result = fillRate({
305+
vessel: cylinder,
306+
heightBefore: 4,
307+
heightAfter: 6,
308+
timeMinutes: 20,
250309
});
251-
expect(result.percentBefore).toBeCloseTo(20, 1);
252-
expect(result.percentAfter).toBeCloseTo(80, 1);
310+
expect(result.direction).toBe("filling");
311+
expect(result.volumeChange).toBeCloseTo(6.283, 2); // pi * 1^2 * (6-4)
312+
expect(result.ratePerMinute).toBeCloseTo(0.314, 2);
313+
expect(result.minutesToFull).toBeCloseTo(40, 2); // 4m remaining, 0.1m/min rate
253314
});
254315
});
255316

256317
describe("tankAlarms", () => {
257-
const tank: VesselConfig = {
258-
type: "rectangular",
259-
dimensions: { length: 10, width: 5, height: 10 },
318+
const config = {
319+
highHigh: 95,
320+
high: 90,
321+
low: 10,
322+
lowLow: 5,
260323
};
261-
const alarms = { highHigh: 95, high: 80, low: 20, lowLow: 5 };
262324

263-
it("should return normal for mid-range level", () => {
264-
const result = tankAlarms(tank, 5, alarms);
325+
it("should be normal when within bounds", () => {
326+
const result = tankAlarms(config, 50);
265327
expect(result.status).toBe("normal");
266328
expect(result.isAlarmed).toBe(false);
267-
expect(result.activeAlarms.length).toBe(0);
329+
expect(result.activeAlarms).toEqual([]);
268330
});
269331

270332
it("should detect high alarm", () => {
271-
const result = tankAlarms(tank, 8.5, alarms);
333+
const result = tankAlarms(config, 92);
272334
expect(result.status).toBe("high");
273335
expect(result.isAlarmed).toBe(true);
274-
expect(result.activeAlarms).toContain("high");
336+
expect(result.activeAlarms).toEqual(["high"]);
275337
});
276338

277339
it("should detect high-high alarm", () => {
278-
const result = tankAlarms(tank, 9.8, alarms);
340+
const result = tankAlarms(config, 98);
279341
expect(result.status).toBe("high-high");
280342
expect(result.isAlarmed).toBe(true);
281-
expect(result.activeAlarms).toContain("high-high");
282-
expect(result.activeAlarms).toContain("high");
343+
expect(result.activeAlarms).toEqual(["high", "high-high"]);
283344
});
284345

285346
it("should detect low alarm", () => {
286-
const result = tankAlarms(tank, 1.5, alarms);
347+
const result = tankAlarms(config, 8);
287348
expect(result.status).toBe("low");
288349
expect(result.isAlarmed).toBe(true);
350+
expect(result.activeAlarms).toEqual(["low"]);
289351
});
290352

291353
it("should detect low-low alarm", () => {
292-
const result = tankAlarms(tank, 0.3, alarms);
354+
const result = tankAlarms(config, 3);
293355
expect(result.status).toBe("low-low");
294-
expect(result.activeAlarms).toContain("low-low");
295-
expect(result.activeAlarms).toContain("low");
356+
expect(result.isAlarmed).toBe(true);
357+
expect(result.activeAlarms).toEqual(["low", "low-low"]);
296358
});
297359

298-
it("should report percentage", () => {
299-
const result = tankAlarms(tank, 5, alarms);
300-
expect(result.percentage).toBeCloseTo(50, 1);
360+
it("should handle exact boundary values", () => {
361+
expect(tankAlarms(config, 90).status).toBe("high");
362+
expect(tankAlarms(config, 95).status).toBe("high-high");
363+
expect(tankAlarms(config, 10).status).toBe("low");
364+
expect(tankAlarms(config, 5).status).toBe("low-low");
365+
});
366+
367+
it("should handle partial alarm configurations", () => {
368+
const partialConfig = { high: 80 };
369+
expect(tankAlarms(partialConfig, 85).status).toBe("high");
370+
expect(tankAlarms(partialConfig, 70).status).toBe("normal");
371+
});
372+
373+
it("should prioritize higher severity alarms", () => {
374+
const result = tankAlarms(config, 96);
375+
expect(result.status).toBe("high-high");
376+
});
377+
378+
it("should handle empty config", () => {
379+
const result = tankAlarms({}, 50);
380+
expect(result.status).toBe("normal");
381+
expect(result.isAlarmed).toBe(false);
382+
});
383+
384+
it("should throw for invalid percentage", () => {
385+
expect(() => tankAlarms(config, -1)).toThrow("Percentage must be between 0 and 100");
386+
expect(() => tankAlarms(config, 101)).toThrow("Percentage must be between 0 and 100");
301387
});
302388
});
303389

@@ -312,46 +398,74 @@ describe("tankInventory", () => {
312398
orientation: "vertical",
313399
};
314400

315-
it("should calculate inventory across multiple tanks", () => {
316-
const result = tankInventory([
317-
{ name: "Tank A", vessel: tank1, liquidHeight: 5 },
318-
{ name: "Tank B", vessel: tank2, liquidHeight: 5 },
319-
]);
320-
expect(result.count).toBe(2);
321-
expect(result.tanks.length).toBe(2);
401+
const readings = [
402+
{ name: "Tank A", vessel: tank1, liquidHeight: 5, unit: "liters" },
403+
{ name: "Tank B", vessel: tank2, liquidHeight: 5, unit: "liters" },
404+
{ name: "Tank C", vessel: tank1, liquidHeight: 2, unit: "cubicMeters" },
405+
];
406+
407+
it("should calculate total inventory correctly", () => {
408+
const result = tankInventory(readings);
409+
expect(result.count).toBe(3);
410+
expect(result.unit).toBe("cubicMeters"); // Default unit is cubicMeters
411+
412+
// Tank A: 10*5*5 = 250 m3
413+
// Tank B: pi * 1^2 * 5 = 15.708 m3
414+
// Tank C: 10*5*2 = 100 m3
415+
expect(result.totalVolume).toBeCloseTo(250 + 15.708 + 100, 2);
416+
417+
// Tank A: 50%
418+
// Tank B: 50%
419+
// Tank C: 20%
420+
expect(result.averagePercentage).toBeCloseTo((50 + 50 + 20) / 3, 2);
421+
322422
expect(result.tanks[0].name).toBe("Tank A");
423+
expect(result.tanks[0].volume).toBeCloseTo(250, 2);
424+
expect(result.tanks[0].percentage).toBe(50);
425+
expect(result.tanks[0].unit).toBe("cubicMeters");
426+
323427
expect(result.tanks[1].name).toBe("Tank B");
324-
expect(result.totalVolume).toBeGreaterThan(0);
325-
});
428+
expect(result.tanks[1].volume).toBeCloseTo(15.708, 2);
429+
expect(result.tanks[1].percentage).toBe(50);
430+
expect(result.tanks[1].unit).toBe("cubicMeters");
326431

327-
it("should calculate average percentage", () => {
328-
const result = tankInventory([
329-
{ name: "Tank A", vessel: tank1, liquidHeight: 5 }, // 50%
330-
{ name: "Tank B", vessel: tank2, liquidHeight: 5 }, // 50%
331-
]);
332-
expect(result.averagePercentage).toBeCloseTo(50, 1);
432+
expect(result.tanks[2].name).toBe("Tank C");
433+
expect(result.tanks[2].volume).toBeCloseTo(100, 2);
434+
expect(result.tanks[2].percentage).toBe(20);
435+
expect(result.tanks[2].unit).toBe("cubicMeters");
333436
});
334437

335-
it("should convert to specified unit", () => {
336-
const result = tankInventory([
337-
{ name: "Tank A", vessel: tank1, liquidHeight: 10 },
338-
], "liters");
339-
expect(result.unit).toBe("liters");
340-
// 10 * 5 * 10 = 500 m3 = 500000 liters
341-
expect(result.totalVolume).toBeGreaterThan(100);
438+
it("should allow specifying a target unit", () => {
439+
const result = tankInventory(readings, { targetUnit: "gallons" });
440+
expect(result.unit).toBe("gallons");
441+
442+
// 250 m3 = 66043 gallons
443+
// 15.708 m3 = 4150 gallons
444+
// 100 m3 = 26417 gallons
445+
const expectedTotalGallons = convertVolume(250 + 15.708 + 100, "cubicMeters", "gallons");
446+
expect(result.totalVolume).toBeCloseTo(expectedTotalGallons, 0);
447+
448+
expect(result.tanks[0].unit).toBe("gallons");
449+
expect(result.tanks[0].volume).toBeCloseTo(convertVolume(250, "cubicMeters", "gallons"), 0);
342450
});
343451

344-
it("should handle empty array", () => {
452+
it("should handle empty readings array", () => {
345453
const result = tankInventory([]);
346454
expect(result.count).toBe(0);
347455
expect(result.totalVolume).toBe(0);
348456
expect(result.averagePercentage).toBe(0);
349-
});
350-
351-
it("should include per-tank percentage", () => {
352-
const result = tankInventory([
353-
{ name: "Tank A", vessel: tank1, liquidHeight: 2 },
354-
]);
355-
expect(result.tanks[0].percentage).toBeCloseTo(20, 1);
457+
expect(result.tanks).toEqual([]);
458+
});
459+
460+
it("should handle readings with invalid liquid heights", () => {
461+
const invalidReadings = [
462+
{ name: "Tank A", vessel: tank1, liquidHeight: -1, unit: "liters" },
463+
{ name: "Tank B", vessel: tank2, liquidHeight: 5, unit: "liters" },
464+
];
465+
// Expect the invalid tank to be skipped or result in 0 volume/percentage
466+
const result = tankInventory(invalidReadings);
467+
expect(result.count).toBe(1); // Only Tank B is valid
468+
expect(result.tanks[0].name).toBe("Tank B");
469+
expect(result.tanks[0].volume).toBeCloseTo(15.708, 2);
356470
});
357471
});

0 commit comments

Comments
 (0)