Sitelet https://github.com/rotki/rotki/commit/develop
Skip to content

Commit 9665a8f

Browse files
committed
refactor(frontend): extract the liquity staking cluster
Closes #13002. Batch D of #12964: one module, `staking/liquity/`, taken whole. 157 tests in the module, 285 uncovered statements down to 26. The two fat components carried 198 of those statements between them and are now wiring over three new modules: - `liquity-aggregation.ts` (100%) sums positions across addresses and proxies. `aggregatedStake` and `aggregatedStakingPool` turned out to be the same algorithm written twice, because a staking detail and a pool detail are structurally identical, so both now call one generic `aggregateEntries`. - `liquity-statistics.ts` (100%) holds the re-pricing and the profit and loss arithmetic, taking a price lookup so it needs no store. - `liquity-assets.ts` gives the LUSD and LQTY identifiers one home; LUSD had been declared separately in two components. `use-liquity-data-fetching.ts` had four near-identical fetchers, ~150 lines differing in five values. They are now one `createFetch` over a definition, and the premium guard that three of them carry, and the fourth deliberately does not, is covered in both directions for the first time. The view toggle became an `as const` object rather than a bare string union, per the repo convention for new types. `LiquityPnlRow.vue` is deliberately left without a spec: it is pure display with no branch, and a mount-only test would move the number without proving anything. Two corrections found by writing the tests: the doc comment claiming an unpriced gain is valued at zero was wrong, since the price defaults to one and only an explicit zero collapses the value; and a per-iteration defensive copy in the aggregation reduce was redundant, because the first entry is already copied and every later field is reassigned.
1 parent 5ecb7bc commit 9665a8f

24 files changed

