Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions test-pnpm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"lint": "eslint '*/**/*.{ts,tsx}' --quiet",
"format": "prettier --write '*/**/*.{ts,tsx,js,json,md}'"
},
"bin": {
"uswitch": "./uswitch/uswitch.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
Expand Down
116 changes: 116 additions & 0 deletions test-pnpm/uswitch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# 🧾 **Objective**

[TOC]

Your **task** is to help make the customer’s decision easier by writing a program that prices plans on the marketaccording to how much energy is consumed.

------

## 🧮 **Pricing Logic Details**

The data in an **energy plan** looks like this:

```json
const energyPlan = {
"supplier": "sse",
"plan": "standard",
"rates": [
{"price": 13.5, "threshold": 150},
{"price": 11.1, "threshold": 100},
{"price": 10}
],
"standing_charge": 9
}
```

- ***Plans*** contain a set of rates that describe how much the customer will be charged for each **kilowatt-hour** (kWh) of energy that they use over the *year*.
- Additionally, plans **may** also include a daily standing charge. (`Daily charge = 365 * 9`)
- **Plans** may have more than one rate but all but the last rate will contain a threshold value.
- **Rates** are *ordered* and the last rate will always have no threshold. (`for const rate of rates`)
- **Thresholds** indicate the *quantity of energy* (up to and including) that may be consumed at that price during the **course of the year**.
- Rates without a threshold have no limit.
- **First test:** In the example above, the first 150kWh will be charged at 13.5p/kWh, the next 100kWh will be charged at 11.1p/kWh and all subsequent consumption will be charged at 10p/kWh.

**Note that**:

- Standing charge is a daily charge stated in pence exclusive of VAT and is applied regardless of consumption.
- VAT for Energy is rated at 5%.
- Prices are stated in pence and are exclusive of VAT. (`totalInPence + (totalInPence * 0.05`)

For the first part of this exercise, we would like you to write a function that takes in a plan and an annual usage amount (in kWh) and returns what a customer would be charged annually (in pounds, including VAT). (`calculateAnnualCost(energyPlan, 1000`)

For the example plan above, when a consumer uses 1000 kWH your function should return 146.16.

For `1000 kWh` of usage:

- First 150 kWh @ 13.5p = 2025p `calculateUsingThreshold`
- Next 100 kWh @ 11.1p = 1110p
- Remaining 750 kWh @ 10p = 7500p
- Standing charge = 9p/day × 365 = 3285p `calculateStandingChargeWithNoThreshold`
- Subtotal (before VAT) = 2025 + 1110 + 7500 + 3285 = 13,920p `calculateOrApplyVat`
- VAT @ 5% = 696p → Total = 146.16 **pounds** `convertPenceToPounds()`

(`expect(calculateAnnualCost(energyPlan, 1000)).toBe(146.16)`)

## 🧾 Output

In the next stages of this exercise, we’ll be handling **multiple plans**, and **multiple different usage amounts**.

- Your program will need to be able to **format the output** to be easily readable.

- In this step, you should extend your program to log out the price of a plan in the format `SUPPLIER,PLAN,TOTAL_COST. TOTAL_COST` should be in pounds, include VAT, and be given to two decimal places.

For the example plan above, when a consumer uses 1000 kWH your program should log out: `sse,standard,146.16`

**Note** that all rounding should be natural (i.e. 1.045 rounded to 2 decimal places is 1.05).

## 📁 **File-Based Input**

Energy providers send new plans to us on a regular basis. In this step, we would like you to extend your program to load plans from a file.

- Your interviewer will provide you with a json file containing a list of plans.

- Your program should take a single command line argument - the path of the file - and should log the price of
- each plan in the format above, assuming a usage of 1000 kWh.

For example, I should be able to run

`npx tsx uswitch.ts test/fixtures/plans.json`

```json
# package.json
"bin": {
"uswitch": "./uswitch/uswitch.ts"
},
❯ npx tsx uswitch/uswitch.ts uswitch/plans.json
sse,standard,146.16
```

## 🖥️ **Interactive Mode (stdin CLI)**

Different customers use different amounts of energy - we need to be able to tell the customer the price of each plan according to their own usage. In this step we would like you to extend your program to take input from **stdin**. It should accept **two commands**:

- **price ANNUAL_USAGE**
- For a given annual kWh consumption produces an annual inclusive of VAT price for all plans available on the market sorted by cheapest first and prints to stdout. Each plan will be printed on its own line in the format SUPPLIER,PLAN,TOTAL_COST. Total cost should be rounded to 2 decimal places, i.e. pounds mand pence.

- **exit** Leaves the program.
- So, for example, I should be able to run the program as follows:

```
$ uswitch plans.json
price 1000
sse,standard,146.16
price 1200
sse,standard,167.16
exit
```

# ✅ Summary of features now complete

| Feature | Description |
| ------------------------------------- | ------------------------------------------------------------ |
| **Step 1: Compute cost of a plan** | Calculates the total annual cost (in pounds, inc. 5% VAT) for a plan based on usage in kWh. |
| **Step 2: Output formatting** | Outputs results in the format: `SUPPLIER,PLAN,TOTAL_COST` with 2 decimal rounding. |
| **Step 3: Load plans from file** | Reads a JSON file of energy plans, computes cost for fixed usage (default: 1000kWh). |
| **Step 4: Interactive CLI via stdin** | Accepts user commands: `price USAGE` to show all plan prices sorted by cost, and `exit` to quit. |
| **Tests (unit + integration)** | All major features are covered by tests for correctness and integration. |
4 changes: 4 additions & 0 deletions test-pnpm/uswitch/applyVAT.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function applyVAT(amountInPence: number, vatRate: number): number {
const vat = amountInPence * vatRate;
return amountInPence + vat;
}
14 changes: 14 additions & 0 deletions test-pnpm/uswitch/calculateAnnualCost.should.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { calculateAnnualCost, EnergyPlan } from './calculateAnnualCost';

describe('calculateAnnualCost', () => {
test('should correctly price a plan with multiple rates and thresholds, standing charge, and include VAT', () => {
const plan = {
supplier: 'sse',
plan: 'standard',
rates: [{ price: 13.5, threshold: 150 }, { price: 11.1, threshold: 100 }, { price: 10 }],
standing_charge: 9,
} as EnergyPlan;

expect(calculateAnnualCost(plan, 1000)).toBe(146.16);
});
});
31 changes: 31 additions & 0 deletions test-pnpm/uswitch/calculateAnnualCost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { applyVAT } from './applyVAT';
import { calculateCostUsingRates } from './calculateCostUsingRates';
import { convertPenceToPounds } from './convertPenceToPounds';

export interface Rate {
price: number;
threshold?: number;
}

export interface EnergyPlan {
supplier: string;
plan: string;
rates: Rate[];
standing_charge: number;
}

const DAYS_PER_YEAR = 365;
const VAT_RATE = 5 / 100;

export function calculateAnnualCost(plan: EnergyPlan, usage: number): number {
const annualStandingCharge = DAYS_PER_YEAR * plan.standing_charge;
const cost = calculateCostUsingRates(plan, usage);
const totalInPence = cost + annualStandingCharge;
const totalWithVAT = applyVAT(totalInPence, VAT_RATE);
return convertPenceToPounds(totalWithVAT);
// Fake it until you make it (if you don't want to do the maths in the beginning)
// if (usage === 1000) {
// return 146.16;
// }
// return 167.16;
}
20 changes: 20 additions & 0 deletions test-pnpm/uswitch/calculateCostUsingRates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { EnergyPlan, Rate } from './calculateAnnualCost';

export function calculateCostUsingRates(plan: EnergyPlan, usageInKWH: number) {
let usage = usageInKWH;
let cost = 0;
for (const rate of plan.rates) {
if (rate.threshold) {
const used = Math.min(usage, rate.threshold!);
cost += calculateFlatRateUsage(used, rate);
usage -= used;
} else {
cost += calculateFlatRateUsage(usage, rate);
}
}
return cost;
}

function calculateFlatRateUsage(usage: number, rate: Rate) {
return usage * rate.price;
}
39 changes: 39 additions & 0 deletions test-pnpm/uswitch/commandHandler.should.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { EnergyPlan } from './calculateAnnualCost';
import { commandHandler } from './commandHandler';

describe('commandHandler', () => {
let plans: Array<EnergyPlan>;
beforeEach(() => {
plans = [
{
supplier: 'sse',
plan: 'standard',
rates: [{ price: 13.5, threshold: 150 }, { price: 11.1, threshold: 100 }, { price: 10 }],
standing_charge: 9,
} as EnergyPlan,
];
});
test('should return null when exiting', () => {
const output = commandHandler('exit');

expect(output).toBeNull();
});

test('should return null command is null', () => {
const output = commandHandler(null);

expect(output).toBeNull();
});

test('should return formatted prices based on price at a 1000', () => {
const output = commandHandler('price 1000', plans);

expect(output).toEqual(['sse,standard,146.16']);
});

test('should return formatted prices based on given prices', () => {
const output = commandHandler('price 1200', plans);

expect(output).toEqual(['sse,standard,167.16']);
});
});
18 changes: 18 additions & 0 deletions test-pnpm/uswitch/commandHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { EnergyPlan } from './calculateAnnualCost';
import { formatPlanPrice } from './formatPlanPrice';

export function commandHandler(
command: string,
plans: Array<EnergyPlan> | null = null,
): string[] | null {
if (command?.toLowerCase().startsWith('price')) {
const priceArray = command.split(/\s/);
const price = priceArray[1];
const usage = Number.parseInt(price);
return plans.map((plan) => formatPlanPrice(plan, usage));
// fake it until you make it :)
// if (price === '1000') return ['sse,standard,146.16'];
// if (price === '1200') return ['sse,standard,167.16'];
}
return null;
}
3 changes: 3 additions & 0 deletions test-pnpm/uswitch/convertPenceToPounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function convertPenceToPounds(totalWithVATInPence: number): number {
return totalWithVATInPence / 100;
}
17 changes: 17 additions & 0 deletions test-pnpm/uswitch/formatPlanPrice.should.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { EnergyPlan } from './calculateAnnualCost';
import { formatPlanPrice } from './formatPlanPrice';

describe('formatPlanPrice', () => {
test('should return the correct string for a given plan and usage', () => {
const plan = {
supplier: 'sse',
plan: 'standard',
rates: [{ price: 13.5, threshold: 150 }, { price: 11.1, threshold: 100 }, { price: 10 }],
standing_charge: 9,
} as EnergyPlan;

const actual = formatPlanPrice(plan, 1000);

expect(actual).toBe('sse,standard,146.16');
});
});
6 changes: 6 additions & 0 deletions test-pnpm/uswitch/formatPlanPrice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { calculateAnnualCost, EnergyPlan } from './calculateAnnualCost';

export function formatPlanPrice(energyPlan: EnergyPlan, usage: number): string {
const amountIncludingVat = calculateAnnualCost(energyPlan, usage);
return `${energyPlan.supplier},${energyPlan.plan},${amountIncludingVat.toFixed(2)}`;
}
12 changes: 12 additions & 0 deletions test-pnpm/uswitch/plans.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[
{
"supplier": "sse",
"plan": "standard",
"rates": [
{ "price": 13.5, "threshold": 150 },
{ "price": 11.1, "threshold": 100 },
{ "price": 10 }
],
"standing_charge": 9
}
]
13 changes: 13 additions & 0 deletions test-pnpm/uswitch/readPlansFromFile.should.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import path from 'node:path';
import { readPlansFromFile } from './readPlansFromFile';

describe('readPlansFromFile should', () => {
test('read energy plans from a file', async () => {
const filePath = path.join(__dirname, './plans.json');

const plans = await readPlansFromFile(filePath);

expect(plans.length).toBeGreaterThan(0);
expect(plans[0].supplier).toBe('sse');
});
});
7 changes: 7 additions & 0 deletions test-pnpm/uswitch/readPlansFromFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import fs from 'fs/promises';
import { EnergyPlan } from './calculateAnnualCost';

export async function readPlansFromFile(filePath: string): Promise<Array<EnergyPlan>> {
const raw = await fs.readFile(filePath, 'utf-8');
return JSON.parse(raw);
}
Loading