diff --git a/frontend/cypress/e2e/mainnet/calculator.spec.ts b/frontend/cypress/e2e/mainnet/calculator.spec.ts new file mode 100644 index 000000000..b09dff154 --- /dev/null +++ b/frontend/cypress/e2e/mainnet/calculator.spec.ts @@ -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}`); + } +}); diff --git a/frontend/cypress/support/websocket.ts b/frontend/cypress/support/websocket.ts index 7bdaff725..811d2d8de 100644 --- a/frontend/cypress/support/websocket.ts +++ b/frontend/cypress/support/websocket.ts @@ -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 = (() => { diff --git a/frontend/src/app/components/calculator/calculator.component.html b/frontend/src/app/components/calculator/calculator.component.html index e205479ee..8c5387b57 100644 --- a/frontend/src/app/components/calculator/calculator.component.html +++ b/frontend/src/app/components/calculator/calculator.component.html @@ -12,7 +12,7 @@
{{ currency$ | async }}
- + @@ -20,7 +20,7 @@
BTC
- + @@ -35,6 +35,12 @@ +
+ +
+
@@ -47,13 +53,13 @@
- +
- Fiat price last updated + Fiat price last updated : {{ price$ | async | fiatCurrency : '1.0-0' : (currency$ | async) }}
diff --git a/frontend/src/app/components/calculator/calculator.component.ts b/frontend/src/app/components/calculator/calculator.component.ts index 94732f86e..d38039d37 100644 --- a/frontend/src/app/components/calculator/calculator.component.ts +++ b/frontend/src/app/components/calculator/calculator.component.ts @@ -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; @@ -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); + } } diff --git a/frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts b/frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts index 5f1f97b40..15a515051 100644 --- a/frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts +++ b/frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts @@ -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; } } diff --git a/frontend/src/app/shared/pipes/fiat-currency.pipe.ts b/frontend/src/app/shared/pipes/fiat-currency.pipe.ts index 0b211d4cf..ffaef26d3 100644 --- a/frontend/src/app/shared/pipes/fiat-currency.pipe.ts +++ b/frontend/src/app/shared/pipes/fiat-currency.pipe.ts @@ -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); }