energetix
Version:
A reactive energy management library using dagify for managing state and updates.
373 lines (320 loc) • 13.5 kB
JavaScript
import {test, solo} from "brittle";
import {
energyConsumer,
energyProducer,
simpleConsumptionStrategy,
simpleProductionStrategy
} from "../lib/strategies/index.js";
import {createNode, createSinkNode, createTrigger, identity, NO_EMIT, trigger} from "dagify";
import {sleep} from "./helpers/sleep.js";
import {createEnergyNode} from "../lib/state-nodes/index.js";
import {simpleConsumptionHandler, simpleProductionHandler} from "../lib/handlers/index.js";
// Helper to set the node's type after creation.
// If the current value does not satisfy the new type, it resets the internal value to NO_EMIT.
function setType(node, validator) {
node.type = validator;
if (!validator(node.value)) {
// Eradicate the current value because it doesn't match new type.
node._value = NO_EMIT;
}
return node;
}
test("energyNode typed node ensures it can only be a whole positive number", async t => {
const n = createEnergyNode(100);
t.is(n.value, 100, "Initial valid value should be accepted");
// Test negative update: value remains unchanged.
n.set(-123);
await sleep();
t.is(n.value, 100, "Negative value should be rejected");
// Test non-integer update: value remains unchanged.
n.set(10.23);
await sleep();
t.is(n.value, 100, "Non-whole number should be rejected");
// Test valid update.
n.set(1000);
t.is(n.value, 1000, "Valid whole positive number should update");
// Additional robust check: multiple invalid updates in a row.
n.set(-50);
n.set(12.5);
await sleep();
t.is(n.value, 1000, "Multiple invalid updates should leave the value unchanged");
});
test("Apply Damage (Consume Energy)", async t => {
// In this scenario, the energy node represents player health.
// The consumer node subtracts incoming damage from health.
const health = createEnergyNode(100);
// The context now represents incoming damage (and includes a function for reporting).
const context = createNode({damage: 0, fn: () => "damage applied"});
// energyConsumer creates a decision node using a strategy function.
// Here, if damage > 0, it returns a decision object with the damage amount.
const damageConsumer = energyConsumer(health, context, (energy, context) => {
if (context.damage > 0) {
return {
energyCost: context.damage,
action: "damage",
fn: context.fn
};
} else return NO_EMIT;
});
let damageCount = 0;
damageConsumer.subscribe(({energyCost, action, fn}) => {
if (action === "damage") {
// Apply the damage by subtracting the energyCost.
health.update(val => val - energyCost);
damageCount++;
t.is(fn(), "damage applied", "fn() should return 'damage applied'");
}
});
await sleep();
t.is(health.value, 100, "Initially, health remains at 100 (no damage)");
// Update context with damage of 20.
context.update(ctx => ({...ctx, damage: 20}));
await sleep();
t.is(health.value, 80, "Health should drop to 80 after 20 damage");
t.is(damageCount, 1, "Damage action should have fired once");
// Now, simulate additional damage by increasing the damage value.
context.update(ctx => ({...ctx, damage: 30}));
await sleep();
t.is(health.value, 50, "Health should drop further to 50 after additional 30 damage");
t.is(damageCount, 2, "Damage action should have fired twice");
});
test("Regenerate Shields (Produce Energy)", async t => {
// In this scenario, the energy node represents player shields.
// The producer node adds regeneration energy (i.e. shield points).
const shields = createEnergyNode(0);
// Here, the context represents a regeneration event.
const context = createNode({regen: 0, fn: () => "regen applied"});
// energyProducer creates a decision node using the production strategy function.
// When context.regen is greater than 0, the decision returns an energy gain.
const regenProducer = energyProducer(shields, context, (energy, context) => {
if (context.regen > 0) {
return {
energyGain: context.regen,
action: "regen",
fn: context.fn
};
} else return NO_EMIT;
});
let regenCount = 0;
regenProducer.subscribe(({energyGain, action, fn}) => {
if (action === "regen") {
// Apply regeneration by adding the energyGain.
shields.update(val => val + energyGain);
regenCount++;
t.is(fn(), "regen applied", "fn() should return 'regen applied'");
}
});
await sleep();
t.is(shields.value, 0, "Initially, shields are 0");
// Update context with a regeneration event of 15 points.
context.update(ctx => ({...ctx, regen: 15}));
await sleep();
t.is(shields.value, 15, "Shields should increase to 15 after regen applied");
t.is(regenCount, 1, "Regen action should have fired once");
// Now, update with a different regeneration value.
context.update(ctx => ({...ctx, regen: 10}));
await sleep();
t.is(shields.value, 25, "Shields should increase cumulatively to 25");
t.is(regenCount, 2, "Regen action should have fired twice");
});
test("Dynamic type setting resets invalid value", async t => {
// Create a node with an initial valid string value.
const node = createNode("hello world");
t.is(node.value, "hello world", "Initial string value is valid");
// Dynamically set the node's type to require a number.
setType(node, value => typeof value === "number");
await sleep();
t.is(node.value, NO_EMIT, "Node value resets to NO_EMIT when it does not match new type");
// Now update with a valid number.
node.set(42);
t.is(node.value, 42, "Valid number update should be accepted");
// Try an invalid update: string.
node.set("not a number");
await sleep();
t.is(node.value, 42, "Invalid update after type set should leave value unchanged");
});
test("Multiple valid and invalid updates maintain consistent state", async t => {
// Create a node with a number type enforced.
const node = createNode(10);
// Set the type to allow only positive integers.
setType(node, value => Number.isInteger(value) && value > 0);
await sleep();
t.is(node.value, 10, "Initial value is valid");
// Update with another valid value.
node.set(20);
t.is(node.value, 20, "Valid update accepted");
// Try an invalid update: a negative integer.
node.set(-5);
await sleep();
t.is(node.value, 20, "Negative update rejected");
// Try an invalid update: a decimal.
node.set(15.5);
await sleep();
t.is(node.value, 20, "Decimal update rejected");
// Valid update again.
node.set(30);
t.is(node.value, 30, "Valid update accepted again");
});
test("Simple Consumption Strategy and Handler", async t => {
// Create an energy node with initial energy of 100.
const energy = createEnergyNode(100);
// Create a context node that indicates a consumption event (cost of 30).
const context = createNode({cost: 30});
// Create a consumer strategy node using the simple consumption strategy.
const consumerStrategyNode = energyConsumer(energy, context, simpleConsumptionStrategy);
// Attach the consumption handler to update the energy node.
simpleConsumptionHandler(energy, consumerStrategyNode);
await sleep();
t.is(energy.value, 70, "Energy should reduce by 30 to 70");
// Test when the cost is zero (should emit NO_EMIT and not change energy)
context.set({cost: 0});
await sleep();
t.is(energy.value, 70, "Energy should remain unchanged when cost is zero");
});
test("Simple Production Strategy and Handler", async t => {
// Create an energy node with initial energy of 50.
const energy = createEnergyNode(50);
// Create a context node that indicates a production event (gain of 20).
const context = createNode({gain: 20});
// Create a producer strategy node using the simple production strategy.
const producerStrategyNode = energyProducer(energy, context, simpleProductionStrategy);
// Attach the production handler to update the energy node.
simpleProductionHandler(energy, producerStrategyNode);
await sleep();
t.is(energy.value, 70, "Energy should increase by 20 to 70");
// Test when the gain is zero (should emit NO_EMIT and not change energy)
context.set({gain: 0});
await sleep();
t.is(energy.value, 70, "Energy should remain unchanged when gain is zero");
});
test("Combined Consumption and Production Handlers", async t => {
// Create an energy node with initial energy of 100.
const energy = createEnergyNode(100);
// Create two context nodes: one for consumption and one for production.
const consumptionContext = createNode({cost: 40});
const productionContext = createNode({gain: 25});
// Create strategy nodes.
const consumerStrategyNode = energyConsumer(energy, consumptionContext, simpleConsumptionStrategy);
const producerStrategyNode = energyProducer(energy, productionContext, simpleProductionStrategy);
// Attach both handlers.
simpleConsumptionHandler(energy, consumerStrategyNode);
simpleProductionHandler(energy, producerStrategyNode);
await sleep();
// Expected energy: 100 - 40 (consumed) + 25 (produced) = 85.
t.is(energy.value, 85, "Energy should be 85 after consumption and production");
});
test("Use a triggering mechanism to trigger production and consumption", async t => {
// Create an energy node representing the available power.
const power = createEnergyNode(100);
const results = [];
// Create appliance nodes with their respective power costs.
const refrigerator = createNode({name: "refrigerator", cost: 10});
const tv = createNode({name: "tv", cost: 4});
const light = createNode({name: "light", cost: 1});
// Compute the total power cost from all appliances.
const powerCost = createNode(
sources => sources.reduce((acc, {cost}) => acc + cost, 0),
[refrigerator, tv, light]
);
// Compute a boolean value to indicate if there's enough power.
const powerIsOn = createNode(
({cost, power}) => cost < power,
{power, cost: powerCost}
);
// House node aggregates power, cost, appliances, and power status.
const house = createNode(
identity,
{
power,
cost: powerCost,
appliances: [refrigerator, tv, light],
powerIsOn
}
);
// Create a trigger node to force re-evaluation.
const timeTrigger = createTrigger();
let runCount = 0;
// Create a consumer strategy node.
const consumer = energyConsumer(
power,
{house, timeTrigger: trigger(timeTrigger)},
(energy, {house: {cost, power}}) => ({energyCost: cost, action: "consume", power})
);
// Create a sink that applies consumption.
createSinkNode(
({energyCost, action}) => {
if (action === "consume") {
power.update(val => val - energyCost);
}
results.push(JSON.parse(JSON.stringify(house.value)));
},
consumer
);
// Use an interval to trigger updates.
const interval = setInterval(() => {
timeTrigger.next();
if (runCount++ > 10) clearInterval(interval);
// Capture a snapshot of the house node's value.
}, 10);
// Wait enough time for all triggers.
await sleep(1000);
// Verify that power has decreased from the initial value.
t.ok(power.value < 100, "Power should be reduced from its initial value");
t.alike(results, [ {
"power": 100, "cost": 15, "appliances": [{
"name": "refrigerator", "cost": 10
}, {
"name": "tv", "cost": 4
}, {
"name": "light", "cost": 1
}], "powerIsOn": true
}, {
"power": 85, "cost": 15, "appliances": [{
"name": "refrigerator", "cost": 10
}, {
"name": "tv", "cost": 4
}, {
"name": "light", "cost": 1
}], "powerIsOn": true
}, {
"power": 70, "cost": 15, "appliances": [{
"name": "refrigerator", "cost": 10
}, {
"name": "tv", "cost": 4
}, {
"name": "light", "cost": 1
}], "powerIsOn": true
}, {
"power": 70, "cost": 15, "appliances": [{
"name": "refrigerator", "cost": 10
}, {
"name": "tv", "cost": 4
}, {
"name": "light", "cost": 1
}], "powerIsOn": true
}, {
"power": 40, "cost": 15, "appliances": [{
"name": "refrigerator", "cost": 10
}, {
"name": "tv", "cost": 4
}, {
"name": "light", "cost": 1
}], "powerIsOn": true
}, {
"power": 25, "cost": 15, "appliances": [{
"name": "refrigerator", "cost": 10
}, {
"name": "tv", "cost": 4
}, {
"name": "light", "cost": 1
}], "powerIsOn": true
}, {
"power": 10, "cost": 15, "appliances": [{
"name": "refrigerator", "cost": 10
}, {
"name": "tv", "cost": 4
}, {
"name": "light", "cost": 1
}], "powerIsOn": false
}]);
});