Merge pull request #6259 from mempool/knorrium/calc_precision

🧮 Calculator improvements
This commit is contained in:
mononaut 2026-02-18 17:29:05 +09:00 committed by GitHub
commit e22902f861
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 378 additions and 20 deletions

View file

@ -0,0 +1,274 @@
import { emitMempoolInfo, receiveWebSocketMessageFromServer } from '../../support/websocket';
const calculatorBaseModule = Cypress.env('BASE_MODULE');
const MOCK_BTC_PRICE_USD = 123456;
const MOCK_BTC_PRICE_JPY = 11057757;
describe('Calculator', () => {
beforeEach(() => {
cy.mockMempoolSocketV2();
cy.visit('/tools/calculator');
emitMempoolInfo({
params: {
command: 'init',
waitForMempoolBlocks: false
}
});
cy.get('input[formControlName="bitcoin"]', { timeout: 15000 }).should('be.visible');
receiveWebSocketMessageFromServer({
params: {
message: {
contents: `{"conversions": { "time": 1770429602, "USD": ${MOCK_BTC_PRICE_USD}, "EUR": 59711, "GBP": 51810, "CAD": 96567, "CHF": 54834, "AUD": 100897, "JPY": ${MOCK_BTC_PRICE_JPY} }}`
}
}
});
cy.get('.symbol', { timeout: 10000 }).should(($el) => {
expect($el.text().replace(/,/g, '')).to.include(String(MOCK_BTC_PRICE_USD));
});
});
if (calculatorBaseModule === 'mempool') {
describe('page load and initial state', () => {
it('loads the calculator page with heading and form', () => {
cy.get('h2').should('contain', 'Calculator');
cy.contains('Waiting for price feed...').should('not.exist');
cy.get('input[formControlName="fiat"]').should('be.visible');
cy.get('input[formControlName="bitcoin"]').should('be.visible');
cy.get('input[formControlName="satoshis"]').should('be.visible');
});
it('displays the mocked conversion rate in .symbol', () => {
cy.get('.symbol').invoke('text').then((text) => {
expect(text.replace(/,/g, '')).to.include(String(MOCK_BTC_PRICE_USD));
});
});
it('shows copy buttons for each input', () => {
cy.get('app-clipboard').should('have.length', 3);
cy.get('app-clipboard').each(($el) => {
cy.wrap($el).should('be.visible');
});
});
it('shows fiat price display and bitcoin visual', () => {
cy.contains('Fiat price last updated').should('be.visible');
cy.get('.bitcoin-satoshis-text').should('be.visible');
cy.get('.bitcoin-satoshis-text').should('contain', '₿');
cy.get('.fiat-text').should('be.visible');
});
it('shows input labels for currency, BTC, and sats', () => {
cy.get('.input-group-text').contains('BTC').should('be.visible');
cy.get('.input-group-text').contains('sats').should('be.visible');
});
});
describe('default values', () => {
it('displays 1 BTC with correct sats and fiat', () => {
cy.get('input[formControlName="bitcoin"]').invoke('val').then((btcVal) => {
expect(parseFloat(String(btcVal))).to.equal(1);
});
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '100000000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(MOCK_BTC_PRICE_USD);
});
});
});
describe('bitcoin input updates fiat and sats', () => {
it('updates fiat and sats when entering 0.5 BTC', () => {
const expectedFiat = Math.round(MOCK_BTC_PRICE_USD * 0.5 * 100) / 100;
cy.get('input[formControlName="bitcoin"]').clear().type('0.5');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '50000000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(expectedFiat);
});
});
it('updates fiat and sats when entering 1 sat (0.00000001 BTC)', () => {
const expectedFiat = (MOCK_BTC_PRICE_USD / 100_000_000 * 100 / 100).toFixed(8);
cy.get('input[formControlName="bitcoin"]').clear().type('0.00000001');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '1');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
expect(String(fiatVal)).to.equal(expectedFiat);
});
});
});
describe('fiat input updates BTC and sats', () => {
it('updates BTC and sats when entering fiat value', () => {
const fiatAmount = 100;
const expectedBtc = parseFloat((fiatAmount / MOCK_BTC_PRICE_USD).toFixed(8));
const expectedSats = Math.round((fiatAmount / MOCK_BTC_PRICE_USD) * 100_000_000);
cy.get('input[formControlName="fiat"]').clear().type(String(fiatAmount));
cy.get('input[formControlName="bitcoin"]').invoke('val').then((btcVal) => {
expect(parseFloat(String(btcVal))).to.equal(expectedBtc);
});
cy.get('input[formControlName="satoshis"]').invoke('val').then((satsVal) => {
expect(parseInt(String(satsVal), 10)).to.equal(expectedSats);
});
});
});
describe('satoshis input updates BTC and fiat', () => {
it('updates BTC and fiat when entering 10000 sats', () => {
const satsAmount = 10000;
const expectedFiat = Math.round((satsAmount / 100_000_000) * MOCK_BTC_PRICE_USD * 100) / 100;
cy.get('input[formControlName="satoshis"]').clear().type(String(satsAmount));
cy.get('input[formControlName="bitcoin"]').invoke('val').should('equal', '0.00010000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(expectedFiat);
});
});
});
describe('input sanitization', () => {
it('normalizes comma to dot in fiat input', () => {
cy.get('input[formControlName="fiat"]').clear().type('1,5');
cy.get('input[formControlName="fiat"]').invoke('val').then((val) => {
expect(String(val)).to.match(/^1\.5/);
});
});
it('limits BTC to 8 decimals', () => {
cy.get('input[formControlName="bitcoin"]').clear().type('1.123456789');
cy.get('input[formControlName="bitcoin"]').invoke('val').then((val) => {
const parts = String(val).split('.');
expect(parts.length).to.be.lte(2);
if (parts[1]) {
expect(parts[1].length).to.be.lte(8);
}
});
});
it('strips decimals from satoshis input', () => {
cy.get('input[formControlName="satoshis"]').clear().type('10000.99');
cy.get('input[formControlName="satoshis"]').invoke('val').then((val) => {
expect(String(val)).not.to.include('.');
});
});
});
describe('max supply (21M BTC)', () => {
it('shows warning when entering 21M BTC', () => {
cy.get('input[formControlName="bitcoin"]').clear().type('21000000');
cy.get('.alert.alert-warning').should('be.visible');
cy.get('.alert.alert-warning').should('contain', 'Values were capped at the max supply of 21M BTC');
cy.get('input[formControlName="bitcoin"]').invoke('val').should('equal', '21000000');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '2100000000000000');
});
it('caps values at max supply', () => {
cy.get('input[formControlName="bitcoin"]').clear().type('25000000');
cy.get('.alert.alert-warning').should('be.visible');
cy.get('input[formControlName="bitcoin"]').invoke('val').should('equal', '21000000');
});
});
describe('clipboard buttons', () => {
it('copy buttons exist and are visible', () => {
cy.get('app-clipboard').should('have.length', 3);
cy.get('app-clipboard button, app-clipboard .btn').each(($btn) => {
cy.wrap($btn).should('be.visible');
});
});
});
describe('responsive viewports', () => {
it('calculator is usable on desktop', () => {
cy.viewport('macbook-16');
cy.get('input[formControlName="bitcoin"]').should('be.visible');
cy.get('input[formControlName="bitcoin"]').clear().type('1');
cy.get('input[formControlName="bitcoin"]').invoke('val').should('include', '1');
});
it('calculator is usable on mobile', () => {
cy.viewport('iphone-6');
cy.get('input[formControlName="bitcoin"]').should('be.visible');
cy.get('input[formControlName="fiat"]').should('be.visible');
cy.get('input[formControlName="satoshis"]').should('be.visible');
});
});
describe('loading state', () => {
it('shows calculator form after price feed loads', () => {
cy.contains('Waiting for price feed...').should('not.exist');
cy.get('input[formControlName="bitcoin"]').should('be.visible');
});
});
describe('JPY currency', () => {
beforeEach(() => {
cy.get('app-fiat-selector').scrollIntoView();
cy.get('app-fiat-selector select').select('JPY');
cy.get('.symbol', { timeout: 10000 }).should(($el) => {
expect($el.text().replace(/,/g, '')).to.include(String(MOCK_BTC_PRICE_JPY));
});
});
it('displays JPY conversion rate in .symbol', () => {
cy.get('.symbol').invoke('text').then((text) => {
expect(text.replace(/,/g, '')).to.include(String(MOCK_BTC_PRICE_JPY));
});
});
it('displays 1 BTC with correct sats and fiat in JPY', () => {
cy.get('input[formControlName="bitcoin"]').invoke('val').then((btcVal) => {
expect(parseFloat(String(btcVal))).to.equal(1);
});
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '100000000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(MOCK_BTC_PRICE_JPY);
});
});
it('updates fiat and sats when entering 0.5 BTC in JPY', () => {
const expectedFiat = Math.round(MOCK_BTC_PRICE_JPY * 0.5 * 100) / 100;
cy.get('input[formControlName="bitcoin"]').clear().type('0.5');
cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '50000000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(expectedFiat);
});
});
it('updates BTC and sats when entering fiat value in JPY', () => {
const fiatAmount = 1000000;
const expectedBtc = parseFloat((fiatAmount / MOCK_BTC_PRICE_JPY).toFixed(8));
const expectedSats = Math.round((fiatAmount / MOCK_BTC_PRICE_JPY) * 100_000_000);
cy.get('input[formControlName="fiat"]').clear().type(String(fiatAmount));
cy.get('input[formControlName="bitcoin"]').invoke('val').then((btcVal) => {
expect(parseFloat(String(btcVal))).to.equal(expectedBtc);
});
cy.get('input[formControlName="satoshis"]').invoke('val').then((satsVal) => {
expect(parseInt(String(satsVal), 10)).to.equal(expectedSats);
});
});
it('updates BTC and fiat when entering 10000 sats in JPY', () => {
const satsAmount = 10000;
const expectedFiat = Math.round((satsAmount / 100_000_000) * MOCK_BTC_PRICE_JPY * 100) / 100;
cy.get('input[formControlName="satoshis"]').clear().type(String(satsAmount));
cy.get('input[formControlName="bitcoin"]').invoke('val').should('equal', '0.00010000');
cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => {
const fiat = parseFloat(String(fiatVal).replace(/,/g, ''));
expect(fiat).to.equal(expectedFiat);
});
});
});
} else {
it.skip(`Tests cannot be run on the selected BASE_MODULE ${calculatorBaseModule}`);
}
});