Lines changed: 2769 additions & 551 deletions
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { useLiquityPage } from '@/modules/staking/liquity/use-liquity-page';
2+
import { libraryDefaults } from '@test/utils/provide-defaults';
3+
import { mount, type VueWrapper } from '@vue/test-utils';
4+
import { createPinia, setActivePinia } from 'pinia';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { defineComponent } from 'vue';
7+
import LiquityPage from '@/modules/staking/liquity/LiquityPage.vue';
8+
9+
const fetch = vi.fn(async (): Promise<void> => {});
10+
11+
const pageState = vi.hoisted((): { moduleEnabled: boolean; premium: boolean } => ({
12+
moduleEnabled: true,
13+
premium: true,
14+
}));
15+
16+
const StakingDetailsStub = defineComponent({
17+
emits: ['refresh'],
18+
name: 'LiquityStakingDetailsStub',
19+
template: '<div data-testid="staking-details"><slot name="modules" /></div>',
20+
});
21+
22+
const PlaceholderStub = defineComponent({
23+
name: 'LiquityStakingPagePlaceholderStub',
24+
props: { text: { default: '', type: String } },
25+
template: '<div data-testid="no-premium" />',
26+
});
27+
28+
const ModuleNotActiveStub = defineComponent({
29+
name: 'ModuleNotActiveStub',
30+
props: { modules: { default: () => [], type: Array } },
31+
template: '<div data-testid="module-not-active" />',
32+
});
33+
34+
vi.mock('@/modules/staking/liquity/use-liquity-page', async () => {
35+
const actual = await vi.importActual<typeof import('@/modules/staking/liquity/use-liquity-page')>(
36+
'@/modules/staking/liquity/use-liquity-page',
37+
);
38+
const { computed, shallowRef } = await import('vue');
39+
return {
40+
LIQUITY_MODULES: actual.LIQUITY_MODULES,
41+
useLiquityPage: (): ReturnType<typeof useLiquityPage> => ({
42+
fetch,
43+
moduleEnabled: computed(() => pageState.moduleEnabled),
44+
premium: shallowRef(pageState.premium),
45+
}),
46+
};
47+
});
48+
49+
describe('modules/staking/liquity/LiquityPage', () => {
50+
let wrapper: VueWrapper<InstanceType<typeof LiquityPage>>;
51+
52+
function mountPage(): VueWrapper<InstanceType<typeof LiquityPage>> {
53+
return mount(LiquityPage, {
54+
global: {
55+
plugins: [createPinia()],
56+
provide: libraryDefaults,
57+
stubs: {
58+
ActiveModules: { props: ['modules'], template: '<div data-testid="active-modules" />' },
59+
LiquityStakingDetails: StakingDetailsStub,
60+
LiquityStakingPagePlaceholder: PlaceholderStub,
61+
ModuleNotActive: ModuleNotActiveStub,
62+
},
63+
},
64+
});
65+
}
66+
67+
beforeEach(() => {
68+
setActivePinia(createPinia());
69+
vi.clearAllMocks();
70+
pageState.moduleEnabled = true;
71+
pageState.premium = true;
72+
});
73+
74+
afterEach(() => {
75+
wrapper?.unmount();
76+
});
77+
78+
describe('without premium', () => {
79+
beforeEach(() => {
80+
pageState.premium = false;
81+
});
82+
83+
it('should show only the upsell placeholder', () => {
84+
wrapper = mountPage();
85+
86+
expect(wrapper.findComponent(PlaceholderStub).exists()).toBe(true);
87+
expect(wrapper.findComponent(StakingDetailsStub).exists()).toBe(false);
88+
expect(wrapper.findComponent(ModuleNotActiveStub).exists()).toBe(false);
89+
});
90+
91+
it('should hand it the liquity copy, not another page\'s', () => {
92+
wrapper = mountPage();
93+
94+
expect(wrapper.findComponent(PlaceholderStub).props('text')).toBe('liquity_page.no_premium');
95+
});
96+
97+
it('should take precedence over the module being off', () => {
98+
pageState.moduleEnabled = false;
99+
100+
wrapper = mountPage();
101+
102+
expect(wrapper.findComponent(PlaceholderStub).exists()).toBe(true);
103+
expect(wrapper.findComponent(ModuleNotActiveStub).exists()).toBe(false);
104+
});
105+
});
106+
107+
describe('with premium but the module off', () => {
108+
beforeEach(() => {
109+
pageState.moduleEnabled = false;
110+
});
111+
112+
it('should ask for the module to be activated', () => {
113+
wrapper = mountPage();
114+
115+
expect(wrapper.findComponent(ModuleNotActiveStub).exists()).toBe(true);
116+
expect(wrapper.findComponent(StakingDetailsStub).exists()).toBe(false);
117+
});
118+
119+
it('should name the liquity module', () => {
120+
wrapper = mountPage();
121+
122+
expect(wrapper.findComponent(ModuleNotActiveStub).props('modules')).toEqual(['liquity']);
123+
});
124+
});
125+
126+
describe('with premium and the module on', () => {
127+
it('should show the staking details', () => {
128+
wrapper = mountPage();
129+
130+
expect(wrapper.findComponent(StakingDetailsStub).exists()).toBe(true);
131+
expect(wrapper.findComponent(PlaceholderStub).exists()).toBe(false);
132+
expect(wrapper.findComponent(ModuleNotActiveStub).exists()).toBe(false);
133+
});
134+
135+
it('should offer the module toggle in the details header', () => {
136+
wrapper = mountPage();
137+
138+
expect(wrapper.find('[data-testid=active-modules]').exists()).toBe(true);
139+
});
140+
141+
it('should refetch when the details ask for a refresh', () => {
142+
wrapper = mountPage();
143+
144+
wrapper.findComponent(StakingDetailsStub).vm.$emit('refresh', true);
145+
146+
expect(fetch).toHaveBeenCalledWith(true);
147+
});
148+
});
149+
});

