diff --git a/app/src/__mocks__/debug.ts b/app/src/__mocks__/debug.ts new file mode 100644 index 000000000..45d59bbde --- /dev/null +++ b/app/src/__mocks__/debug.ts @@ -0,0 +1,9 @@ +// export this mock function so the executions can be checked in tests +export const debugMock = jest.fn(); + +const debug = jest.fn(() => { + // return the exported mock above each time `debug()` is called + return debugMock; +}); + +export default debug; diff --git a/app/src/__stories__/ChannelBalance.stories.tsx b/app/src/__stories__/ChannelBalance.stories.tsx index 751692a45..0965fa98b 100644 --- a/app/src/__stories__/ChannelBalance.stories.tsx +++ b/app/src/__stories__/ChannelBalance.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { lndListChannelsOne } from 'util/tests/sampleData'; +import { Store, useStore } from 'store'; import { Channel } from 'store/models'; import ChannelBalance from 'components/loop/ChannelBalance'; @@ -9,27 +10,31 @@ export default { parameters: { centered: true }, }; -const getChannel = (ratio: number) => { - const channel = new Channel(lndListChannelsOne.channelsList[0]); +const getChannel = (store: Store, ratio: number) => { + const channel = new Channel(store, lndListChannelsOne.channelsList[0]); channel.localBalance = channel.capacity * ratio; channel.remoteBalance = channel.capacity * (1 - ratio); return channel; }; export const Good = () => { - return ; + const store = useStore(); + return ; }; export const Warn = () => { - return ; + const store = useStore(); + return ; }; export const Bad = () => { - return ; + const store = useStore(); + return ; }; export const Inactive = () => { - const channel = getChannel(0.45); + const store = useStore(); + const channel = getChannel(store, 0.45); channel.active = false; return ; }; diff --git a/app/src/__stories__/ChannelList.stories.tsx b/app/src/__stories__/ChannelList.stories.tsx index a90a17a5c..2b85133c5 100644 --- a/app/src/__stories__/ChannelList.stories.tsx +++ b/app/src/__stories__/ChannelList.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { observable } from 'mobx'; +import { observable, ObservableMap, values } from 'mobx'; +import { BalanceMode } from 'util/constants'; import { useStore } from 'store'; import { Channel } from 'store/models'; import ChannelList from 'components/loop/ChannelList'; @@ -10,22 +11,45 @@ export default { parameters: { contained: true }, }; +const channelSubset = (channels: ObservableMap) => { + const few = values(channels) + .slice(0, 20) + .reduce((result, c) => { + result[c.chanId] = c; + return result; + }, {} as Record); + return observable.map(few); +}; + export const NoChannels = () => { const store = useStore(); store.channelStore.channels = observable.map(); return ; }; -export const FewChannels = () => { - const store = useStore(); - const channels = store.channelStore.sortedChannels.slice(0, 10).reduce((result, c) => { - result[c.chanId] = c; - return result; - }, {} as Record); - store.channelStore.channels = observable.map(channels); +export const ReceiveMode = () => { + const { channelStore, settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.receive; + channelStore.channels = channelSubset(channelStore.channels); + return ; +}; + +export const SendMode = () => { + const { channelStore, settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.send; + channelStore.channels = channelSubset(channelStore.channels); + return ; +}; + +export const RoutingMode = () => { + const { channelStore, settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.routing; + channelStore.channels = channelSubset(channelStore.channels); return ; }; export const ManyChannels = () => { + const { settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.routing; return ; }; diff --git a/app/src/__stories__/ChannelRow.stories.tsx b/app/src/__stories__/ChannelRow.stories.tsx index 2685c755b..1bada109c 100644 --- a/app/src/__stories__/ChannelRow.stories.tsx +++ b/app/src/__stories__/ChannelRow.stories.tsx @@ -32,44 +32,48 @@ const renderStory = ( }; export const Good = () => { - const channel = new Channel(lndListChannels.channelsList[0]); - return renderStory(channel, { ratio: 0.59 }); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[0]); + return renderStory(channel, { ratio: 0.3 }); }; export const Warn = () => { - const channel = new Channel(lndListChannels.channelsList[1]); - return renderStory(channel, { ratio: 0.28 }); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[1]); + return renderStory(channel, { ratio: 0.5 }); }; export const Bad = () => { - const channel = new Channel(lndListChannels.channelsList[2]); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[2]); return renderStory(channel, { ratio: 0.91 }); }; export const Inactive = () => { - const channel = new Channel(lndListChannels.channelsList[3]); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[3]); channel.active = false; return renderStory(channel); }; export const Editable = () => { - const channel = new Channel(lndListChannels.channelsList[4]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[4]); store.buildSwapStore.startSwap(); return renderStory(channel); }; export const Selected = () => { - const channel = new Channel(lndListChannels.channelsList[5]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[5]); store.buildSwapStore.startSwap(); store.buildSwapStore.toggleSelectedChannel(channel.chanId); return renderStory(channel); }; export const Disabled = () => { - const channel = new Channel(lndListChannels.channelsList[6]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[6]); store.buildSwapStore.startSwap(); store.buildSwapStore.toggleSelectedChannel(channel.chanId); store.buildSwapStore.setDirection(SwapDirection.OUT); @@ -77,8 +81,8 @@ export const Disabled = () => { }; export const Dimmed = () => { - const channel = new Channel(lndListChannels.channelsList[6]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[6]); store.buildSwapStore.startSwap(); store.buildSwapStore.setDirection(SwapDirection.OUT); return renderStory(channel); diff --git a/app/src/__stories__/SettingsPage.stories.tsx b/app/src/__stories__/SettingsPage.stories.tsx new file mode 100644 index 000000000..0239a1a3f --- /dev/null +++ b/app/src/__stories__/SettingsPage.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Layout } from 'components/layout'; +import SettingsPage from 'components/settings/SettingsPage'; + +export default { + title: 'Pages/Settings', + component: SettingsPage, + parameters: { contained: true }, +}; + +export const Default = () => { + return ; +}; + +export const InsideLayout = () => { + return ( + + + + ); +}; diff --git a/app/src/__stories__/StoryWrapper.tsx b/app/src/__stories__/StoryWrapper.tsx index 436734f3d..cdeca1782 100644 --- a/app/src/__stories__/StoryWrapper.tsx +++ b/app/src/__stories__/StoryWrapper.tsx @@ -1,7 +1,9 @@ import React, { CSSProperties, useMemo } from 'react'; import { observer } from 'mobx-react-lite'; +import { BalanceMode, Unit } from 'util/constants'; import { sampleApiResponses } from 'util/tests/sampleData'; import { createStore, StoreProvider } from 'store'; +import { PersistentSettings } from 'store/stores/settingsStore'; import { Background } from 'components/common/base'; import { ThemeProvider } from 'components/theme'; @@ -16,8 +18,19 @@ const grpc = { }, }; -// Create a store that pulls data from the mock GRPC for stories -const createStoryStore = () => createStore(grpc); +// fake the AppStorage dependency so that settings aren't shared across stories +class StoryAppStorage { + set = () => undefined; + get = (): PersistentSettings => ({ + sidebarVisible: true, + unit: Unit.sats, + balanceMode: BalanceMode.receive, + }); +} + +// Create a store that pulls data from the mock GRPC and doesn't use +// the real localStorage to save settings +const createStoryStore = () => createStore(grpc, new StoryAppStorage()); /** * This component is used to wrap every story. It provides the app theme diff --git a/app/src/__stories__/Tile.stories.tsx b/app/src/__stories__/Tile.stories.tsx index 90972515d..f47676e90 100644 --- a/app/src/__stories__/Tile.stories.tsx +++ b/app/src/__stories__/Tile.stories.tsx @@ -23,5 +23,5 @@ export const WithArrowIcon = () => ( ); export const InboundLiquidity = () => ( - + ); diff --git a/app/src/__tests__/Pages.spec.tsx b/app/src/__tests__/Pages.spec.tsx index 999cb8df0..37b586c2b 100644 --- a/app/src/__tests__/Pages.spec.tsx +++ b/app/src/__tests__/Pages.spec.tsx @@ -19,4 +19,11 @@ describe('Pages Component', () => { expect(getByText('Loop History')).toBeInTheDocument(); expect(store.uiStore.page).toBe('history'); }); + + it('should display the Settings page', () => { + const { getByText, store } = render(); + store.uiStore.goToSettings(); + expect(getByText('Settings')).toBeInTheDocument(); + expect(store.uiStore.page).toBe('settings'); + }); }); diff --git a/app/src/__tests__/components/NodeStatus.spec.tsx b/app/src/__tests__/components/NodeStatus.spec.tsx index 4716e2f74..52463938c 100644 --- a/app/src/__tests__/components/NodeStatus.spec.tsx +++ b/app/src/__tests__/components/NodeStatus.spec.tsx @@ -15,7 +15,7 @@ describe('NodeStatus component', () => { it('should display the lightning balance', () => { const { getByText, store } = render(); store.nodeStore.wallet = { channelBalance: 123, walletBalance: 0 }; - expect(getByText('123 SAT')).toBeInTheDocument(); + expect(getByText('123 sats')).toBeInTheDocument(); }); it('should display the bitcoin balance', () => { diff --git a/app/src/__tests__/components/common/Range.spec.tsx b/app/src/__tests__/components/common/Range.spec.tsx index 1a37fd5ce..f8451642a 100644 --- a/app/src/__tests__/components/common/Range.spec.tsx +++ b/app/src/__tests__/components/common/Range.spec.tsx @@ -29,7 +29,7 @@ describe('Range component', () => { it('should display slider and value by default', () => { const { getByText, getByLabelText } = render(); expect(getByLabelText('range-slider')).toBeInTheDocument(); - expect(getByText('50 SAT')).toBeInTheDocument(); + expect(getByText('50 sats')).toBeInTheDocument(); }); it('should accept custom props', () => { @@ -41,9 +41,9 @@ describe('Range component', () => { showRadios: true, }); expect(getByLabelText('range-slider')).toBeInTheDocument(); - expect(getByText('5,000 SAT')).toBeInTheDocument(); - expect(getByText('2,500 SAT')).toBeInTheDocument(); - expect(getByText('7,500 SAT')).toBeInTheDocument(); + expect(getByText('5,000 sats')).toBeInTheDocument(); + expect(getByText('2,500 sats')).toBeInTheDocument(); + expect(getByText('7,500 sats')).toBeInTheDocument(); expect(getByText('Min')).toHaveAttribute('aria-checked', 'false'); expect(getByText('Max')).toHaveAttribute('aria-checked', 'false'); }); diff --git a/app/src/__tests__/components/layout/Layout.spec.tsx b/app/src/__tests__/components/layout/Layout.spec.tsx index a0020577c..3df99bc20 100644 --- a/app/src/__tests__/components/layout/Layout.spec.tsx +++ b/app/src/__tests__/components/layout/Layout.spec.tsx @@ -40,4 +40,15 @@ describe('Layout component', () => { expect(store.uiStore.page).toBe('loop'); expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); }); + + it('should navigate back to the Settings page', () => { + const { getByText, store } = render(); + expect(store.uiStore.page).toBe('loop'); + fireEvent.click(getByText('Settings')); + expect(store.uiStore.page).toBe('settings'); + expect(getByText('Settings').parentElement).toHaveClass('active'); + fireEvent.click(getByText('Lightning Loop')); + expect(store.uiStore.page).toBe('loop'); + expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); + }); }); diff --git a/app/src/__tests__/components/loop/ChannelBalance.spec.tsx b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx index 0f84d7f2a..24cc2d95f 100644 --- a/app/src/__tests__/components/loop/ChannelBalance.spec.tsx +++ b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx @@ -1,22 +1,24 @@ import React from 'react'; import { renderWithProviders } from 'util/tests'; import { lndListChannelsOne } from 'util/tests/sampleData'; +import { createStore } from 'store'; import { Channel } from 'store/models'; import ChannelBalance from 'components/loop/ChannelBalance'; describe('ChannelBalance component', () => { - const channel: Channel = new Channel(lndListChannelsOne.channelsList[0]); + let channel: Channel; const bgColor = (el: any) => window.getComputedStyle(el).backgroundColor; const width = (el: any) => window.getComputedStyle(el).width; - const shiftBalance = (channel: Channel, ratio: number) => { + const render = (ratio: number, active = true) => { + const store = createStore(); + channel = new Channel(store, lndListChannelsOne.channelsList[0]); channel.localBalance = channel.capacity * ratio; channel.remoteBalance = channel.capacity * (1 - ratio); - }; + channel.active = active; - const render = () => { - const result = renderWithProviders(); + const result = renderWithProviders(, store); const el = result.container.children[0]; return { ...result, @@ -27,27 +29,23 @@ describe('ChannelBalance component', () => { }; it('should display a good balance', () => { - shiftBalance(channel, 0.55); - channel.localBalance = channel.capacity * 0.55; - const { el, remote, local } = render(); + const { el, remote, local } = render(0.25); expect(el.children.length).toBe(3); - expect(width(local)).toBe('55%'); + expect(width(local)).toBe('25%'); expect(bgColor(local)).toBe('rgb(70, 232, 14)'); expect(bgColor(remote)).toBe('rgb(70, 232, 14)'); }); it('should display a warning balance', () => { - shiftBalance(channel, 0.72); - const { el, remote, local } = render(); + const { el, remote, local } = render(0.52); expect(el.children.length).toBe(3); - expect(width(local)).toBe('72%'); + expect(width(local)).toBe('52%'); expect(bgColor(local)).toBe('rgb(246, 107, 28)'); expect(bgColor(remote)).toBe('rgb(246, 107, 28)'); }); it('should display a bad balance', () => { - shiftBalance(channel, 0.93); - const { el, remote, local } = render(); + const { el, remote, local } = render(0.93); expect(el.children.length).toBe(3); expect(width(local)).toBe('93%'); expect(bgColor(local)).toBe('rgb(245, 64, 110)'); @@ -55,9 +53,7 @@ describe('ChannelBalance component', () => { }); it('should display an inactive channel', () => { - channel.active = false; - shiftBalance(channel, 0.55); - const { el, remote, local } = render(); + const { el, remote, local } = render(0.55, false); expect(el.children.length).toBe(3); expect(width(local)).toBe('55%'); expect(bgColor(local)).toBe('rgb(132, 138, 153)'); diff --git a/app/src/__tests__/components/loop/ChannelRow.spec.tsx b/app/src/__tests__/components/loop/ChannelRow.spec.tsx index 9960e20fa..ebbc0b29f 100644 --- a/app/src/__tests__/components/loop/ChannelRow.spec.tsx +++ b/app/src/__tests__/components/loop/ChannelRow.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { SwapDirection } from 'types/state'; import { fireEvent } from '@testing-library/react'; +import { formatSats } from 'util/formatters'; import { ellipseInside } from 'util/strings'; import { renderWithProviders } from 'util/tests'; import { createStore, Store } from 'store'; @@ -13,14 +14,7 @@ describe('ChannelRow component', () => { beforeEach(async () => { store = createStore(); - }); - - const render = () => { - return renderWithProviders(, store); - }; - - beforeEach(() => { - channel = new Channel({ + channel = new Channel(store, { chanId: '150633093070848', remotePubkey: '02ac59099da6d4bd818e6a81098f5d54580b7c3aa8255c707fa0f95ca89b02cb8c', capacity: 15000000, @@ -32,14 +26,22 @@ describe('ChannelRow component', () => { } as any); }); + const render = () => { + return renderWithProviders(, store); + }; + it('should display the remote balance', () => { const { getByText } = render(); - expect(getByText(channel.remoteBalance.toLocaleString())).toBeInTheDocument(); + expect( + getByText(formatSats(channel.remoteBalance, { withSuffix: false })), + ).toBeInTheDocument(); }); it('should display the local balance', () => { const { getByText } = render(); - expect(getByText(channel.localBalance.toLocaleString())).toBeInTheDocument(); + expect( + getByText(formatSats(channel.localBalance, { withSuffix: false })), + ).toBeInTheDocument(); }); it('should display the uptime', () => { @@ -54,7 +56,9 @@ describe('ChannelRow component', () => { it('should display the capacity', () => { const { getByText } = render(); - expect(getByText(channel.capacity.toLocaleString())).toBeInTheDocument(); + expect( + getByText(formatSats(channel.capacity, { withSuffix: false })), + ).toBeInTheDocument(); }); it('should display correct dot icon for an inactive channel', () => { @@ -65,8 +69,8 @@ describe('ChannelRow component', () => { }); it.each<[number, string]>([ - [55, 'success'], - [75, 'warn'], + [20, 'success'], + [50, 'warn'], [90, 'error'], ])('should display correct dot icon for a "%s" balance', (localPct, label) => { channel.localBalance = channel.capacity * (localPct / 100); diff --git a/app/src/__tests__/components/loop/LoopPage.spec.tsx b/app/src/__tests__/components/loop/LoopPage.spec.tsx index 3d894bbf5..953184051 100644 --- a/app/src/__tests__/components/loop/LoopPage.spec.tsx +++ b/app/src/__tests__/components/loop/LoopPage.spec.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SwapStatus } from 'types/generated/loop_pb'; import { grpc } from '@improbable-eng/grpc-web'; import { fireEvent, waitFor } from '@testing-library/react'; +import { formatSats } from 'util/formatters'; import { renderWithProviders } from 'util/tests'; import { loopListSwaps } from 'util/tests/sampleData'; import { createStore, Store } from 'store'; @@ -37,12 +38,8 @@ describe('LoopPage component', () => { const { getByText, store } = render(); // wait for the channels to be fetched async before checking the UI labels await waitFor(() => expect(store.channelStore.totalInbound).toBeGreaterThan(0)); - expect( - getByText(`${store.channelStore.totalInbound.toLocaleString()} SAT`), - ).toBeInTheDocument(); - expect( - getByText(`${store.channelStore.totalOutbound.toLocaleString()} SAT`), - ).toBeInTheDocument(); + expect(getByText(formatSats(store.channelStore.totalInbound))).toBeInTheDocument(); + expect(getByText(formatSats(store.channelStore.totalOutbound))).toBeInTheDocument(); }); it('should display the loop history records', async () => { @@ -55,9 +52,9 @@ describe('LoopPage component', () => { ); expect(await findByText(formatDate(swap1))).toBeInTheDocument(); - expect(await findByText('530,000 SAT')).toBeInTheDocument(); + expect(await findByText('530,000 sats')).toBeInTheDocument(); expect(await findByText(formatDate(swap2))).toBeInTheDocument(); - expect(await findByText('525,000 SAT')).toBeInTheDocument(); + expect(await findByText('525,000 sats')).toBeInTheDocument(); }); describe('Swap Process', () => { diff --git a/app/src/__tests__/components/loop/SwapWizard.spec.tsx b/app/src/__tests__/components/loop/SwapWizard.spec.tsx index fe99dbdce..6a0ecc34e 100644 --- a/app/src/__tests__/components/loop/SwapWizard.spec.tsx +++ b/app/src/__tests__/components/loop/SwapWizard.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { SwapDirection } from 'types/state'; import { fireEvent } from '@testing-library/react'; +import { formatSats } from 'util/formatters'; import { renderWithProviders } from 'util/tests'; import { createStore, Store } from 'store'; import SwapWizard from 'components/loop/swap/SwapWizard'; @@ -48,8 +49,8 @@ describe('SwapWizard component', () => { it('should display the correct min an max values', () => { const { getByText } = render(); const { min, max } = store.buildSwapStore.termsForDirection; - expect(getByText(`${min.toLocaleString()} SAT`)).toBeInTheDocument(); - expect(getByText(`${max.toLocaleString()} SAT`)).toBeInTheDocument(); + expect(getByText(formatSats(min))).toBeInTheDocument(); + expect(getByText(formatSats(max))).toBeInTheDocument(); }); it('should display the correct number of channels', () => { @@ -62,10 +63,10 @@ describe('SwapWizard component', () => { const { getByText, getByLabelText } = render(); const build = store.buildSwapStore; expect(build.amount).toEqual(625000); - expect(getByText(`625,000 SAT`)).toBeInTheDocument(); + expect(getByText(`625,000 sats`)).toBeInTheDocument(); fireEvent.change(getByLabelText('range-slider'), { target: { value: '575000' } }); expect(build.amount).toEqual(575000); - expect(getByText(`575,000 SAT`)).toBeInTheDocument(); + expect(getByText(`575,000 sats`)).toBeInTheDocument(); }); }); @@ -88,11 +89,9 @@ describe('SwapWizard component', () => { it('should display the correct values', () => { const { getByText } = render(); const build = store.buildSwapStore; - const toLabel = (x: number) => `${x.toLocaleString()} SAT`; - expect(getByText(toLabel(build.amount))).toBeInTheDocument(); - const pct = ((100 * build.fee) / build.amount).toFixed(2); - expect(getByText(toLabel(build.fee) + ` (${pct}%)`)).toBeInTheDocument(); - expect(getByText(toLabel(build.amount + build.fee))).toBeInTheDocument(); + expect(getByText(formatSats(build.amount))).toBeInTheDocument(); + expect(getByText(build.feesLabel)).toBeInTheDocument(); + expect(getByText(formatSats(build.invoiceTotal))).toBeInTheDocument(); }); }); diff --git a/app/src/__tests__/components/settings/BalanceSettings.spec.tsx b/app/src/__tests__/components/settings/BalanceSettings.spec.tsx new file mode 100644 index 000000000..91981d9b0 --- /dev/null +++ b/app/src/__tests__/components/settings/BalanceSettings.spec.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { BalanceMode } from 'util/constants'; +import { renderWithProviders } from 'util/tests'; +import { createStore, Store } from 'store'; +import BalanceSettings from 'components/settings/BalanceSettings'; + +describe('BalanceSettings', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', () => { + const { getByText } = render(); + expect(getByText('Channel Balance Mode')).toBeInTheDocument(); + }); + + it('should display the balance mode list', () => { + const { getByText } = render(); + expect(getByText('Receiving')).toBeInTheDocument(); + expect(getByText('Sending')).toBeInTheDocument(); + expect(getByText('Routing')).toBeInTheDocument(); + }); + + it('should display the back link', () => { + const { getByText } = render(); + expect(getByText('Settings')).toBeInTheDocument(); + }); + + it('should display the back icon', () => { + const { getByText } = render(); + expect(getByText('arrow-left.svg')).toBeInTheDocument(); + }); + + it('should navigate back to the Settings screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Settings')); + expect(store.uiStore.selectedSetting).toEqual('general'); + }); + + it('should update the Balance Mode to receive', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText('Receiving')); + expect(getAllByRole('switch')[0]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.balanceMode).toEqual(BalanceMode.receive); + }); + + it('should update the Balance Mode to send', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText('Sending')); + expect(getAllByRole('switch')[1]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.balanceMode).toEqual(BalanceMode.send); + }); + + it('should update the Balance Mode to routing', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText('Routing')); + expect(getAllByRole('switch')[2]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.balanceMode).toEqual(BalanceMode.routing); + }); +}); diff --git a/app/src/__tests__/components/settings/SettingsPage.spec.tsx b/app/src/__tests__/components/settings/SettingsPage.spec.tsx new file mode 100644 index 000000000..0c5d83e15 --- /dev/null +++ b/app/src/__tests__/components/settings/SettingsPage.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { renderWithProviders } from 'util/tests'; +import { createStore, Store } from 'store'; +import SettingsPage from 'components/settings/SettingsPage'; + +describe('SettingsPage', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', () => { + const { getByText } = render(); + expect(getByText('Settings')).toBeInTheDocument(); + }); + + it('should display the general section', () => { + const { getByText } = render(); + expect(getByText('General')).toBeInTheDocument(); + }); + + it('should display the settings list', () => { + const { getByText } = render(); + expect(getByText('Bitcoin Unit')).toBeInTheDocument(); + expect(getByText('Satoshis (0.00000001 BTC)')).toBeInTheDocument(); + expect(getByText('Channel Balance Mode')).toBeInTheDocument(); + expect(getByText('Optimize for Receiving')).toBeInTheDocument(); + }); + + it('should navigate to the Bitcoin Unit screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Bitcoin Unit')); + expect(store.uiStore.selectedSetting).toEqual('unit'); + }); + + it('should navigate to the Channel Balance Mode screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Channel Balance Mode')); + expect(store.uiStore.selectedSetting).toEqual('balance'); + }); +}); diff --git a/app/src/__tests__/components/settings/UnitSettings.spec.tsx b/app/src/__tests__/components/settings/UnitSettings.spec.tsx new file mode 100644 index 000000000..9349d6bd7 --- /dev/null +++ b/app/src/__tests__/components/settings/UnitSettings.spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { Unit } from 'util/constants'; +import { formatUnit } from 'util/formatters'; +import { renderWithProviders } from 'util/tests'; +import { createStore, Store } from 'store'; +import UnitSettings from 'components/settings/UnitSettings'; + +describe('UnitSettings', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', () => { + const { getByText } = render(); + expect(getByText('Bitcoin Unit')).toBeInTheDocument(); + }); + + it('should display the unit list', () => { + const { getByText } = render(); + expect(getByText(formatUnit(Unit.sats))).toBeInTheDocument(); + expect(getByText(formatUnit(Unit.bits))).toBeInTheDocument(); + expect(getByText(formatUnit(Unit.btc))).toBeInTheDocument(); + }); + + it('should display the back link', () => { + const { getByText } = render(); + expect(getByText('Settings')).toBeInTheDocument(); + }); + + it('should display the back icon', () => { + const { getByText } = render(); + expect(getByText('arrow-left.svg')).toBeInTheDocument(); + }); + + it('should navigate back to the Settings screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Settings')); + expect(store.uiStore.selectedSetting).toEqual('general'); + }); + + it('should update the Bitcoin Unit to sats', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText(formatUnit(Unit.sats))); + expect(getAllByRole('switch')[0]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.unit).toEqual(Unit.sats); + }); + + it('should update the Bitcoin Unit to bits', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText(formatUnit(Unit.bits))); + expect(getAllByRole('switch')[1]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.unit).toEqual(Unit.bits); + }); + + it('should update the Bitcoin Unit to BTC', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText(formatUnit(Unit.btc))); + expect(getAllByRole('switch')[2]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.unit).toEqual(Unit.btc); + }); +}); diff --git a/app/src/__tests__/store/buildSwapStore.spec.ts b/app/src/__tests__/store/buildSwapStore.spec.ts index 565c1d0fd..b14d88fa8 100644 --- a/app/src/__tests__/store/buildSwapStore.spec.ts +++ b/app/src/__tests__/store/buildSwapStore.spec.ts @@ -8,7 +8,7 @@ import { SWAP_ABORT_DELAY } from 'store/stores/buildSwapStore'; const grpcMock = grpc as jest.Mocked; -describe('SwapStore', () => { +describe('BuildSwapStore', () => { let store: BuildSwapStore; beforeEach(async () => { diff --git a/app/src/__tests__/store/channelStore.spec.ts b/app/src/__tests__/store/channelStore.spec.ts index bf935ae97..665dafb67 100644 --- a/app/src/__tests__/store/channelStore.spec.ts +++ b/app/src/__tests__/store/channelStore.spec.ts @@ -1,12 +1,28 @@ +import { observable, ObservableMap, values } from 'mobx'; +import { BalanceMode } from 'util/constants'; import { lndListChannels } from 'util/tests/sampleData'; -import { createStore } from 'store'; +import { createStore, SettingsStore } from 'store'; +import Channel from 'store/models/channel'; import ChannelStore from 'store/stores/channelStore'; -describe('Store', () => { +describe('ChannelStore', () => { + let settingsStore: SettingsStore; let store: ChannelStore; + const channelSubset = (channels: ObservableMap) => { + const few = values(channels) + .slice(0, 20) + .reduce((result, c) => { + result[c.chanId] = c; + return result; + }, {} as Record); + return observable.map(few); + }; + beforeEach(() => { - store = createStore().channelStore; + const rootStore = createStore(); + store = rootStore.channelStore; + settingsStore = rootStore.settingsStore; }); it('should fetch list of channels', async () => { @@ -29,6 +45,43 @@ describe('Store', () => { expect(updatedChan.uptime).toBe(prevUptime); }); + it('should sort channels correctly when using receive mode', async () => { + await store.fetchChannels(); + settingsStore.setBalanceMode(BalanceMode.receive); + store.channels = channelSubset(store.channels); + store.sortedChannels.forEach((c, i) => { + if (i === 0) return; + expect(c.localPercent).toBeLessThanOrEqual( + store.sortedChannels[i - 1].localPercent, + ); + }); + }); + + it('should sort channels correctly when using send mode', async () => { + await store.fetchChannels(); + settingsStore.setBalanceMode(BalanceMode.send); + store.channels = channelSubset(store.channels); + store.sortedChannels.forEach((c, i) => { + if (i === 0) return; + expect(c.localPercent).toBeGreaterThanOrEqual( + store.sortedChannels[i - 1].localPercent, + ); + }); + }); + + it('should sort channels correctly when using routing mode', async () => { + await store.fetchChannels(); + settingsStore.setBalanceMode(BalanceMode.routing); + store.channels = channelSubset(store.channels); + store.sortedChannels.forEach((c, i) => { + if (i === 0) return; + const currPct = Math.max(c.localPercent, 99 - c.localPercent); + const prev = store.sortedChannels[i - 1]; + const prevPct = Math.max(prev.localPercent, 99 - prev.localPercent); + expect(currPct).toBeLessThanOrEqual(prevPct); + }); + }); + it('should compute inbound liquidity', async () => { await store.fetchChannels(); const inbound = lndListChannels.channelsList.reduce( diff --git a/app/src/__tests__/store/nodeStore.spec.ts b/app/src/__tests__/store/nodeStore.spec.ts index cbbb439b7..b4b4dedc6 100644 --- a/app/src/__tests__/store/nodeStore.spec.ts +++ b/app/src/__tests__/store/nodeStore.spec.ts @@ -1,7 +1,7 @@ import { lndChannelBalance, lndWalletBalance } from 'util/tests/sampleData'; import { createStore, NodeStore } from 'store'; -describe('NodeAction', () => { +describe('NodeStore', () => { let store: NodeStore; beforeEach(() => { diff --git a/app/src/__tests__/store/settingsStore.spec.ts b/app/src/__tests__/store/settingsStore.spec.ts new file mode 100644 index 000000000..d14af53ce --- /dev/null +++ b/app/src/__tests__/store/settingsStore.spec.ts @@ -0,0 +1,40 @@ +import AppStorage from 'util/appStorage'; +import { BalanceMode, Unit } from 'util/constants'; +import { createStore, SettingsStore } from 'store'; +import { PersistentSettings } from 'store/stores/settingsStore'; + +const appStorageMock = AppStorage as jest.Mock>; + +describe('SettingsStore', () => { + let store: SettingsStore; + + beforeEach(() => { + store = createStore().settingsStore; + }); + + it('should load settings', async () => { + const getMock = appStorageMock.mock.instances[0].get as jest.Mock; + getMock.mockImplementation(() => ({ + sidebarVisible: false, + unit: Unit.bits, + balanceMode: BalanceMode.routing, + })); + + store.load(); + + expect(store.sidebarVisible).toEqual(false); + expect(store.unit).toEqual(Unit.bits); + expect(store.balanceMode).toEqual(BalanceMode.routing); + }); + + it('should do nothing if nothing is saved in storage', () => { + const getMock = appStorageMock.mock.instances[0].get as jest.Mock; + getMock.mockReturnValue(undefined as any); + + store.load(); + + expect(store.sidebarVisible).toEqual(true); + expect(store.unit).toEqual(Unit.sats); + expect(store.balanceMode).toEqual(BalanceMode.receive); + }); +}); diff --git a/app/src/__tests__/util/appStorage.spec.ts b/app/src/__tests__/util/appStorage.spec.ts new file mode 100644 index 000000000..adc571177 --- /dev/null +++ b/app/src/__tests__/util/appStorage.spec.ts @@ -0,0 +1,42 @@ +import AppStorage from 'util/appStorage'; + +jest.unmock('util/appStorage'); + +describe('appStorage util', () => { + const key = 'test-data'; + const settings = { + someNumber: 123, + someString: 'abc', + someBool: false, + }; + const appStorage = new AppStorage(); + + it('should save an object to localStorage', () => { + appStorage.set(key, settings); + + const json = localStorage.getItem(key); + expect(json).toBeDefined(); + expect(typeof json).toBe('string'); + const data = JSON.parse(json as string) as typeof settings; + expect(data).toBeDefined(); + expect(data.someNumber).toEqual(settings.someNumber); + expect(data.someString).toEqual(settings.someString); + expect(data.someBool).toEqual(settings.someBool); + }); + + it('should load an object from localStorage', () => { + const json = '{"someNumber":123,"someString":"abc","someBool":false}'; + localStorage.setItem(key, json); + + const data = appStorage.get(key) as typeof settings; + expect(data).toBeDefined(); + expect(data.someNumber).toEqual(settings.someNumber); + expect(data.someString).toEqual(settings.someString); + expect(data.someBool).toEqual(settings.someBool); + }); + + it('should return undefined for a missing key', () => { + const data = appStorage.get('invalid-key'); + expect(data).toBeUndefined(); + }); +}); diff --git a/app/src/__tests__/util/balances.spec.ts b/app/src/__tests__/util/balances.spec.ts new file mode 100644 index 000000000..69522a682 --- /dev/null +++ b/app/src/__tests__/util/balances.spec.ts @@ -0,0 +1,108 @@ +import { getBalanceStatus } from 'util/balances'; +import { BalanceConfig, BalanceModes, BalanceStatus } from 'util/constants'; + +describe('balances Util', () => { + let config: BalanceConfig; + + describe('Receive Optimized', () => { + beforeEach(() => { + config = BalanceModes.receive; + }); + + it('should return ok status', () => { + expect(getBalanceStatus(1, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(100, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.ok); + }); + + it('should return warn status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(350, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(600, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.warn); + }); + + it('should return danger status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(900, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(999, 1000, config)).toBe(BalanceStatus.danger); + }); + }); + + describe('Send Optimized', () => { + beforeEach(() => { + config = BalanceModes.send; + }); + + it('should return ok status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(900, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(999, 1000, config)).toBe(BalanceStatus.ok); + }); + + it('should return warn status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(350, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(600, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.warn); + }); + + it('should return danger status', () => { + expect(getBalanceStatus(1, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(100, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.danger); + }); + }); + + describe('Routing Optimized', () => { + beforeEach(() => { + config = BalanceModes.routing; + }); + + it('should return ok status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(350, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(600, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(800, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.ok); + }); + + it('should return warn status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(800, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.warn); + }); + + it('should return danger status', () => { + expect(getBalanceStatus(1, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(100, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(800, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(900, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(999, 1000, config)).toBe(BalanceStatus.danger); + }); + }); +}); diff --git a/app/src/__tests__/util/formatters.spec.ts b/app/src/__tests__/util/formatters.spec.ts new file mode 100644 index 000000000..be686319c --- /dev/null +++ b/app/src/__tests__/util/formatters.spec.ts @@ -0,0 +1,73 @@ +import { Unit } from 'util/constants'; +import { formatSats, formatUnit } from 'util/formatters'; + +describe('formatters Util', () => { + describe('formatSats', () => { + it('should format to sats', () => { + expect(formatSats(0)).toEqual('0 sats'); + expect(formatSats(123)).toEqual('123 sats'); + expect(formatSats(123456)).toEqual('123,456 sats'); + expect(formatSats(123456789)).toEqual('123,456,789 sats'); + expect(formatSats(12345678901)).toEqual('12,345,678,901 sats'); + }); + + it('should format to sats without', () => { + const opts = { withSuffix: false }; + expect(formatSats(0, opts)).toEqual('0'); + expect(formatSats(123, opts)).toEqual('123'); + expect(formatSats(123456, opts)).toEqual('123,456'); + expect(formatSats(123456789, opts)).toEqual('123,456,789'); + expect(formatSats(12345678901, opts)).toEqual('12,345,678,901'); + }); + + it('should format to bits', () => { + const opts = { unit: Unit.bits }; + expect(formatSats(0, opts)).toEqual('0.00 bits'); + expect(formatSats(123, opts)).toEqual('1.23 bits'); + expect(formatSats(123456, opts)).toEqual('1,234.56 bits'); + expect(formatSats(123456789, opts)).toEqual('1,234,567.89 bits'); + expect(formatSats(12345678901, opts)).toEqual('123,456,789.01 bits'); + }); + + it('should format to bits without', () => { + const opts = { unit: Unit.bits, withSuffix: false }; + expect(formatSats(0, opts)).toEqual('0.00'); + expect(formatSats(123, opts)).toEqual('1.23'); + expect(formatSats(123456, opts)).toEqual('1,234.56'); + expect(formatSats(123456789, opts)).toEqual('1,234,567.89'); + expect(formatSats(12345678901, opts)).toEqual('123,456,789.01'); + }); + + it('should format to BTC', () => { + const opts = { unit: Unit.btc }; + expect(formatSats(0, opts)).toEqual('0.00000000 BTC'); + expect(formatSats(123, opts)).toEqual('0.00000123 BTC'); + expect(formatSats(123456, opts)).toEqual('0.00123456 BTC'); + expect(formatSats(123456789, opts)).toEqual('1.23456789 BTC'); + expect(formatSats(12345678901, opts)).toEqual('123.45678901 BTC'); + }); + + it('should format to BTC without suffix', () => { + const opts = { unit: Unit.btc, withSuffix: false }; + expect(formatSats(0, opts)).toEqual('0.00000000'); + expect(formatSats(123, opts)).toEqual('0.00000123'); + expect(formatSats(123456, opts)).toEqual('0.00123456'); + expect(formatSats(123456789, opts)).toEqual('1.23456789'); + expect(formatSats(12345678901, opts)).toEqual('123.45678901'); + }); + }); + + describe('formatUnit', () => { + it('should format sats', () => { + expect(formatUnit(Unit.sats)).toEqual('Satoshis (0.00000001 BTC)'); + }); + + it('should format bits', () => { + expect(formatUnit(Unit.bits)).toEqual('Bits (0.00000100 BTC)'); + }); + + it('should format BTC', () => { + expect(formatUnit(Unit.btc)).toEqual('Bitcoin (1.00000000 BTC)'); + }); + }); +}); diff --git a/app/src/__tests__/util/log.spec.ts b/app/src/__tests__/util/log.spec.ts new file mode 100644 index 000000000..ffd5018ef --- /dev/null +++ b/app/src/__tests__/util/log.spec.ts @@ -0,0 +1,140 @@ +import { debugMock } from '__mocks__/debug'; +import * as config from 'config'; +import { Logger, LogLevel } from 'util/log'; + +describe('log Util', () => { + describe('fromEnv', () => { + afterEach(() => { + localStorage.removeItem('debug'); + localStorage.removeItem('debug-level'); + }); + + it('should enable debug logging in dev env', () => { + Object.defineProperty(config, 'IS_DEV', { get: () => true }); + + const log = Logger.fromEnv('test'); + log.debug('sample message'); + expect(debugMock).toBeCalledWith('[debug] sample message'); + + // revert IS_DEV + Object.defineProperty(config, 'IS_DEV', { get: () => false }); + }); + + it('should enable debug logging when storage key exists', () => { + localStorage.setItem('debug', '*'); + const log = Logger.fromEnv('test'); + log.debug('sample message'); + expect(debugMock).toBeCalledWith('[debug] sample message'); + }); + + it('should enable logging based on the debug-level storage key', () => { + localStorage.setItem('debug', '*'); + localStorage.setItem('debug-level', 'warn'); + const log = Logger.fromEnv('test'); + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + log.info('sample message'); + expect(debugMock).not.toBeCalledWith('[info] sample message'); + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel debug', () => { + const log = new Logger(LogLevel.debug, 'test'); + + it('should output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).toBeCalledWith('[debug] sample message'); + }); + + it('should output a info log message', () => { + log.info('sample message'); + expect(debugMock).toBeCalledWith('[info] sample message'); + }); + + it('should output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel info', () => { + const log = new Logger(LogLevel.info, 'test'); + + it('should not output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + }); + + it('should output a info log message', () => { + log.info('sample message'); + expect(debugMock).toBeCalledWith('[info] sample message'); + }); + + it('should output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel warn', () => { + const log = new Logger(LogLevel.warn, 'test'); + + it('should not output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + }); + + it('should not output a info log message', () => { + log.info('sample message'); + expect(debugMock).not.toBeCalledWith('[info] sample message'); + }); + + it('should output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel error', () => { + const log = new Logger(LogLevel.error, 'test'); + + it('should not output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + }); + + it('should not output a info log message', () => { + log.info('sample message'); + expect(debugMock).not.toBeCalledWith('[info] sample message'); + }); + + it('should not output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).not.toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); +}); diff --git a/app/src/__tests__/util/strings.spec.ts b/app/src/__tests__/util/strings.spec.ts new file mode 100644 index 000000000..fa110890a --- /dev/null +++ b/app/src/__tests__/util/strings.spec.ts @@ -0,0 +1,32 @@ +import { ellipseInside } from 'util/strings'; + +describe('strings util', () => { + describe('ellipseInside', () => { + it('should ellipse valid text', () => { + expect(ellipseInside('xxxxxxyyyyzzzzzz')).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxyyyyzzzz', 4)).toEqual('xxxx...zzzz'); + expect(ellipseInside('xxyyyyzz', 2)).toEqual('xx...zz'); + expect(ellipseInside('xxyyyyzz', 2, 4)).toEqual('xx...yyzz'); + }); + + it('should do nothing with short text', () => { + expect(ellipseInside('abcdef')).toEqual('abcdef'); + expect(ellipseInside('abcdef', 3)).toEqual('abcdef'); + }); + + it('should keep 6 chars with invalid value provided', () => { + expect(ellipseInside('xxxxxxyyyyzzzzzz', 0)).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxxxyyyyzzzzzz', -1)).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxxxyyyyzzzzzz', NaN)).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxxxyyyyzzzzzz', (undefined as unknown) as number)).toEqual( + 'xxxxxx...zzzzzz', + ); + }); + + it('should handle empty text', () => { + expect(ellipseInside((undefined as unknown) as string)).toBeUndefined(); + expect(ellipseInside((null as unknown) as string)).toBeNull(); + expect(ellipseInside('')).toEqual(''); + }); + }); +}); diff --git a/app/src/components/NodeStatus.tsx b/app/src/components/NodeStatus.tsx index 93127f6cd..983b23969 100644 --- a/app/src/components/NodeStatus.tsx +++ b/app/src/components/NodeStatus.tsx @@ -4,6 +4,7 @@ import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; import { SmallText, Title, XLargeText } from 'components/common/text'; import { Bitcoin, Bolt } from './common/icons'; +import Unit from './common/Unit'; import { styled } from './theme'; const Styled = { @@ -32,23 +33,20 @@ const Styled = { }; const NodeStatus: React.FC = () => { - const { Wrapper, StatusTitle, BoltIcon, BitcoinIcon, Divider } = Styled; - const { l } = usePrefixedTranslation('cmps.NodeStatus'); + const { nodeStore } = useStore(); - const store = useStore(); - const { channelBalance, walletBalance } = store.nodeStore.wallet; - + const { Wrapper, StatusTitle, BoltIcon, BitcoinIcon, Divider } = Styled; return ( {l('title')} - {channelBalance.toLocaleString()} SAT + - {walletBalance.toLocaleString()} + diff --git a/app/src/components/Pages.tsx b/app/src/components/Pages.tsx index b9092b084..820a10e73 100644 --- a/app/src/components/Pages.tsx +++ b/app/src/components/Pages.tsx @@ -3,6 +3,7 @@ import { observer } from 'mobx-react-lite'; import { useStore } from 'store'; import HistoryPage from './history/HistoryPage'; import LoopPage from './loop/LoopPage'; +import SettingsPage from './settings/SettingsPage'; const Pages: React.FC = () => { const { uiStore } = useStore(); @@ -10,6 +11,8 @@ const Pages: React.FC = () => { switch (uiStore.page) { case 'history': return ; + case 'settings': + return ; default: return ; } diff --git a/app/src/components/common/Radio.tsx b/app/src/components/common/Radio.tsx index c79b1aebe..f0241d837 100644 --- a/app/src/components/common/Radio.tsx +++ b/app/src/components/common/Radio.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { styled } from 'components/theme'; import { SmallText } from './text'; @@ -30,7 +30,7 @@ const Styled = { interface Props { text: string; - description?: string; + description?: ReactNode; active?: boolean; right?: boolean; onClick?: () => void; diff --git a/app/src/components/common/Range.tsx b/app/src/components/common/Range.tsx index 13ab87c7f..3ae61157f 100644 --- a/app/src/components/common/Range.tsx +++ b/app/src/components/common/Range.tsx @@ -2,6 +2,7 @@ import React, { ChangeEvent, useCallback } from 'react'; import { styled } from 'components/theme'; import { RangeInput } from './base'; import Radio from './Radio'; +import Unit from './Unit'; const Styled = { RadioGroup: styled.div` @@ -47,13 +48,13 @@ const Range: React.FC = ({ } onClick={handleMinClicked} active={min === value} /> } onClick={handleMaxClicked} active={max === value} right @@ -72,7 +73,9 @@ const Range: React.FC = ({ onChange={handleInputClicked} /> - {value.toLocaleString()} SAT + + + ); }; diff --git a/app/src/components/common/Tile.tsx b/app/src/components/common/Tile.tsx index 306e9f0e1..b5f09297d 100644 --- a/app/src/components/common/Tile.tsx +++ b/app/src/components/common/Tile.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { styled } from 'components/theme'; import { Maximize } from './icons'; import { Title } from './text'; @@ -43,7 +43,7 @@ interface Props { * optional text to display in the tile. if this is not * provided, then the `children` will be displayed instead */ - text?: string; + text?: ReactNode; /** * optional click handler for the icon which will not be * visible if this prop is not defined diff --git a/app/src/components/common/Unit.tsx b/app/src/components/common/Unit.tsx new file mode 100644 index 000000000..4001507e1 --- /dev/null +++ b/app/src/components/common/Unit.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from 'react'; +import { observer } from 'mobx-react-lite'; +import { formatSats } from 'util/formatters'; +import { useStore } from 'store'; + +interface Props { + sats: number; + suffix?: boolean; +} + +const Sats: React.FC = ({ sats, suffix = true }) => { + const { settingsStore } = useStore(); + + // memoize so that formatSats isn't called too often unnecessarily + const text = useMemo(() => { + return formatSats(sats, { + unit: settingsStore.unit, + withSuffix: suffix, + lang: settingsStore.lang, + }); + }, [sats, suffix, settingsStore.unit, settingsStore.lang]); + + return <>{text}; +}; + +export default observer(Sats); diff --git a/app/src/components/common/base.tsx b/app/src/components/common/base.tsx index 6ff593806..52cc76a80 100644 --- a/app/src/components/common/base.tsx +++ b/app/src/components/common/base.tsx @@ -37,7 +37,7 @@ export const Button = styled.button` color: ${props => props.theme.colors.white}; background-color: ${props => props.ghost ? 'transparent' : props.theme.colors.tileBack}; - border-width: ${props => (props.borderless ? '0' : '1px')}; + border-width: ${props => (props.borderless && !props.primary ? '0' : '1px')}; border-color: ${props => props.primary ? props.theme.colors.green : props.theme.colors.white}; border-style: solid; @@ -53,6 +53,23 @@ export const Button = styled.button` } `; +interface RadioButtonProps { + checked?: boolean; +} + +export const RadioButton = styled.span` + display: inline-block; + width: 14px; + height: 14px; + border-radius: 14px; + border: 1px solid ${props => props.theme.colors.lightPurple}; + background-color: ${props => (props.checked ? props.theme.colors.lightPurple : 'none')}; + + &:hover { + opacity: 0.8; + } +`; + /** * the react-virtualized list doesn't play nice with the bootstrap row -15px * margin. We need to manually offset the container and remove the diff --git a/app/src/components/common/icons.tsx b/app/src/components/common/icons.tsx index 11c25dc20..8c26ace1b 100644 --- a/app/src/components/common/icons.tsx +++ b/app/src/components/common/icons.tsx @@ -1,9 +1,9 @@ import React, { ReactNode } from 'react'; +import { ReactComponent as ArrowRight } from 'assets/icons/arrow-right.svg'; import { ReactComponent as Clock } from 'assets/icons/clock.svg'; import { ReactComponent as Download } from 'assets/icons/download.svg'; import { styled } from 'components/theme'; -export { ReactComponent as ArrowRight } from 'assets/icons/arrow-right.svg'; export { ReactComponent as ArrowLeft } from 'assets/icons/arrow-left.svg'; export { ReactComponent as Bolt } from 'assets/icons/bolt.svg'; export { ReactComponent as Bitcoin } from 'assets/icons/bitcoin.svg'; @@ -17,11 +17,13 @@ export { ReactComponent as Maximize } from 'assets/icons/maximize.svg'; export { ReactComponent as Refresh } from 'assets/icons/refresh-cw.svg'; interface IconComponents { + 'arrow-right': ReactNode; clock: ReactNode; download: ReactNode; } const components: IconComponents = { + 'arrow-right': , clock: , download: , }; diff --git a/app/src/components/history/HistoryRow.tsx b/app/src/components/history/HistoryRow.tsx index f442fccbc..b3767a65b 100644 --- a/app/src/components/history/HistoryRow.tsx +++ b/app/src/components/history/HistoryRow.tsx @@ -4,6 +4,7 @@ import { usePrefixedTranslation } from 'hooks'; import { Swap } from 'store/models'; import { Column, Row } from 'components/common/grid'; import { Title } from 'components/common/text'; +import Unit from 'components/common/Unit'; import SwapDot from 'components/loop/SwapDot'; import { styled } from 'components/theme'; @@ -75,7 +76,9 @@ const HistoryRow: React.FC = ({ swap, style }) => { {swap.stateLabel} {swap.typeName} - {swap.amount.toLocaleString()} + + + {swap.createdOnLabel} {swap.updatedOnLabel} diff --git a/app/src/components/layout/NavMenu.tsx b/app/src/components/layout/NavMenu.tsx index b9d6d8006..a2082fc89 100644 --- a/app/src/components/layout/NavMenu.tsx +++ b/app/src/components/layout/NavMenu.tsx @@ -44,24 +44,30 @@ const Styled = { `, }; +const NavItem: React.FC<{ page: string; onClick: () => void }> = observer( + ({ page, onClick }) => { + const { l } = usePrefixedTranslation('cmps.layout.NavMenu'); + const { uiStore } = useStore(); + return ( + + {l(page)} + + ); + }, +); + const NavMenu: React.FC = () => { const { l } = usePrefixedTranslation('cmps.layout.NavMenu'); const { uiStore } = useStore(); - const { NavTitle, Nav, NavItem } = Styled; + const { NavTitle, Nav } = Styled; return ( <> {l('menu')} ); diff --git a/app/src/components/loop/ChannelBalance.tsx b/app/src/components/loop/ChannelBalance.tsx index 0611af25e..3541b7613 100644 --- a/app/src/components/loop/ChannelBalance.tsx +++ b/app/src/components/loop/ChannelBalance.tsx @@ -1,27 +1,28 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; -import { BalanceLevel } from 'types/state'; +import { statusToColor } from 'util/balances'; +import { BalanceStatus } from 'util/constants'; import { Channel } from 'store/models'; -import { levelToColor, styled } from 'components/theme'; +import { styled } from 'components/theme'; const Styled = { - Wrapper: styled.div<{ pct: number; level: BalanceLevel; active: boolean }>` + Wrapper: styled.div<{ pct: number; status: BalanceStatus; active: boolean }>` display: flex; width: 100%; > div { - min-width: 10px; + min-width: 6px; &:first-of-type { flex-grow: 1; background-color: ${props => - levelToColor(props.level, props.active, props.theme)}; + statusToColor(props.status, props.active, props.theme)}; } &:last-of-type { width: ${props => props.pct}%; background-color: ${props => - levelToColor(props.level, props.active, props.theme)}; + statusToColor(props.status, props.active, props.theme)}; } } `, @@ -45,7 +46,7 @@ const ChannelBalance: React.FC = ({ channel, className }) => { return ( diff --git a/app/src/components/loop/ChannelRow.tsx b/app/src/components/loop/ChannelRow.tsx index 4e5ba46f7..01c599f4c 100644 --- a/app/src/components/loop/ChannelRow.tsx +++ b/app/src/components/loop/ChannelRow.tsx @@ -1,7 +1,7 @@ import React, { CSSProperties } from 'react'; import { observer } from 'mobx-react-lite'; -import { BalanceLevel } from 'types/state'; import { usePrefixedTranslation } from 'hooks'; +import { BalanceStatus } from 'util/constants'; import { ellipseInside } from 'util/strings'; import { useStore } from 'store'; import { Channel } from 'store/models'; @@ -9,6 +9,7 @@ import Checkbox from 'components/common/Checkbox'; import { Column, Row } from 'components/common/grid'; import StatusDot from 'components/common/StatusDot'; import { Title } from 'components/common/text'; +import Unit from 'components/common/Unit'; import { styled } from 'components/theme'; import ChannelBalance from './ChannelBalance'; @@ -41,7 +42,7 @@ const Styled = { Check: styled(Checkbox)` float: left; margin-top: 18px; - margin-left: 15px; + margin-left: 10px; `, Balance: styled(ChannelBalance)` margin-top: ${ROW_HEIGHT / 2 - 2}px; @@ -75,12 +76,12 @@ export const ChannelRowHeader: React.FC = () => { const ChannelDot: React.FC<{ channel: Channel }> = observer(({ channel }) => { if (!channel.active) return ; - switch (channel.balanceLevel) { - case BalanceLevel.good: + switch (channel.balanceStatus) { + case BalanceStatus.ok: return ; - case BalanceLevel.warn: + case BalanceStatus.warn: return ; - case BalanceLevel.bad: + case BalanceStatus.danger: return ; } }); @@ -113,15 +114,19 @@ const ChannelRow: React.FC = ({ channel, style }) => { )} - {channel.remoteBalance.toLocaleString()} + - {channel.localBalance.toLocaleString()} + + + {channel.uptimePercent} {ellipseInside(channel.remotePubkey)} - {channel.capacity.toLocaleString()} + + + ); }; diff --git a/app/src/components/loop/LoopActions.tsx b/app/src/components/loop/LoopActions.tsx index 8a95aff1b..ed115b1f1 100644 --- a/app/src/components/loop/LoopActions.tsx +++ b/app/src/components/loop/LoopActions.tsx @@ -34,13 +34,11 @@ const Styled = { const LoopActions: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.LoopActions'); const { buildSwapStore } = useStore(); - const handleLoopOut = useCallback( - () => buildSwapStore.setDirection(SwapDirection.OUT), - [buildSwapStore], - ); - const handleLoopIn = useCallback(() => buildSwapStore.setDirection(SwapDirection.IN), [ - buildSwapStore, + const { setDirection, inferredDirection } = buildSwapStore; + const handleLoopOut = useCallback(() => setDirection(SwapDirection.OUT), [ + setDirection, ]); + const handleLoopIn = useCallback(() => setDirection(SwapDirection.IN), [setDirection]); const selectedCount = buildSwapStore.selectedChanIds.length; const { Wrapper, Actions, CloseIcon, Selected } = Styled; @@ -52,16 +50,16 @@ const LoopActions: React.FC = () => { {selectedCount} {l('channelsSelected')}