Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,653 @@
|
||||
// SPDX-FileCopyrightText: 2026 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Ajv } from 'ajv';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
OCPP1_6_CALL_RESULT_SCHEMA_RECORD,
|
||||
OCPP1_6_CALL_SCHEMA_RECORD,
|
||||
OCPP2_0_1_CALL_RESULT_SCHEMA_RECORD,
|
||||
OCPP2_0_1_CALL_SCHEMA_RECORD,
|
||||
OCPP_CallAction,
|
||||
OCPPVersion,
|
||||
} from '../../index.js';
|
||||
import { OCPPValidator } from '../../src/interfaces/modules/OCPPValidator.js';
|
||||
|
||||
describe('OCPPValidator', () => {
|
||||
let validator: OCPPValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new OCPPValidator();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create an instance with default ajv and logger', () => {
|
||||
const instance = new OCPPValidator();
|
||||
expect(instance).toBeInstanceOf(OCPPValidator);
|
||||
});
|
||||
|
||||
it('should accept a custom Ajv instance', () => {
|
||||
const customAjv = new Ajv();
|
||||
const instance = new OCPPValidator(undefined, customAjv);
|
||||
expect(instance).toBeInstanceOf(OCPPValidator);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createServerAjvInstance', () => {
|
||||
it('should create a new Ajv instance when none is provided', () => {
|
||||
const ajv = OCPPValidator.createServerAjvInstance();
|
||||
expect(ajv).toBeInstanceOf(Ajv);
|
||||
});
|
||||
|
||||
it('should use the provided Ajv instance', () => {
|
||||
const customAjv = new Ajv({ strict: false });
|
||||
const result = OCPPValidator.createServerAjvInstance(customAjv);
|
||||
expect(result).toBe(customAjv);
|
||||
});
|
||||
|
||||
it('should add OCPP keywords to the instance', () => {
|
||||
const ajv = OCPPValidator.createServerAjvInstance();
|
||||
// Verify schema with custom keywords compiles without error
|
||||
const schema = {
|
||||
type: 'object',
|
||||
comment: 'A test comment',
|
||||
javaType: 'com.example.Test',
|
||||
tsEnumNames: ['A', 'B'],
|
||||
properties: { id: { type: 'string' } },
|
||||
};
|
||||
expect(() => ajv.compile(schema)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createValidatorAjvInstance', () => {
|
||||
it('should create a new Ajv instance when none is provided', () => {
|
||||
const ajv = OCPPValidator.createValidatorAjvInstance();
|
||||
expect(ajv).toBeInstanceOf(Ajv);
|
||||
});
|
||||
|
||||
it('should return the provided Ajv instance unchanged', () => {
|
||||
const customAjv = new Ajv({ strict: false });
|
||||
const result = OCPPValidator.createValidatorAjvInstance(customAjv);
|
||||
expect(result).toBe(customAjv);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOcppKeywords', () => {
|
||||
it('should add comment, javaType, and tsEnumNames keywords', () => {
|
||||
const ajv = new Ajv({ strict: false });
|
||||
OCPPValidator.addOcppKeywords(ajv);
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
comment: 'Test comment',
|
||||
javaType: 'com.test.Type',
|
||||
tsEnumNames: ['Value1'],
|
||||
properties: { name: { type: 'string' } },
|
||||
};
|
||||
|
||||
const validate = ajv.compile(schema);
|
||||
expect(validate({ name: 'test' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFormats', () => {
|
||||
it('should add date-time and uri formats', () => {
|
||||
const ajv = new Ajv({ strict: false });
|
||||
OCPPValidator.addFormats(ajv);
|
||||
|
||||
const dateTimeSchema = { type: 'string', format: 'date-time' };
|
||||
const validateDateTime = ajv.compile(dateTimeSchema);
|
||||
expect(validateDateTime('2024-01-01T00:00:00Z')).toBe(true);
|
||||
expect(validateDateTime('not-a-date')).toBe(false);
|
||||
|
||||
const uriSchema = { type: 'string', format: 'uri' };
|
||||
const validateUri = ajv.compile(uriSchema);
|
||||
expect(validateUri('https://example.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOCPPRequest', () => {
|
||||
describe('OCPP 1.6', () => {
|
||||
it('should validate a valid BootNotification request', () => {
|
||||
const payload = {
|
||||
chargePointModel: 'TestModel',
|
||||
chargePointVendor: 'TestVendor',
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.BootNotification,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return invalid for a malformed request', () => {
|
||||
const payload = {
|
||||
// missing required chargePointModel and chargePointVendor
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.BootNotification,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate a valid Heartbeat request', () => {
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
{},
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate a valid Authorize request', () => {
|
||||
const payload = {
|
||||
idTag: 'TESTIDTAG001',
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.Authorize,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return invalid for Authorize request missing idTag', () => {
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.Authorize,
|
||||
{},
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCPP 2.0.1', () => {
|
||||
it('should validate a valid Heartbeat request', () => {
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
{},
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCPP 2.0.1', () => {
|
||||
it('should validate a valid RequestStartTransaction request', () => {
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.RequestStartTransaction,
|
||||
{
|
||||
remoteStartId: 0,
|
||||
evseId: 1,
|
||||
idToken: {
|
||||
idToken: 'deadbeef',
|
||||
type: 'ISO14443',
|
||||
},
|
||||
},
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return invalid for unknown protocol version', () => {
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
{},
|
||||
'ocpp1.1' as OCPPVersion,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return invalid when no schema is found for the action', () => {
|
||||
const result = validator.validateOCPPRequest(
|
||||
'UnknownAction' as OCPP_CallAction,
|
||||
{},
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
describe('DataTransfer request validation', () => {
|
||||
it('should validate a valid DataTransfer request for OCPP 2.0.1', () => {
|
||||
const payload = {
|
||||
vendorId: 'TestVendor',
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.DataTransfer,
|
||||
payload,
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate a valid DataTransfer request for OCPP 1.6', () => {
|
||||
const payload = {
|
||||
vendorId: 'TestVendor',
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.DataTransfer,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate DataTransfer payload when a custom schema is registered', () => {
|
||||
const customSchema = {
|
||||
$id: `${OCPPVersion.OCPP2_0_1}-CustomVendor`,
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
},
|
||||
required: ['key'],
|
||||
};
|
||||
validator['_ajv'].addSchema(customSchema);
|
||||
|
||||
const payload = {
|
||||
vendorId: 'CustomVendor',
|
||||
data: JSON.stringify({ key: 'value' }),
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.DataTransfer,
|
||||
payload,
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return invalid when DataTransfer custom payload fails validation', () => {
|
||||
const customSchema = {
|
||||
$id: `${OCPPVersion.OCPP2_0_1}-FailVendor`,
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
},
|
||||
required: ['key'],
|
||||
};
|
||||
validator['_ajv'].addSchema(customSchema);
|
||||
|
||||
const payload = {
|
||||
vendorId: 'FailVendor',
|
||||
data: JSON.stringify({ wrong: 123 }),
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.DataTransfer,
|
||||
payload,
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include messageId in DataTransfer schema lookup when present', () => {
|
||||
const customSchema = {
|
||||
$id: `${OCPPVersion.OCPP2_0_1}-MsgVendor-CustomMsg`,
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'number' },
|
||||
},
|
||||
required: ['value'],
|
||||
};
|
||||
validator['_ajv'].addSchema(customSchema);
|
||||
|
||||
const payload = {
|
||||
vendorId: 'MsgVendor',
|
||||
messageId: 'CustomMsg',
|
||||
data: JSON.stringify({ value: 42 }),
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.DataTransfer,
|
||||
payload,
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip DataTransfer payload validation when no custom schema is registered', () => {
|
||||
const payload = {
|
||||
vendorId: 'UnregisteredVendor',
|
||||
data: JSON.stringify({ anything: 'goes' }),
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.DataTransfer,
|
||||
payload,
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOCPPResponse', () => {
|
||||
describe('OCPP 1.6', () => {
|
||||
it('should validate a valid BootNotification response', () => {
|
||||
const payload = {
|
||||
currentTime: '2024-01-01T00:00:00Z',
|
||||
interval: 300,
|
||||
status: 'Accepted',
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPResponse(
|
||||
OCPP_CallAction.BootNotification,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return invalid for a malformed response', () => {
|
||||
const payload = {
|
||||
// missing required fields
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPResponse(
|
||||
OCPP_CallAction.BootNotification,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate a valid Heartbeat response', () => {
|
||||
const payload = {
|
||||
currentTime: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPResponse(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return invalid for Heartbeat response missing currentTime', () => {
|
||||
const result = validator.validateOCPPResponse(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
{},
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCPP 2.0.1', () => {
|
||||
it('should validate a valid Heartbeat response', () => {
|
||||
const payload = {
|
||||
currentTime: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const result = validator.validateOCPPResponse(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
payload,
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return invalid for a malformed Heartbeat response', () => {
|
||||
const result = validator.validateOCPPResponse(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
{},
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return invalid for unknown protocol version', () => {
|
||||
const result = validator.validateOCPPResponse(
|
||||
OCPP_CallAction.Heartbeat,
|
||||
{ currentTime: '2024-01-01T00:00:00Z' },
|
||||
'ocpp1.1' as OCPPVersion,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return invalid when no schema is found for the action', () => {
|
||||
const result = validator.validateOCPPResponse(
|
||||
'UnknownAction' as OCPP_CallAction,
|
||||
{},
|
||||
OCPPVersion.OCPP2_0_1,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeOCPPPayload', () => {
|
||||
it('should replace null values with undefined in a flat object', () => {
|
||||
const payload = {
|
||||
id: 'test',
|
||||
value: null,
|
||||
status: 'Accepted',
|
||||
};
|
||||
|
||||
const result = validator.sanitizeOCPPPayload(payload as any);
|
||||
|
||||
expect(result.id).toBe('test');
|
||||
expect(result.status).toBe('Accepted');
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should replace null values with undefined in nested objects', () => {
|
||||
const payload = {
|
||||
outer: {
|
||||
inner: null,
|
||||
kept: 'yes',
|
||||
},
|
||||
top: 'level',
|
||||
};
|
||||
|
||||
const result = validator.sanitizeOCPPPayload(payload as any);
|
||||
|
||||
expect(result.outer.kept).toBe('yes');
|
||||
expect(result.outer.inner).toBeUndefined();
|
||||
expect(result.top).toBe('level');
|
||||
});
|
||||
|
||||
it('should filter null values from arrays', () => {
|
||||
const payload = {
|
||||
items: [1, null, 3, null, 5],
|
||||
};
|
||||
|
||||
const result = validator.sanitizeOCPPPayload(payload as any);
|
||||
|
||||
expect(result.items).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it('should handle deeply nested nulls', () => {
|
||||
const payload = {
|
||||
a: {
|
||||
b: {
|
||||
c: null,
|
||||
d: 'keep',
|
||||
},
|
||||
e: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = validator.sanitizeOCPPPayload(payload as any);
|
||||
|
||||
expect(result.a.b.d).toBe('keep');
|
||||
expect(result.a.b.c).toBeUndefined();
|
||||
expect(result.a.e).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve non-null values unchanged', () => {
|
||||
const payload = {
|
||||
str: 'hello',
|
||||
num: 42,
|
||||
bool: true,
|
||||
arr: [1, 2, 3],
|
||||
obj: { nested: 'value' },
|
||||
};
|
||||
|
||||
const result = validator.sanitizeOCPPPayload(payload as any);
|
||||
|
||||
expect(result.str).toBe('hello');
|
||||
expect(result.num).toBe(42);
|
||||
expect(result.bool).toBe(true);
|
||||
expect(result.arr).toEqual([1, 2, 3]);
|
||||
expect(result.obj).toEqual({ nested: 'value' });
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = validator.sanitizeOCPPPayload({} as any);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle arrays with nested objects containing nulls', () => {
|
||||
const payload = {
|
||||
items: [
|
||||
{ id: 1, value: null },
|
||||
{ id: 2, value: 'keep' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = validator.sanitizeOCPPPayload(payload as any);
|
||||
|
||||
expect(result.items[0].id).toBe(1);
|
||||
expect(result.items[0].value).toBeUndefined();
|
||||
expect(result.items[1].id).toBe(2);
|
||||
expect(result.items[1].value).toBe('keep');
|
||||
});
|
||||
|
||||
it('should remove null entries from arrays entirely', () => {
|
||||
const payload = {
|
||||
items: ['a', null, 'b'],
|
||||
};
|
||||
|
||||
const result = validator.sanitizeOCPPPayload(payload as any);
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema caching', () => {
|
||||
it('should reuse cached schema on subsequent validations', () => {
|
||||
const payload = {
|
||||
chargePointModel: 'TestModel',
|
||||
chargePointVendor: 'TestVendor',
|
||||
};
|
||||
|
||||
// First call compiles the schema
|
||||
const result1 = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.BootNotification,
|
||||
payload,
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
// Second call should reuse cached schema
|
||||
const result2 = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.BootNotification,
|
||||
{ ...payload },
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result1.isValid).toBe(true);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return errors as a deep copy that do not affect the cached validator', () => {
|
||||
// First call - get validation errors
|
||||
const result1 = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.BootNotification,
|
||||
{},
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
// Mutate the returned errors
|
||||
if (result1.errors && result1.errors.length > 0) {
|
||||
result1.errors[0].message = 'MUTATED';
|
||||
}
|
||||
|
||||
// Second call - errors should not be affected by the mutation
|
||||
const result2 = validator.validateOCPPRequest(
|
||||
OCPP_CallAction.BootNotification,
|
||||
{},
|
||||
OCPPVersion.OCPP1_6,
|
||||
);
|
||||
|
||||
expect(result2.isValid).toBe(false);
|
||||
expect(result2.errors).toBeDefined();
|
||||
expect(result2.errors![0].message).not.toBe('MUTATED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-version validation', () => {
|
||||
it.each([
|
||||
[OCPP_CallAction.BootNotification, OCPPVersion.OCPP2_0_1, 'OCPP 2.0.1'],
|
||||
[OCPP_CallAction.BootNotification, OCPPVersion.OCPP1_6, 'OCPP 1.6'],
|
||||
])('should have request schemas for %s in %s', (action, version) => {
|
||||
const schemaMap =
|
||||
version === OCPPVersion.OCPP2_0_1
|
||||
? OCPP2_0_1_CALL_SCHEMA_RECORD
|
||||
: OCPP1_6_CALL_SCHEMA_RECORD;
|
||||
expect(schemaMap[action]).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[OCPP_CallAction.BootNotification, OCPPVersion.OCPP2_0_1, 'OCPP 2.0.1'],
|
||||
[OCPP_CallAction.BootNotification, OCPPVersion.OCPP1_6, 'OCPP 1.6'],
|
||||
])('should have response schemas for %s in %s', (action, version) => {
|
||||
const schemaMap =
|
||||
version === OCPPVersion.OCPP2_0_1
|
||||
? OCPP2_0_1_CALL_RESULT_SCHEMA_RECORD
|
||||
: OCPP1_6_CALL_RESULT_SCHEMA_RECORD;
|
||||
expect(schemaMap[action]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { Currency } from '../../index';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('currency', () => {
|
||||
describe('of', () => {
|
||||
it.each(['', ' ', 'RANDOM', 'USD ', ' USD', 'usd', 'PLN', 'CHF'])(
|
||||
'should fail if currency is not supported',
|
||||
(currencyCode) => {
|
||||
expect(() => Currency.of(currencyCode)).toThrow(
|
||||
`Unsupported currency code: ${currencyCode}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['USD', Currency.of('USD')],
|
||||
['EUR', Currency.of('EUR')],
|
||||
['CAD', Currency.of('CAD')],
|
||||
['GBP', Currency.of('GBP')],
|
||||
] as Array<[string, Currency]>)(
|
||||
'should return currency for currency code',
|
||||
(currencyCode, expectedCurrency) => {
|
||||
expect(Currency.of(currencyCode)).toEqual(expectedCurrency);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('code', () => {
|
||||
it.each([
|
||||
[Currency.of('USD'), 'USD'],
|
||||
[Currency.of('EUR'), 'EUR'],
|
||||
[Currency.of('CAD'), 'CAD'],
|
||||
[Currency.of('GBP'), 'GBP'],
|
||||
] as Array<[Currency, string]>)('should return currency code', (currency, expectedCode) => {
|
||||
expect(currency.code).toEqual(expectedCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scale', () => {
|
||||
it.each([
|
||||
[Currency.of('USD'), 2],
|
||||
[Currency.of('EUR'), 2],
|
||||
[Currency.of('CAD'), 2],
|
||||
[Currency.of('GBP'), 2],
|
||||
] as Array<[Currency, number]>)('should return currency scale', (currency, expectedScale) => {
|
||||
expect(currency.scale).toEqual(expectedScale);
|
||||
});
|
||||
});
|
||||
});
|
||||
777
tools/citrineos-core-main/packages/base/test/money/Money.test.ts
Normal file
777
tools/citrineos-core-main/packages/base/test/money/Money.test.ts
Normal file
@@ -0,0 +1,777 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { Currency, Money } from '../../index';
|
||||
import { Big } from 'big.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('money', () => {
|
||||
describe('of', () => {
|
||||
it.each([
|
||||
[0, new Big(0)],
|
||||
[0.012345, new Big('0.012345')],
|
||||
[0.512921747191123, new Big('0.512921747191123')],
|
||||
[0.999999999999999, new Big('0.999999999999999')],
|
||||
[11.6, new Big('11.60')],
|
||||
[11.6058, new Big('11.6058')],
|
||||
[12.1742, new Big('12.1742')],
|
||||
[11.89609, new Big('11.89609')],
|
||||
[59.8, new Big('59.80')],
|
||||
[59.8299, new Big('59.8299')],
|
||||
[62.7601, new Big('62.7601')],
|
||||
[61.326395, new Big('61.326395')],
|
||||
[99.0, new Big('99.00')],
|
||||
[99.01, new Big('99.01')],
|
||||
[99.99, new Big('99.99')],
|
||||
[123.01, new Big('123.01')],
|
||||
[1001.999, new Big('1001.999')],
|
||||
] as Array<[number, Big]>)('should instantiate from number', (numberAmount, expectedAmount) => {
|
||||
const money = Money.of(numberAmount, 'USD');
|
||||
|
||||
expect(money.amount).toEqual(expectedAmount);
|
||||
expect(money.currency.code).toEqual('USD');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new Big('0'), new Big(0)],
|
||||
[new Big('0.012345'), new Big('0.012345')],
|
||||
[new Big('0.512921747191123'), new Big('0.512921747191123')],
|
||||
[new Big('0.999999999999999'), new Big('0.999999999999999')],
|
||||
[new Big('11.60'), new Big('11.60')],
|
||||
[new Big('11.6058'), new Big('11.6058')],
|
||||
[new Big('12.1742'), new Big('12.1742')],
|
||||
[new Big('11.89609'), new Big('11.89609')],
|
||||
[new Big('59.80'), new Big('59.80')],
|
||||
[new Big('59.8299'), new Big('59.8299')],
|
||||
[new Big('62.7601'), new Big('62.7601')],
|
||||
[new Big('61.326395'), new Big('61.326395')],
|
||||
[new Big('99.00'), new Big('99.00')],
|
||||
[new Big('99.01'), new Big('99.01')],
|
||||
[new Big('99.99'), new Big('99.99')],
|
||||
[new Big('123.01'), new Big('123.01')],
|
||||
[new Big('1001.999'), new Big('1001.999')],
|
||||
] as Array<[Big, Big]>)('should instantiate from Big', (bigAmount, expectedAmount) => {
|
||||
const money = Money.of(bigAmount, 'USD');
|
||||
|
||||
expect(money.amount).toEqual(expectedAmount);
|
||||
expect(money.currency.code).toEqual('USD');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['0', new Big(0)],
|
||||
['0.012345', new Big('0.012345')],
|
||||
['0.512921747191123', new Big('0.512921747191123')],
|
||||
['0.999999999999999', new Big('0.999999999999999')],
|
||||
['11.60', new Big('11.60')],
|
||||
['11.6058', new Big('11.6058')],
|
||||
['12.1742', new Big('12.1742')],
|
||||
['11.89609', new Big('11.89609')],
|
||||
['59.80', new Big('59.80')],
|
||||
['59.8299', new Big('59.8299')],
|
||||
['62.7601', new Big('62.7601')],
|
||||
['61.326395', new Big('61.326395')],
|
||||
['99.00', new Big('99.00')],
|
||||
['99.01', new Big('99.01')],
|
||||
['99.99', new Big('99.99')],
|
||||
['123.01', new Big('123.01')],
|
||||
['1001.999', new Big('1001.999')],
|
||||
] as Array<[string, Big]>)('should instantiate from string', (stringAmount, expectedAmount) => {
|
||||
const money = Money.of(stringAmount, 'USD');
|
||||
|
||||
expect(money.amount).toEqual(expectedAmount);
|
||||
expect(money.currency.code).toEqual('USD');
|
||||
});
|
||||
|
||||
it('should fail when undefined amount', () => {
|
||||
const amount: number | undefined = undefined;
|
||||
expect(() => Money.of(amount!, 'USD')).toThrow(`Amount has to be defined`);
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
|
||||
'should fail when invalid amount',
|
||||
(amount) => {
|
||||
expect(() => Money.of(amount, 'USD')).toThrow(`Invalid money amount: ${amount}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('should fail when undefined currency', () => {
|
||||
const currency: string | undefined = undefined;
|
||||
expect(() => Money.of('1.00', currency!)).toThrow(`Currency has to be defined`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
' ',
|
||||
'U',
|
||||
'US',
|
||||
'US ',
|
||||
'USd',
|
||||
'usd',
|
||||
' USD',
|
||||
'USD ',
|
||||
' USD ',
|
||||
'AUD',
|
||||
'PLN',
|
||||
'CHF',
|
||||
])('should fail when unsupported currency', (currency) => {
|
||||
expect(() => Money.of('1.00', currency)).toThrow(`Unsupported currency code: ${currency}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('amount', () => {
|
||||
it.each([
|
||||
[Money.USD('0'), new Big('0')],
|
||||
[Money.USD('0.1'), new Big('0.1')],
|
||||
[Money.USD('0.195'), new Big('0.195')],
|
||||
[Money.USD('0.33'), new Big('0.33')],
|
||||
[Money.USD('1.00'), new Big('1')],
|
||||
[Money.USD('0.0000000001'), new Big('0.0000000001')],
|
||||
[Money.USD('-0.0000000001'), new Big('-0.0000000001')],
|
||||
[Money.USD('0.012345'), new Big('0.012345')],
|
||||
[Money.USD('0.512921747191123'), new Big('0.512921747191123')],
|
||||
[Money.USD('0.999999999999999'), new Big('0.999999999999999')],
|
||||
[Money.USD('11.60'), new Big('11.60')],
|
||||
[Money.USD('11.6058'), new Big('11.6058')],
|
||||
[Money.USD('12.1742'), new Big('12.1742')],
|
||||
[Money.USD('11.89609'), new Big('11.89609')],
|
||||
[Money.USD('59.80'), new Big('59.80')],
|
||||
[Money.USD('59.8299'), new Big('59.8299')],
|
||||
[Money.USD('62.7601'), new Big('62.7601')],
|
||||
[Money.USD('61.326395'), new Big('61.326395')],
|
||||
[Money.USD('99.00'), new Big('99')],
|
||||
[Money.USD('99.01'), new Big('99.01')],
|
||||
[Money.USD('99.99'), new Big('99.99')],
|
||||
[Money.USD('123.01'), new Big('123.01')],
|
||||
[Money.USD('1001.999'), new Big('1001.999')],
|
||||
] as Array<[Money, Big]>)('should return amount', (money, expectedAmount) => {
|
||||
expect(money.amount).toEqual(expectedAmount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currency', () => {
|
||||
it.each([
|
||||
[Money.USD('1.00'), Currency.of('USD')],
|
||||
[Money.of('1.00', 'USD'), Currency.of('USD')],
|
||||
[Money.of('1.00', Currency.of('USD')), Currency.of('USD')],
|
||||
[Money.of('1.00', 'EUR'), Currency.of('EUR')],
|
||||
[Money.of('1.00', Currency.of('EUR')), Currency.of('EUR')],
|
||||
[Money.of('1.00', 'CAD'), Currency.of('CAD')],
|
||||
[Money.of('1.00', Currency.of('CAD')), Currency.of('CAD')],
|
||||
[Money.of('1.00', 'GBP'), Currency.of('GBP')],
|
||||
[Money.of('1.00', Currency.of('GBP')), Currency.of('GBP')],
|
||||
] as Array<[Money, Currency]>)('should return currency', (money, expectedCurrency) => {
|
||||
expect(money.currency).toEqual(expectedCurrency);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNumber', () => {
|
||||
it.each([
|
||||
[Money.USD('0'), 0],
|
||||
[Money.USD('0.1'), 0.1],
|
||||
[Money.USD('0.195'), 0.195],
|
||||
[Money.USD('0.33'), 0.33],
|
||||
[Money.USD('1.00'), 1],
|
||||
[Money.USD('0.0000000001'), 0.0000000001],
|
||||
[Money.USD('-0.0000000001'), -0.0000000001],
|
||||
[Money.USD('0.012345'), 0.012345],
|
||||
[Money.USD('0.512921747191123'), 0.512921747191123],
|
||||
[Money.USD('0.999999999999999'), 0.999999999999999],
|
||||
[Money.USD('11.60'), 11.6],
|
||||
[Money.USD('11.6058'), 11.6058],
|
||||
[Money.USD('12.1742'), 12.1742],
|
||||
[Money.USD('11.89609'), 11.89609],
|
||||
[Money.USD('59.80'), 59.8],
|
||||
[Money.USD('59.8299'), 59.8299],
|
||||
[Money.USD('62.7601'), 62.7601],
|
||||
[Money.USD('61.326395'), 61.326395],
|
||||
[Money.USD('99.00'), 99],
|
||||
[Money.USD('99.01'), 99.01],
|
||||
[Money.USD('99.99'), 99.99],
|
||||
[Money.USD('123.01'), 123.01],
|
||||
[Money.USD('1001.999'), 1001.999],
|
||||
] as Array<[Money, number]>)('should return amount as number', (money, expectedNumber) => {
|
||||
expect(money.toNumber()).toEqual(expectedNumber);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundToCurrencyScale', () => {
|
||||
it('should return a new instance', () => {
|
||||
const money = Money.of('1.00', 'USD');
|
||||
const result = money.roundToCurrencyScale();
|
||||
|
||||
expect(Object.is(money, result)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0'), Money.USD('0')],
|
||||
[Money.USD('0.012345'), Money.USD('0.01')],
|
||||
[Money.USD('0.512921747191123'), Money.USD('0.51')],
|
||||
[Money.USD('0.999999999999999'), Money.USD('0.99')],
|
||||
[Money.USD('10.00'), Money.USD('10.00')],
|
||||
[Money.USD('11.60'), Money.USD('11.60')],
|
||||
[Money.USD('11.6058'), Money.USD('11.60')],
|
||||
[Money.USD('12.1742'), Money.USD('12.17')],
|
||||
[Money.USD('11.89609'), Money.USD('11.89')],
|
||||
[Money.USD('59.80'), Money.USD('59.80')],
|
||||
[Money.USD('59.8299'), Money.USD('59.82')],
|
||||
[Money.USD('60.00'), Money.USD('60.00')],
|
||||
[Money.USD('62.7601'), Money.USD('62.76')],
|
||||
[Money.USD('61.326395'), Money.USD('61.32')],
|
||||
])('should round down to currency scale', (money, roundedMoney) => {
|
||||
expect(money.roundToCurrencyScale()).toEqual(roundedMoney);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiply', () => {
|
||||
it('should return a new instance', () => {
|
||||
const money = Money.of('1.00', 'USD');
|
||||
const result = money.multiply('1.00');
|
||||
|
||||
expect(Object.is(money, result)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0'), '0', Money.USD('0')],
|
||||
[Money.USD('0.01'), '0', Money.USD('0')],
|
||||
[Money.USD('1'), '0', Money.USD('0')],
|
||||
[Money.USD('100'), '0', Money.USD('0')],
|
||||
|
||||
[Money.USD('0.1'), '0.2', Money.USD('0.02')],
|
||||
[Money.USD('0.01'), '0.01', Money.USD('0.0001')],
|
||||
[Money.USD('0.005'), '0.005', Money.USD('0.000025')],
|
||||
[Money.USD('0.0020445'), '0.0005405', Money.USD('0.00000110505225')],
|
||||
[Money.USD('0.0000000032'), '0.0014406509', Money.USD('0.00000000000461008288')],
|
||||
[Money.USD('0.6970656124'), '0.0098969742', Money.USD('0.00689884038163000008')],
|
||||
|
||||
[Money.USD('1'), '1', Money.USD('1')],
|
||||
[Money.USD('1'), '99', Money.USD('99')],
|
||||
[Money.USD('1'), '144', Money.USD('144')],
|
||||
[Money.USD('1'), '800000', Money.USD('800000')],
|
||||
[Money.USD('1'), '9000000000000', Money.USD('9000000000000')],
|
||||
|
||||
[Money.USD('0.58'), '20.00', Money.USD('11.60')],
|
||||
[Money.USD('0.58'), '20.01', Money.USD('11.6058')],
|
||||
[Money.USD('0.58'), '20.99', Money.USD('12.1742')],
|
||||
[Money.USD('0.58'), '20.5105', Money.USD('11.89609')],
|
||||
|
||||
[Money.USD('2.99'), '20.00', Money.USD('59.80')],
|
||||
[Money.USD('2.99'), '20.01', Money.USD('59.8299')],
|
||||
[Money.USD('2.99'), '20.99', Money.USD('62.7601')],
|
||||
[Money.USD('2.99'), '20.5105', Money.USD('61.326395')],
|
||||
|
||||
[Money.USD('1'), '0.00000000000014406509', Money.USD('0.00000000000014406509')],
|
||||
[Money.USD('1'), '0.00000000000001', Money.USD('0.00000000000001')],
|
||||
[Money.USD('1'), '0.000000003345', Money.USD('0.000000003345')],
|
||||
[Money.USD('1'), '0.0001', Money.USD('0.0001')],
|
||||
[Money.USD('1'), '0.09', Money.USD('0.09')],
|
||||
[Money.USD('1'), '1.02', Money.USD('1.02')],
|
||||
[Money.USD('1'), '6.1915', Money.USD('6.1915')],
|
||||
|
||||
[Money.USD('0.1'), '-0.2', Money.USD('-0.02')],
|
||||
[Money.USD('0.01'), '-0.01', Money.USD('-0.0001')],
|
||||
[Money.USD('0.005'), '-0.005', Money.USD('-0.000025')],
|
||||
|
||||
[Money.USD('1'), '-1', Money.USD('-1')],
|
||||
[Money.USD('1'), '-99', Money.USD('-99')],
|
||||
[Money.USD('1'), '-144', Money.USD('-144')],
|
||||
[Money.USD('1'), '-800000', Money.USD('-800000')],
|
||||
[Money.USD('1'), '-9000000000000', Money.USD('-9000000000000')],
|
||||
] as Array<[Money, string, Money]>)(
|
||||
'should multiply with high precision',
|
||||
(multiplicand, multiplier, expected) => {
|
||||
expect(multiplicand.multiply(multiplier)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('should fail when adding different currencies', () => {
|
||||
const dollars = Money.of('1.00', 'USD');
|
||||
const euros = Money.of('1.00', 'EUR');
|
||||
|
||||
expect(() => dollars.add(euros)).toThrow('Currency mismatch');
|
||||
});
|
||||
|
||||
it('should return a new instance', () => {
|
||||
const money = Money.of('1.00', 'USD');
|
||||
const result = money.add(Money.of('1.00', 'USD'));
|
||||
|
||||
expect(Object.is(money, result)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0'), Money.USD('0'), Money.USD('0')],
|
||||
|
||||
[Money.USD('0.1'), Money.USD('0.2'), Money.USD('0.3')],
|
||||
[Money.USD('0.01'), Money.USD('0.01'), Money.USD('0.02')],
|
||||
[Money.USD('0.005'), Money.USD('0.005'), Money.USD('0.01')],
|
||||
[
|
||||
Money.USD('0.00204456882324225'),
|
||||
Money.USD('0.00054054406579017'),
|
||||
Money.USD('0.00258511288903242'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.00000000326851523835'),
|
||||
Money.USD('0.00000000000014406509'),
|
||||
Money.USD('0.00000000326865930344'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.69706561243525725549'),
|
||||
Money.USD('0.00000000000098969742'),
|
||||
Money.USD('0.69706561243624695291'),
|
||||
],
|
||||
|
||||
[Money.USD('1'), Money.USD('0'), Money.USD('1')],
|
||||
[Money.USD('1'), Money.USD('1'), Money.USD('2')],
|
||||
[Money.USD('1'), Money.USD('99'), Money.USD('100')],
|
||||
[Money.USD('1'), Money.USD('144'), Money.USD('145')],
|
||||
[Money.USD('1'), Money.USD('800000'), Money.USD('800001')],
|
||||
[Money.USD('1'), Money.USD('9000000000000'), Money.USD('9000000000001')],
|
||||
|
||||
[Money.USD('1'), Money.USD('0.00000000000014406509'), Money.USD('1.00000000000014406509')],
|
||||
[Money.USD('1'), Money.USD('0.00000000000001'), Money.USD('1.00000000000001')],
|
||||
[Money.USD('1'), Money.USD('0.000000003345'), Money.USD('1.000000003345')],
|
||||
[Money.USD('1'), Money.USD('0.0001'), Money.USD('1.0001')],
|
||||
[Money.USD('1'), Money.USD('0.09'), Money.USD('1.09')],
|
||||
[Money.USD('1'), Money.USD('1.02'), Money.USD('2.02')],
|
||||
[Money.USD('1'), Money.USD('6.1915'), Money.USD('7.1915')],
|
||||
|
||||
[Money.USD('20.00'), Money.USD('9.0091'), Money.USD('29.0091')],
|
||||
[Money.USD('20.01'), Money.USD('9.0091'), Money.USD('29.0191')],
|
||||
[Money.USD('20.99'), Money.USD('9.0091'), Money.USD('29.9991')],
|
||||
[Money.USD('20.9909'), Money.USD('9.0091'), Money.USD('30.00')],
|
||||
|
||||
[Money.USD('20.00'), Money.USD('2.99'), Money.USD('22.99')],
|
||||
[Money.USD('20.01'), Money.USD('2.99'), Money.USD('23.00')],
|
||||
[Money.USD('20.99'), Money.USD('2.99'), Money.USD('23.98')],
|
||||
|
||||
[
|
||||
Money.USD('13455320760120.8707665014'),
|
||||
Money.USD('2952858872457.929554397223499'),
|
||||
Money.USD('16408179632578.800320898623499'),
|
||||
],
|
||||
[
|
||||
Money.USD('9936044410052508775.881727897883802'),
|
||||
Money.USD('58302388.37179491476'),
|
||||
Money.USD('9936044410110811164.253522812643802'),
|
||||
],
|
||||
|
||||
[Money.USD('0.1'), Money.USD('-0.2'), Money.USD('-0.1')],
|
||||
[Money.USD('0.01'), Money.USD('-0.01'), Money.USD('0')],
|
||||
[Money.USD('0.005'), Money.USD('-0.005'), Money.USD('0')],
|
||||
[
|
||||
Money.USD('0.00054054406579017'),
|
||||
Money.USD('-0.00204456882324225'),
|
||||
Money.USD('-0.00150402475745208'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.00000000000014406509'),
|
||||
Money.USD('-0.00000000326851523835'),
|
||||
Money.USD('-0.00000000326837117326'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.00000000000098969742'),
|
||||
Money.USD('-0.69706561243525725549'),
|
||||
Money.USD('-0.69706561243426755807'),
|
||||
],
|
||||
|
||||
[Money.USD('1'), Money.USD('-1'), Money.USD('0')],
|
||||
[Money.USD('1'), Money.USD('-99'), Money.USD('-98')],
|
||||
[Money.USD('1'), Money.USD('-144'), Money.USD('-143')],
|
||||
[Money.USD('1'), Money.USD('-800000'), Money.USD('-799999')],
|
||||
[Money.USD('1'), Money.USD('-9000000000000'), Money.USD('-8999999999999')],
|
||||
|
||||
[Money.USD('1'), Money.USD('-0.00000000000014406509'), Money.USD('0.99999999999985593491')],
|
||||
[Money.USD('1'), Money.USD('-0.00000000000001'), Money.USD('0.99999999999999')],
|
||||
[Money.USD('1'), Money.USD('-0.000000003345'), Money.USD('0.999999996655')],
|
||||
[Money.USD('1'), Money.USD('-0.0001'), Money.USD('0.9999')],
|
||||
[Money.USD('1'), Money.USD('-0.09'), Money.USD('0.91')],
|
||||
[Money.USD('1'), Money.USD('-1.02'), Money.USD('-0.02')],
|
||||
[Money.USD('1'), Money.USD('-6.1915'), Money.USD('-5.1915')],
|
||||
])('should correctly add', (addendA, addendB, expected) => {
|
||||
expect(addendA.add(addendB)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtract', () => {
|
||||
it('should fail when subtracting different currencies', () => {
|
||||
const dollars = Money.of('1.00', 'USD');
|
||||
const euros = Money.of('1.00', 'EUR');
|
||||
|
||||
expect(() => dollars.subtract(euros)).toThrow('Currency mismatch');
|
||||
});
|
||||
|
||||
it('should return a new instance', () => {
|
||||
const money = Money.of('1.00', 'USD');
|
||||
const result = money.subtract(Money.of('1.00', 'USD'));
|
||||
|
||||
expect(Object.is(money, result)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0'), Money.USD('0'), Money.USD('0')],
|
||||
|
||||
[Money.USD('0.1'), Money.USD('0.2'), Money.USD('-0.1')],
|
||||
[Money.USD('0.2'), Money.USD('0.1'), Money.USD('0.1')],
|
||||
[Money.USD('0.01'), Money.USD('0.01'), Money.USD('0.00')],
|
||||
[Money.USD('0.005'), Money.USD('0.005'), Money.USD('0.00')],
|
||||
[
|
||||
Money.USD('0.00204456882324225'),
|
||||
Money.USD('0.00054054406579017'),
|
||||
Money.USD('0.00150402475745208'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.00000000326851523835'),
|
||||
Money.USD('0.00000000000014406509'),
|
||||
Money.USD('0.00000000326837117326'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.69706561243525725549'),
|
||||
Money.USD('0.00000000000098969742'),
|
||||
Money.USD('0.69706561243426755807'),
|
||||
],
|
||||
|
||||
[Money.USD('1'), Money.USD('0'), Money.USD('1')],
|
||||
[Money.USD('1'), Money.USD('1'), Money.USD('0')],
|
||||
[Money.USD('1'), Money.USD('99'), Money.USD('-98')],
|
||||
[Money.USD('99'), Money.USD('1'), Money.USD('98')],
|
||||
[Money.USD('144'), Money.USD('1'), Money.USD('143')],
|
||||
[Money.USD('800000'), Money.USD('1'), Money.USD('799999')],
|
||||
[Money.USD('9000000000000'), Money.USD('1'), Money.USD('8999999999999')],
|
||||
|
||||
[Money.USD('1'), Money.USD('0.00000000000014406509'), Money.USD('0.99999999999985593491')],
|
||||
[Money.USD('1'), Money.USD('0.00000000000001'), Money.USD('0.99999999999999')],
|
||||
[Money.USD('1'), Money.USD('0.000000003345'), Money.USD('0.999999996655')],
|
||||
[Money.USD('1'), Money.USD('0.0001'), Money.USD('0.9999')],
|
||||
[Money.USD('1'), Money.USD('0.09'), Money.USD('0.91')],
|
||||
[Money.USD('1'), Money.USD('1.02'), Money.USD('-0.02')],
|
||||
[Money.USD('1'), Money.USD('6.1915'), Money.USD('-5.1915')],
|
||||
|
||||
[
|
||||
Money.USD('13455320760120.8707665014'),
|
||||
Money.USD('2952858872457.929554397223499'),
|
||||
Money.USD('10502461887662.941212104176501'),
|
||||
],
|
||||
|
||||
[Money.USD('20.00'), Money.USD('9.0091'), Money.USD('10.9909')],
|
||||
[Money.USD('20.01'), Money.USD('9.0091'), Money.USD('11.0009')],
|
||||
[Money.USD('20.99'), Money.USD('9.0091'), Money.USD('11.9809')],
|
||||
[Money.USD('20.9909'), Money.USD('9.0091'), Money.USD('11.9818')],
|
||||
|
||||
[Money.USD('20.00'), Money.USD('2.99'), Money.USD('17.01')],
|
||||
[Money.USD('20.01'), Money.USD('2.99'), Money.USD('17.02')],
|
||||
[Money.USD('20.99'), Money.USD('2.99'), Money.USD('18.00')],
|
||||
|
||||
[Money.USD('0.1'), Money.USD('-0.2'), Money.USD('0.3')],
|
||||
[Money.USD('0.01'), Money.USD('-0.01'), Money.USD('0.02')],
|
||||
[Money.USD('0.005'), Money.USD('-0.005'), Money.USD('0.01')],
|
||||
[
|
||||
Money.USD('0.00054054406579017'),
|
||||
Money.USD('-0.00204456882324225'),
|
||||
Money.USD('0.00258511288903242'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.00000000000014406509'),
|
||||
Money.USD('-0.00000000326851523835'),
|
||||
Money.USD('0.00000000326865930344'),
|
||||
],
|
||||
[
|
||||
Money.USD('0.00000000000098969742'),
|
||||
Money.USD('-0.69706561243525725549'),
|
||||
Money.USD('0.69706561243624695291'),
|
||||
],
|
||||
|
||||
[Money.USD('1'), Money.USD('-1'), Money.USD('2')],
|
||||
[Money.USD('1'), Money.USD('-99'), Money.USD('100')],
|
||||
[Money.USD('1'), Money.USD('-144'), Money.USD('145')],
|
||||
[Money.USD('1'), Money.USD('-800000'), Money.USD('800001')],
|
||||
[Money.USD('1'), Money.USD('-9000000000000'), Money.USD('9000000000001')],
|
||||
|
||||
[Money.USD('1'), Money.USD('-0.00000000000014406509'), Money.USD('1.00000000000014406509')],
|
||||
[Money.USD('1'), Money.USD('-0.00000000000001'), Money.USD('1.00000000000001')],
|
||||
[Money.USD('1'), Money.USD('-0.000000003345'), Money.USD('1.000000003345')],
|
||||
[Money.USD('1'), Money.USD('-0.0001'), Money.USD('1.0001')],
|
||||
[Money.USD('1'), Money.USD('-0.09'), Money.USD('1.09')],
|
||||
[Money.USD('1'), Money.USD('-1.02'), Money.USD('2.02')],
|
||||
[Money.USD('1'), Money.USD('-6.1915'), Money.USD('7.1915')],
|
||||
])('should correctly subtract', (minuend, subtrahend, expected) => {
|
||||
expect(minuend.subtract(subtrahend)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return false when comparing different currencies', () => {
|
||||
const dollars = Money.of('1.00', 'USD');
|
||||
const euros = Money.of('1.00', 'EUR');
|
||||
|
||||
expect(dollars.equals(euros)).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('1.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000002')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000010')],
|
||||
[Money.USD('0.99'), Money.USD('1.00')],
|
||||
[Money.USD('20'), Money.USD('100')],
|
||||
])('should return false when amount is smaller', (money, moreMoney) => {
|
||||
expect(money.equals(moreMoney)).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('0.99'), Money.USD('0.99')],
|
||||
[Money.USD('20'), Money.USD('20.00')],
|
||||
])('should return true when amount is equal', (money, sameMoney) => {
|
||||
expect(money.equals(sameMoney)).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('1.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000002'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000010'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('1.00'), Money.USD('0.99')],
|
||||
[Money.USD('100'), Money.USD('99')],
|
||||
])('should return false when amount is greater', (money, lessMoney) => {
|
||||
expect(money.equals(lessMoney)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('greaterThan', () => {
|
||||
it('should fail when comparing different currencies', () => {
|
||||
const dollars = Money.of('1.00', 'USD');
|
||||
const euros = Money.of('1.00', 'EUR');
|
||||
|
||||
expect(() => dollars.greaterThan(euros)).toThrow('Currency mismatch');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('1.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000002')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000010')],
|
||||
[Money.USD('0.99'), Money.USD('1.00')],
|
||||
[Money.USD('20'), Money.USD('100')],
|
||||
])('should return false when amount is smaller', (money, moreMoney) => {
|
||||
expect(money.greaterThan(moreMoney)).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('0.99'), Money.USD('0.99')],
|
||||
[Money.USD('20'), Money.USD('20.00')],
|
||||
])('should return false when amount is equal', (money, sameMoney) => {
|
||||
expect(money.greaterThan(sameMoney)).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('1.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000002'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000010'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('1.00'), Money.USD('0.99')],
|
||||
[Money.USD('100'), Money.USD('99')],
|
||||
])('should return true when amount is greater', (money, lessMoney) => {
|
||||
expect(money.greaterThan(lessMoney)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('greaterThanOrEqual', () => {
|
||||
it('should fail when comparing different currencies', () => {
|
||||
const dollars = Money.of('1.00', 'USD');
|
||||
const euros = Money.of('1.00', 'EUR');
|
||||
|
||||
expect(() => dollars.greaterThanOrEqual(euros)).toThrow('Currency mismatch');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('1.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000002')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000010')],
|
||||
[Money.USD('0.99'), Money.USD('1.00')],
|
||||
[Money.USD('99'), Money.USD('100')],
|
||||
])('should return false when amount is smaller', (money, moreMoney) => {
|
||||
expect(money.greaterThanOrEqual(moreMoney)).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('0.99'), Money.USD('0.99')],
|
||||
[Money.USD('99'), Money.USD('20.00')],
|
||||
])('should return true when amount is equal', (money, sameMoney) => {
|
||||
expect(money.greaterThanOrEqual(sameMoney)).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('1.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000002'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000010'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('1.00'), Money.USD('0.99')],
|
||||
[Money.USD('100'), Money.USD('20')],
|
||||
])('should return true when amount is greater', (money, lessMoney) => {
|
||||
expect(money.greaterThanOrEqual(lessMoney)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lessThan', () => {
|
||||
it('should fail when comparing different currencies', () => {
|
||||
const dollars = Money.of('1.00', 'USD');
|
||||
const euros = Money.of('1.00', 'EUR');
|
||||
|
||||
expect(() => dollars.lessThan(euros)).toThrow('Currency mismatch');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('1.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000002')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000010')],
|
||||
[Money.USD('0.99'), Money.USD('1.00')],
|
||||
[Money.USD('99'), Money.USD('100')],
|
||||
])('should return true when amount is smaller', (money, moreMoney) => {
|
||||
expect(money.lessThan(moreMoney)).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('0.99'), Money.USD('0.99')],
|
||||
[Money.USD('99'), Money.USD('20.00')],
|
||||
])('should return false when amount is equal', (money, sameMoney) => {
|
||||
expect(money.lessThan(sameMoney)).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('1.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000002'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000010'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('1.00'), Money.USD('0.99')],
|
||||
[Money.USD('100'), Money.USD('99')],
|
||||
])('should return false when amount is greater', (money, lessMoney) => {
|
||||
expect(money.lessThan(lessMoney)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lessThanOrEqual', () => {
|
||||
it('should fail when comparing different currencies', () => {
|
||||
const dollars = Money.of('1.00', 'USD');
|
||||
const euros = Money.of('1.00', 'EUR');
|
||||
|
||||
expect(() => dollars.lessThanOrEqual(euros)).toThrow('Currency mismatch');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('1.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000002')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000010')],
|
||||
[Money.USD('0.99'), Money.USD('1.00')],
|
||||
[Money.USD('99'), Money.USD('100')],
|
||||
])('should return true when amount is smaller', (money, moreMoney) => {
|
||||
expect(money.lessThanOrEqual(moreMoney)).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('0.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000001'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000009'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('0.99'), Money.USD('0.99')],
|
||||
[Money.USD('20'), Money.USD('20.00')],
|
||||
])('should return true when amount is equal', (money, sameMoney) => {
|
||||
expect(money.lessThanOrEqual(sameMoney)).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Money.USD('1.00'), Money.USD('0.00')],
|
||||
[Money.USD('0.00000000000000000002'), Money.USD('0.00000000000000000001')],
|
||||
[Money.USD('0.00000000000000000010'), Money.USD('0.00000000000000000009')],
|
||||
[Money.USD('1.00'), Money.USD('0.99')],
|
||||
[Money.USD('100'), Money.USD('99')],
|
||||
])('should return false when amount is greater', (money, lessMoney) => {
|
||||
expect(money.lessThanOrEqual(lessMoney)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isZero', () => {
|
||||
it.each([Money.USD('0')])('should return true when amount is zero', (money) => {
|
||||
expect(money.isZero()).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
Money.USD('0.00000000000000000001'),
|
||||
Money.USD('0.005'),
|
||||
Money.USD('0.01'),
|
||||
Money.USD('1.00'),
|
||||
Money.USD('1000.00'),
|
||||
])('should return false when amount is greater than zero', (money) => {
|
||||
expect(money.isZero()).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
Money.USD('-0.00000000000000000001'),
|
||||
Money.USD('-0.005'),
|
||||
Money.USD('-0.01'),
|
||||
Money.USD('-1.00'),
|
||||
Money.USD('-99.00'),
|
||||
Money.USD('-1000.00'),
|
||||
])('should return false when amount is less than zero', (money) => {
|
||||
expect(money.isZero()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositive', () => {
|
||||
it.each([
|
||||
Money.USD('0.00000000000000000001'),
|
||||
Money.USD('0.005'),
|
||||
Money.USD('0.01'),
|
||||
Money.USD('1.00'),
|
||||
Money.USD('1000.00'),
|
||||
])('should return true when amount is greater than zero', (money) => {
|
||||
expect(money.isPositive()).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
Money.USD('-0.00000000000000000001'),
|
||||
Money.USD('-0.005'),
|
||||
Money.USD('-0.01'),
|
||||
Money.USD('-1.00'),
|
||||
Money.USD('-99.00'),
|
||||
Money.USD('-1000.00'),
|
||||
])('should return false when amount is less than zero', (money) => {
|
||||
expect(money.isPositive()).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([Money.USD('0')])('should return false when amount is equal to zero', (money) => {
|
||||
expect(money.isPositive()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNegative', () => {
|
||||
it.each([
|
||||
Money.USD('-0.00000000000000000001'),
|
||||
Money.USD('-0.005'),
|
||||
Money.USD('-0.01'),
|
||||
Money.USD('-1.00'),
|
||||
Money.USD('-99.00'),
|
||||
Money.USD('-1000.00'),
|
||||
])('should return true when amount is less than zero', (money) => {
|
||||
expect(money.isNegative()).toEqual(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
Money.USD('0.00000000000000000001'),
|
||||
Money.USD('0.005'),
|
||||
Money.USD('0.01'),
|
||||
Money.USD('1.00'),
|
||||
Money.USD('1000.00'),
|
||||
])('should return false when amount is greater than zero', (money) => {
|
||||
expect(money.isNegative()).toEqual(false);
|
||||
});
|
||||
|
||||
it.each([Money.USD('0')])('should return false when amount is equal to zero', (money) => {
|
||||
expect(money.isNegative()).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MeterValueUtils, type MeterValueDto, type SampledValue } from '../../index';
|
||||
|
||||
function makeMeterValue(
|
||||
ts: string,
|
||||
measurand: SampledValue['measurand'],
|
||||
value: number,
|
||||
unit: string = 'kWh',
|
||||
context?: SampledValue['context'],
|
||||
): MeterValueDto {
|
||||
return {
|
||||
timestamp: ts,
|
||||
sampledValue: [
|
||||
{
|
||||
measurand,
|
||||
unitOfMeasure: { unit, multiplier: 0 },
|
||||
value,
|
||||
context,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('MeterValueUtils', () => {
|
||||
describe('getTotalKwh', () => {
|
||||
describe('Register values', () => {
|
||||
it('calculates difference between first and last register readings without meterStart', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 100),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Register', 150),
|
||||
makeMeterValue('2025-05-29T12:03:00Z', 'Energy.Active.Import.Register', 200),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(100); // 200 - 100 = 100
|
||||
});
|
||||
|
||||
it('calculates difference from meterStart when provided', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 100),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Register', 150),
|
||||
makeMeterValue('2025-05-29T12:03:00Z', 'Energy.Active.Import.Register', 200),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0, 50)).toBe(150); // 200 - 50 = 150
|
||||
});
|
||||
|
||||
it('handles single register reading without meterStart', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 100),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(0); // 100 - 100 = 0
|
||||
});
|
||||
|
||||
it('handles single register reading with meterStart', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 100),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0, 50)).toBe(50); // 100 - 50 = 50
|
||||
});
|
||||
|
||||
it('treats missing measurand as Energy.Active.Import.Register', () => {
|
||||
const meterValues: MeterValueDto[] = [
|
||||
{
|
||||
timestamp: '2025-05-29T12:01:00Z',
|
||||
sampledValue: [{ value: 100, unitOfMeasure: { unit: 'kWh', multiplier: 0 } }],
|
||||
},
|
||||
{
|
||||
timestamp: '2025-05-29T12:02:00Z',
|
||||
sampledValue: [{ value: 200, unitOfMeasure: { unit: 'kWh', multiplier: 0 } }],
|
||||
},
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interval values', () => {
|
||||
it('sums interval readings starting from currentTotal', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Interval', 50),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Interval', 49),
|
||||
makeMeterValue('2025-05-29T12:03:00Z', 'Energy.Active.Import.Interval', 46),
|
||||
makeMeterValue('2025-05-29T12:04:00Z', 'Energy.Active.Import.Interval', 52),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(50 + 49 + 46 + 52);
|
||||
});
|
||||
|
||||
it('adds interval readings to currentTotal', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Interval', 50),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Interval', 50),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 100)).toBe(200); // 100 + 50 + 50
|
||||
});
|
||||
});
|
||||
|
||||
describe('Net values', () => {
|
||||
it('picks latest net reading', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Net', 50),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Net', 100),
|
||||
makeMeterValue('2025-05-29T12:03:00Z', 'Energy.Active.Net', 153),
|
||||
makeMeterValue('2025-05-29T12:04:00Z', 'Energy.Active.Net', 201),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 50)).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering by context', () => {
|
||||
it('filters out invalid context values', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:00:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
150,
|
||||
'kWh',
|
||||
'Trigger',
|
||||
), // Invalid context, should be filtered
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:01:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
100,
|
||||
'kWh',
|
||||
'Transaction.Begin',
|
||||
),
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:03:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
200,
|
||||
'kWh',
|
||||
'Transaction.End',
|
||||
),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(100); // 200 - 100, ignoring 150
|
||||
});
|
||||
|
||||
it('includes Sample.Periodic context', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:01:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
100,
|
||||
'kWh',
|
||||
'Transaction.Begin',
|
||||
),
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:02:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
150,
|
||||
'kWh',
|
||||
'Sample.Periodic',
|
||||
),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(50);
|
||||
});
|
||||
|
||||
it('treats undefined context as Sample.Periodic (valid)', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 100),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Register', 200),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unit normalization', () => {
|
||||
it('normalizes Wh to kWh', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 1000, 'Wh'),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Register', 5000, 'Wh'),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(4); // (5000 - 1000) / 1000
|
||||
});
|
||||
|
||||
it('handles kWh without conversion', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 100, 'kWh'),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Register', 150, 'kWh'),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(50);
|
||||
});
|
||||
|
||||
it('applies multiplier correctly', () => {
|
||||
const meterValues: MeterValueDto[] = [
|
||||
{
|
||||
timestamp: '2025-05-29T12:01:00Z',
|
||||
sampledValue: [
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
value: 100,
|
||||
unitOfMeasure: { unit: 'Wh', multiplier: 3 }, // multiplier=3 means *1000, so 100kWh
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamp: '2025-05-29T12:02:00Z',
|
||||
sampledValue: [
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
value: 200,
|
||||
unitOfMeasure: { unit: 'Wh', multiplier: 3 }, // 200kWh
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(100); // 200kWh - 100kWh = 100kWh
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 0 for empty meter values', () => {
|
||||
expect(MeterValueUtils.getTotalKwh([], 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when all values have invalid contexts', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:01:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
100,
|
||||
'kWh',
|
||||
'Other',
|
||||
),
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:02:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
200,
|
||||
'kWh',
|
||||
'Other',
|
||||
),
|
||||
];
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMeterStart', () => {
|
||||
it('returns the first register value', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:03:00Z', 'Energy.Active.Import.Register', 300),
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 100),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Register', 200),
|
||||
];
|
||||
expect(MeterValueUtils.getMeterStart(meterValues)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns null for empty meter values', () => {
|
||||
expect(MeterValueUtils.getMeterStart([])).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null when there are no register values', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Interval', 50),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Interval', 60),
|
||||
];
|
||||
expect(MeterValueUtils.getMeterStart(meterValues)).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null when all values have invalid contexts', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue(
|
||||
'2025-05-29T12:01:00Z',
|
||||
'Energy.Active.Import.Register',
|
||||
100,
|
||||
'kWh',
|
||||
'Other',
|
||||
),
|
||||
];
|
||||
expect(MeterValueUtils.getMeterStart(meterValues)).toBe(null);
|
||||
});
|
||||
|
||||
it('normalizes units when getting meter start', () => {
|
||||
const meterValues = [
|
||||
makeMeterValue('2025-05-29T12:01:00Z', 'Energy.Active.Import.Register', 5000, 'Wh'),
|
||||
makeMeterValue('2025-05-29T12:02:00Z', 'Energy.Active.Import.Register', 10000, 'Wh'),
|
||||
];
|
||||
expect(MeterValueUtils.getMeterStart(meterValues)).toBe(5); // 5000 Wh = 5 kWh
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phased values', () => {
|
||||
it('sums L1, L2, L3 phased values for register measurand', () => {
|
||||
const meterValues: MeterValueDto[] = [
|
||||
{
|
||||
timestamp: '2025-05-29T12:01:00Z',
|
||||
sampledValue: [
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L1',
|
||||
value: 10,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L2',
|
||||
value: 20,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L3',
|
||||
value: 30,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamp: '2025-05-29T12:02:00Z',
|
||||
sampledValue: [
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L1',
|
||||
value: 20,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L2',
|
||||
value: 40,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L3',
|
||||
value: 60,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// First reading: 10 + 20 + 30 = 60
|
||||
// Second reading: 20 + 40 + 60 = 120
|
||||
// Difference: 120 - 60 = 60
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(60);
|
||||
});
|
||||
|
||||
it('falls back to L1-N, L2-N, L3-N phased values', () => {
|
||||
const meterValues: MeterValueDto[] = [
|
||||
{
|
||||
timestamp: '2025-05-29T12:01:00Z',
|
||||
sampledValue: [
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L1-N',
|
||||
value: 10,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L2-N',
|
||||
value: 20,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L3-N',
|
||||
value: 30,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamp: '2025-05-29T12:02:00Z',
|
||||
sampledValue: [
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L1-N',
|
||||
value: 50,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L2-N',
|
||||
value: 60,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
{
|
||||
measurand: 'Energy.Active.Import.Register',
|
||||
phase: 'L3-N',
|
||||
value: 70,
|
||||
unitOfMeasure: { unit: 'kWh', multiplier: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// First: 10 + 20 + 30 = 60, Second: 50 + 60 + 70 = 180
|
||||
expect(MeterValueUtils.getTotalKwh(meterValues, 0)).toBe(120); // 180 - 60
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user