frontend/app/src/modules/staking/liquity/LiquityPage.vue

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,34 @@
11
<script setup lang="ts">
2-
import { useHistoricCachePriceStore } from '@/modules/assets/prices/use-historic-cache-price-store';
3-
import { usePriceTaskManager } from '@/modules/assets/prices/use-price-task-manager';
4-
import { usePremium } from '@/modules/premium/use-premium';
5-
import { Module, useModuleEnabled } from '@/modules/session/use-module-enabled';
62
import ActiveModules from '@/modules/settings/modules/ActiveModules.vue';
73
import ModuleNotActive from '@/modules/settings/modules/ModuleNotActive.vue';
8-
import { useSetting } from '@/modules/settings/use-setting';
94
import LiquityStakingDetails from '@/modules/staking/liquity/LiquityStakingDetails.vue';
105
import LiquityStakingPagePlaceholder from '@/modules/staking/liquity/LiquityStakingPagePlaceholder.vue';
11-
import { useLiquityDataFetching } from '@/modules/staking/liquity/use-liquity-data-fetching';
12-
13-
const modules = [Module.LIQUITY];
14-
const { enabled: moduleEnabled } = useModuleEnabled(modules[0]);
15-
const { fetchPools, fetchStaking, fetchStatistics } = useLiquityDataFetching();
16-
const { resetProtocolStatsPriceQueryStatus } = useHistoricCachePriceStore();
17-
const currencySymbol = useSetting('currencySymbol');
18-
const premium = usePremium();
19-
const { fetchPrices } = usePriceTaskManager();
20-
21-
const LUSD_ID = 'eip155:1/erc20:0x5f98805A4E8be255a32880FDeC7F6728C6568bA0';
22-
const LQTY_ID = 'eip155:1/erc20:0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D';
23-
24-
async function fetch(refresh = false) {
25-
resetProtocolStatsPriceQueryStatus('liquity');
26-
27-
await Promise.all([
28-
fetchStaking(refresh),
29-
fetchPools(refresh),
30-
fetchStatistics(refresh),
31-
fetchPrices({
32-
ignoreCache: refresh,
33-
selectedAssets: [LUSD_ID, LQTY_ID, 'ETH'],
34-
}),
35-
]);
36-
}
37-
38-
watchImmediate(moduleEnabled, async (enabled) => {
39-
if (enabled)
40-
await fetch();
41-
});
42-
43-
watch(currencySymbol, async () => {
44-
if (get(moduleEnabled)) {
45-
await fetch(true);
46-
}
47-
});
6+
import { LIQUITY_MODULES, useLiquityPage } from '@/modules/staking/liquity/use-liquity-page';
487
498
const { t } = useI18n({ useScope: 'global' });
9+
10+
const { fetch, moduleEnabled, premium } = useLiquityPage();
5011
</script>
5112