View file

@ -32,7 +32,7 @@ export const mockWebSocketV2 = () => {
const winWebSocket = win.WebSocket;
cy.stub(win, 'WebSocket').callsFake((url) => {
console.log(url);
if ((new URL(url).pathname.indexOf('/sockjs-node/') !== 0)) {
if ((new URL(url).pathname.indexOf('/sockjs-node/') !== 0) && (new URL(url).pathname.indexOf('/ng-cli-ws') !== 0)) {
const { server, websocket } = createMock(url);
win.mockServer = server;
@ -117,10 +117,16 @@ export const receiveWebSocketMessageFromServer = ({
};
const MOCK_SOCKET_WAIT_TIMEOUT_MS = 5000;
export const emitMempoolInfo = ({
params
}: { params?: any } = {}) => {
cy.window().then((win) => {
cy.window({ timeout: MOCK_SOCKET_WAIT_TIMEOUT_MS })
.should((win) => {
expect(win.mockSocket, 'mockSocket to be set (app should open WebSocket within timeout)').to.not.be.undefined;
})
.then((win) => {
//TODO: Refactor to take into account different parameterized mocking scenarios
switch (params.network) {
//TODO: Use network specific mocks
@ -133,7 +139,6 @@ export const emitMempoolInfo = ({
switch (params.command) {
case 'init': {
win.mockSocket.send('{"conversions":{"USD":32365.338815782445}}');
cy.readFile('cypress/fixtures/mainnet_live2hchart.json', 'utf-8').then((fixture) => {
win.mockSocket.send(JSON.stringify(fixture));
});
@ -159,7 +164,11 @@ export const emitMempoolInfo = ({
}
});
cy.waitForSkeletonGone();
return cy.get('#mempool-block-0');
if (!params.waitForMempoolBlocks) {
return
} else {
return cy.get('#mempool-block-0');
}
};
export const dropWebSocket = (() => {

View file

@ -12,7 +12,7 @@
<div class="input-group-prepend">
<span class="input-group-text">{{ currency$ | async }}</span>
</div>
<input type="text" inputmode="numeric" class="form-control" formControlName="fiat" (input)="transformInput('fiat')" (click)="selectAll($event)">
<input type="text" inputmode="decimal" class="form-control" formControlName="fiat" (input)="transformInput('fiat')" (click)="selectAll($event)">
<app-clipboard [button]="true" [text]="form.get('fiat').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
</div>
@ -20,7 +20,7 @@
<div class="input-group-prepend">
<span class="input-group-text">BTC</span>
</div>
<input type="text" inputmode="numeric" class="form-control" formControlName="bitcoin" (input)="transformInput('bitcoin')" (click)="selectAll($event)">
<input type="text" inputmode="decimal" class="form-control" formControlName="bitcoin" (input)="transformInput('bitcoin')" (click)="selectAll($event)">
<app-clipboard [button]="true" [text]="form.get('bitcoin').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
</div>
@ -35,6 +35,12 @@
</div>
<div class="row justify-content-center mt-3" *ngIf="isMaxSupply">
<div class="alert alert-warning" role="alert" i18n="calculator.max-supply-warning|Max supply warning message">
Values were capped at the max supply of 21M BTC
</div>
</div>
<br>
<div class="row justify-content-center">
@ -47,13 +53,13 @@
<div class="row justify-content-center">
<div class="fiat-text">
<app-fiat [value]="form.get('satoshis').value" digitsInfo="1.0-0"></app-fiat>
<app-fiat [value]="form.get('satoshis').value" digitsInfo="1.2-2"></app-fiat>
</div>
</div>
<div class="row justify-content-center mt-3">
<div class="symbol">
Fiat price last updated <app-time kind="since" [time]="lastFiatPrice$ | async" [fastRender]="true"></app-time>
Fiat price last updated <app-time kind="since" [time]="lastFiatPrice$ | async" [fastRender]="true"></app-time>: {{ price$ | async | fiatCurrency : '1.0-0' : (currency$ | async) }}
</div>
</div>

View file

@ -5,6 +5,9 @@ import { map, switchMap } from 'rxjs/operators';
import { StateService } from '@app/services/state.service';
import { WebsocketService } from '@app/services/websocket.service';
const MAX_BTC_SUPPLY = 21000000;
const MAX_SATOSHI_SUPPLY = MAX_BTC_SUPPLY * 100_000_000;
@Component({
selector: 'app-calculator',
templateUrl: './calculator.component.html',
@ -15,6 +18,8 @@ import { WebsocketService } from '@app/services/websocket.service';
export class CalculatorComponent implements OnInit {
satoshis = 10000;
form: FormGroup;
currentPrice = 0;
isMaxSupply = false;
currency$ = this.stateService.fiatCurrency$;
price$: Observable<number>;
@ -53,12 +58,23 @@ export class CalculatorComponent implements OnInit {
this.price$,
this.form.get('fiat').valueChanges
]).subscribe(([price, value]) => {
const rate = (value / price).toFixed(8);
const satsRate = Math.round(value / price * 100_000_000);
this.currentPrice = price;
const maxFiat = price * MAX_BTC_SUPPLY;
const isMaxSupply = value >= maxFiat;
this.isMaxSupply = isMaxSupply;
if (isMaxSupply) {
value = maxFiat;
this.form.get('fiat').setValue(this.formatFiat(value), { emitEvent: false });
}
let rate = parseFloat((value / price).toFixed(8));
if (rate >= MAX_BTC_SUPPLY) {
rate = MAX_BTC_SUPPLY;
}
const satsRate = Math.round(rate * 100_000_000);
if (isNaN(value)) {
return;
}
this.form.get('bitcoin').setValue(rate, { emitEvent: false });
this.form.get('bitcoin').setValue(isMaxSupply ? MAX_BTC_SUPPLY.toString() : rate.toFixed(8), { emitEvent: false });
this.form.get('satoshis').setValue(satsRate, { emitEvent: false } );
});
@ -66,24 +82,36 @@ export class CalculatorComponent implements OnInit {
this.price$,
this.form.get('bitcoin').valueChanges
]).subscribe(([price, value]) => {
this.currentPrice = price;
const isMaxSupply = parseFloat(value) >= MAX_BTC_SUPPLY;
this.isMaxSupply = isMaxSupply;
const rate = parseFloat((value * price).toFixed(8));
if (isNaN(value)) {
return;
}
this.form.get('fiat').setValue(rate, { emitEvent: false } );
this.form.get('satoshis').setValue(Math.round(value * 100_000_000), { emitEvent: false } );
this.form.get('fiat').setValue(this.formatFiat(rate), { emitEvent: false } );
this.form.get('satoshis').setValue(Math.min(Math.round(value * 100_000_000), MAX_SATOSHI_SUPPLY), { emitEvent: false } );
});
combineLatest([
this.price$,
this.form.get('satoshis').valueChanges
]).subscribe(([price, value]) => {
const rate = parseFloat((value / 100_000_000 * price).toFixed(8));
const bitcoinRate = (value / 100_000_000).toFixed(8);
this.currentPrice = price;
let bitcoinValue = value / 100_000_000;
const isMaxSupply = bitcoinValue >= MAX_BTC_SUPPLY;
this.isMaxSupply = isMaxSupply;
if (isMaxSupply) {
bitcoinValue = MAX_BTC_SUPPLY;
value = MAX_SATOSHI_SUPPLY;
this.form.get('satoshis').setValue(value, { emitEvent: false });
}
const rate = parseFloat((bitcoinValue * price).toFixed(8));
const bitcoinRate = isMaxSupply ? MAX_BTC_SUPPLY.toString() : bitcoinValue.toFixed(8);
if (isNaN(value)) {
return;
}
this.form.get('fiat').setValue(rate, { emitEvent: false } );
this.form.get('fiat').setValue(this.formatFiat(rate), { emitEvent: false } );
this.form.get('bitcoin').setValue(bitcoinRate, { emitEvent: false });
});
@ -104,12 +132,21 @@ export class CalculatorComponent implements OnInit {
if (name === 'bitcoin' && this.countDecimals(sanitizedValue) > 8) {
sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 8);
}
if (name === 'fiat' && this.countDecimals(sanitizedValue) > 2) {
sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 2);
}
if (sanitizedValue === '') {
sanitizedValue = '0';
}
if (name === 'satoshis') {
sanitizedValue = parseFloat(sanitizedValue).toFixed(0);
}
if (name === 'bitcoin' && parseFloat(sanitizedValue) >= MAX_BTC_SUPPLY) {
sanitizedValue = MAX_BTC_SUPPLY.toString();
}
if (name === 'satoshis' && parseFloat(sanitizedValue) > MAX_SATOSHI_SUPPLY) {
sanitizedValue = MAX_SATOSHI_SUPPLY.toString();
}
formControl.setValue(sanitizedValue, {emitEvent: true});
}
@ -137,4 +174,22 @@ export class CalculatorComponent implements OnInit {
selectAll(event): void {
event.target.select();
}
formatFiat(num: number): string | number {
if (Math.abs(num) >= 1000) {
// For values >= 1000: show 2 decimals, or 0 if whole number
if (num % 1 === 0) {
return Math.round(num);
}
return (Math.round(num * 100) / 100).toFixed(2);
}
if (num % 1 === 0) {
return Math.round(num);
}
// For small values (< 1), show more precision
if (Math.abs(num) < 1 && num !== 0) {
return num.toFixed(8);
}
return (Math.round(num * 100) / 100).toFixed(2);
}
}

View file

@ -10,7 +10,8 @@ export class BitcoinsatoshisPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) { }
transform(value: string, firstPartClass?: string): SafeHtml {
const newValue = this.insertSpaces(parseFloat(value || '0').toFixed(8));
const numValue = parseFloat(value || '0');
const newValue = this.insertSpaces(numValue.toFixed(8));
const position = (newValue || '0').search(/[1-9]/);
const firstPart = newValue.slice(0, position);
@ -22,8 +23,17 @@ export class BitcoinsatoshisPipe implements PipeTransform {
}
insertSpaces(str: string): string {
const length = str.length;
return str.slice(0, length - 6) + ' ' + str.slice(length - 6, length - 3) + ' ' + str.slice(length - 3);
const [integerPart, decimalPart] = str.split('.');
// Format integer part with thousand separators (right to left)
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
// Format decimal part: first 2 digits, then groups of 3
const formattedDecimal = decimalPart.slice(0, 2) + ' ' +
decimalPart.slice(2, 5) + ' ' +
decimalPart.slice(5);
return formattedInteger + '.' + formattedDecimal;
}
}

View file

@ -25,7 +25,11 @@ export class FiatCurrencyPipe implements PipeTransform {
const currency = args[1] || this.currency || 'USD';
if (Math.abs(num) >= 1000) {
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(num);
// Check if decimals are exactly 0
if (num % 1 === 0) {
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(num);
}
return new Intl.NumberFormat(this.locale, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
} else {
return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num);
}