5213
<template>
5314
<div>
5415
<LiquityStakingPagePlaceholder
5516
v-if="!premium"
5617
:text="t('liquity_page.no_premium')"
18+
data-testid="no-premium"
5719
/>
5820
<ModuleNotActive
5921
v-else-if="!moduleEnabled"
60-
:modules="modules"
22+
:modules="LIQUITY_MODULES"
23+
data-testid="module-not-active"
6124
/>
6225
<LiquityStakingDetails
6326
v-else
27+
data-testid="staking-details"
6428
@refresh="fetch($event)"
6529
>
6630
<template #modules>
67-
<ActiveModules :modules="modules" />
31+
<ActiveModules :modules="LIQUITY_MODULES" />
6832
</template>
6933
</LiquityStakingDetails>
7034
</div>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { bigNumberify, type LiquityPoolDetailEntry } from '@rotki/common';
2+
import { libraryDefaults } from '@test/utils/provide-defaults';
3+
import { mount, type VueWrapper } from '@vue/test-utils';
4+
import { createPinia, setActivePinia } from 'pinia';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { defineComponent } from 'vue';
7+
import LiquityPools from '@/modules/staking/liquity/LiquityPools.vue';
8+
9+
const isActive = vi.hoisted(() => ({ current: false }));
10+
11+
vi.mock('@/modules/task-center/use-task-center', async () => {
12+
const { computed } = await import('vue');
13+
return {
14+
useTaskCenter: (): Record<string, unknown> => ({
15+
useIsActive: () => computed(() => isActive.current),
16+
}),
17+
};
18+
});
19+
20+
const BalanceDisplayStub = defineComponent({
21+
name: 'BalanceDisplayStub',
22+
props: {
23+
asset: { default: '', type: String },
24+
iconSize: { default: '', type: String },
25+
loading: { default: false, type: Boolean },
26+
value: { default: null, type: Object },
27+
},
28+
template: '<div data-testid="balance" />',
29+
});
30+
31+
function pool(): LiquityPoolDetailEntry {
32+
const balance = (asset: string, amount: number): { amount: ReturnType<typeof bigNumberify>; asset: string; value: ReturnType<typeof bigNumberify> } => ({
33+
amount: bigNumberify(amount),
34+
asset,
35+
value: bigNumberify(amount),
36+
});
37+
38+
return {
39+
deposited: balance('LUSD', 500),
40+
gains: balance('ETH', 1),
41+
rewards: balance('LQTY', 5),
42+
};
43+
}
44+
45+
describe('modules/staking/liquity/LiquityPools', () => {
46+
let wrapper: VueWrapper<InstanceType<typeof LiquityPools>>;
47+
48+
function mountComponent(poolValue: LiquityPoolDetailEntry | null): VueWrapper<InstanceType<typeof LiquityPools>> {
49+
return mount(LiquityPools, {
50+
global: {
51+
plugins: [createPinia()],
52+
provide: libraryDefaults,
53+
stubs: { BalanceDisplay: BalanceDisplayStub },
54+
},
55+
props: { pool: poolValue },
56+
});
57+
}
58+
59+
beforeEach(() => {
60+
setActivePinia(createPinia());
61+
vi.clearAllMocks();
62+
isActive.current = false;
63+
});
64+
65+
afterEach(() => {
66+
wrapper?.unmount();
67+
});
68+
69+
it('should show no balances without a pool position', () => {
70+
wrapper = mountComponent(null);
71+
72+
expect(wrapper.findComponent(BalanceDisplayStub).exists()).toBe(false);
73+
});
74+
75+
it('should show the deposit, the rewards and the liquidation gains', () => {
76+
wrapper = mountComponent(pool());
77+
78+
const balances = wrapper.findAllComponents(BalanceDisplayStub);
79+
expect(balances).toHaveLength(3);
80+
expect(balances.map(item => item.props('asset'))).toEqual(['LUSD', 'LQTY', 'ETH']);
81+
});
82+
83+
it('should pass the pool activity through as the loading state', () => {
84+
isActive.current = true;
85+
86+
wrapper = mountComponent(pool());
87+
88+
expect(wrapper.findComponent(BalanceDisplayStub).props('loading')).toBe(true);
89+
});
90+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { libraryDefaults } from '@test/utils/provide-defaults';
2+
import { mount, type VueWrapper } from '@vue/test-utils';
3+
import { createPinia, setActivePinia } from 'pinia';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import { defineComponent } from 'vue';
6+
import LiquityProxyInformation from '@/modules/staking/liquity/LiquityProxyInformation.vue';
7+
8+
const HashLinkStub = defineComponent({
9+
name: 'HashLinkStub',
10+
props: { text: { default: '', type: String } },
11+
template: '<div data-testid="hash-link">{{ text }}</div>',
12+
});
13+
14+
const DividerStub = defineComponent({
15+
name: 'DividerStub',
16+
template: '<hr data-testid="divider" />',
17+
});
18+
19+
describe('modules/staking/liquity/LiquityProxyInformation', () => {
20+
let wrapper: VueWrapper<InstanceType<typeof LiquityProxyInformation>>;
21+
22+
function mountComponent(proxyInformation: Record<string, string[]>): VueWrapper<InstanceType<typeof LiquityProxyInformation>> {
23+
return mount(LiquityProxyInformation, {
24+
global: {
25+
plugins: [createPinia()],
26+
provide: libraryDefaults,
27+
stubs: {
28+
HashLink: HashLinkStub,
29+
RuiDivider: DividerStub,
30+
// The contents live in a menu that only renders once opened.
31+
RuiMenu: { template: '<div><slot /></div>' },
32+
},
33+
},
34+
props: { proxyInformation },
35+
});
36+
}
37+
38+
beforeEach(() => {
39+
setActivePinia(createPinia());
40+
});
41+
42+
afterEach(() => {
43+
wrapper?.unmount();
44+
});
45+
46+
it('should link the owner and each of its proxies', () => {
47+
wrapper = mountComponent({ '0xaaa': ['0xproxy1', '0xproxy2'] });
48+
49+
const links = wrapper.findAllComponents(HashLinkStub).map(item => item.props('text'));
50+
expect(links).toEqual(['0xaaa', '0xproxy1', '0xproxy2']);
51+
});
52+
53+
it('should separate owners with a divider, but not trail one after the last', () => {
54+
wrapper = mountComponent({ '0xaaa': ['0xproxy1'], '0xbbb': ['0xproxy2'] });
55+
56+
expect(wrapper.findAllComponents(DividerStub)).toHaveLength(1);
57+
});
58+
59+
it('should show no divider for a single owner', () => {
60+
wrapper = mountComponent({ '0xaaa': ['0xproxy1'] });
61+
62+
expect(wrapper.findComponent(DividerStub).exists()).toBe(false);
63+
});
64+
65+
it('should show one divider fewer than the number of owners', () => {
66+
wrapper = mountComponent({ '0xaaa': ['0xp1'], '0xbbb': ['0xp2'], '0xccc': ['0xp3'] });
67+
68+
expect(wrapper.findAllComponents(DividerStub)).toHaveLength(2);
69+
});
70+
71+
it('should render nothing for an empty record', () => {
72+
wrapper = mountComponent({});
73+
74+
expect(wrapper.findComponent(HashLinkStub).exists()).toBe(false);
75+
});
76+
});

0 commit comments

Comments
 (0)