Skip to content

Commit 139ad12

Browse files
committed
feat: implement persistent state management for generator
- Introduced utilities for loading and saving generator state to localStorage and query parameters. - Added tests for persistent state functionality, ensuring correct handling of boolean and malformed values. - Updated generator components to clear localStorage and history state before each test. - Enhanced useGeneratorControls composable to support state hydration and persistence. - Refactored Onlyfans component styles for improved layout and consistency.
1 parent 8451c9e commit 139ad12

File tree

6 files changed

+530
-38
lines changed

6 files changed

+530
-38
lines changed

src/__tests__/generators.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ describe('generator components', () => {
8383
beforeEach(() => {
8484
document.body.innerHTML = '';
8585
setActivePinia(createPinia());
86+
window.localStorage.clear();
87+
window.history.replaceState(null, '', '/');
8688
});
8789

8890
it('synchronizes text updates and highlight order in Pornhub generator', async () => {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import {
3+
loadGeneratorState,
4+
saveGeneratorState,
5+
GENERATOR_STATE_STORAGE_KEY
6+
} from '@/utils/persistentState';
7+
8+
describe('persistentState utilities', () => {
9+
beforeEach(() => {
10+
window.localStorage.clear();
11+
window.history.replaceState(null, '', '/');
12+
});
13+
14+
it('parses boolean query params and preserves false values', () => {
15+
window.history.replaceState(null, '', '/?transparentBg=0&reverseHighlight=yes');
16+
const state = loadGeneratorState();
17+
expect(state.transparentBg).toBe(false);
18+
expect(state.reverseHighlight).toBe(true);
19+
});
20+
21+
it('ignores malformed booleans and swallows storage parse errors', () => {
22+
window.history.replaceState(null, '', '/?transparentBg=maybe&prefixColor=%23fff');
23+
window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, '{');
24+
25+
const state = loadGeneratorState();
26+
expect(state.transparentBg).toBeUndefined();
27+
expect(state.prefixColor).toBe('#fff');
28+
});
29+
30+
it('normalizes payloads before saving and updates the URL', () => {
31+
saveGeneratorState({
32+
prefix: 'Share',
33+
suffix: 'Logo',
34+
font: 'Lora',
35+
fontSize: '120',
36+
transparentBg: '1',
37+
reverseHighlight: 'no',
38+
postfixBgColor: '#123456',
39+
extraneous: 'ignore-me'
40+
});
41+
42+
const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));
43+
expect(saved.fontSize).toBe(120);
44+
expect(saved.transparentBg).toBe(true);
45+
expect(saved.reverseHighlight).toBe(false);
46+
expect(saved.extraneous).toBeUndefined();
47+
48+
expect(window.location.search).toContain('prefix=Share');
49+
expect(window.location.search).toContain('reverseHighlight=0');
50+
});
51+
52+
it('no-ops when window APIs are unavailable', () => {
53+
const originalWindow = window;
54+
// eslint-disable-next-line no-global-assign
55+
window = undefined;
56+
57+
expect(loadGeneratorState()).toEqual({});
58+
expect(() => saveGeneratorState({ prefix: 'SSR' })).not.toThrow();
59+
60+
// eslint-disable-next-line no-global-assign
61+
window = originalWindow;
62+
});
63+
64+
it('ignores null, empty, or non-string values', () => {
65+
saveGeneratorState({
66+
prefix: 'Keep',
67+
suffix: '',
68+
transparentBg: null,
69+
postfixBgColor: 123
70+
});
71+
72+
const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));
73+
expect(saved.prefix).toBe('Keep');
74+
expect(saved.suffix).toBe('');
75+
expect(saved.transparentBg).toBeUndefined();
76+
expect(saved.postfixBgColor).toBeUndefined();
77+
78+
window.history.replaceState(null, '', '/?reverseHighlight=&fontSize=');
79+
const state = loadGeneratorState();
80+
expect(state.reverseHighlight).toBeUndefined();
81+
expect(state.fontSize).toBeUndefined();
82+
});
83+
84+
it('skips invalid storage payloads', () => {
85+
window.localStorage.setItem(
86+
GENERATOR_STATE_STORAGE_KEY,
87+
JSON.stringify({
88+
prefix: 123,
89+
suffix: 'Share',
90+
fontSize: 'huge'
91+
})
92+
);
93+
const state = loadGeneratorState();
94+
expect(state.prefix).toBeUndefined();
95+
expect(state.suffix).toBe('Share');
96+
expect(state.fontSize).toBeUndefined();
97+
});
98+
99+
it('handles malformed JSON in storage as empty state', () => {
100+
window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, '"no-object"');
101+
expect(loadGeneratorState()).toEqual({});
102+
});
103+
});

src/__tests__/useGeneratorControls.test.js

Lines changed: 182 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { describe, it, expect, beforeEach, vi } from 'vitest';
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22
import { mount } from '@vue/test-utils';
33
import { defineComponent, nextTick } from 'vue';
44
import { createPinia, setActivePinia } from 'pinia';
55
import { useGeneratorControls } from '@/composables/useGeneratorControls';
66
import { useStore } from '@/stores/store';
7+
import { GENERATOR_STATE_STORAGE_KEY } from '@/utils/persistentState';
8+
9+
const mountedWrappers = new Set();
710

811
const mountComposable = (options) => {
912
let api;
@@ -16,12 +19,24 @@ const mountComposable = (options) => {
1619
});
1720

1821
const wrapper = mount(Comp);
22+
mountedWrappers.add(wrapper);
1923
return { wrapper, api };
2024
};
2125

2226
describe('useGeneratorControls', () => {
2327
beforeEach(() => {
2428
setActivePinia(createPinia());
29+
window.localStorage.clear();
30+
window.history.replaceState(null, '', '/');
31+
});
32+
33+
afterEach(() => {
34+
mountedWrappers.forEach((wrapper) => {
35+
if (wrapper.exists()) {
36+
wrapper.unmount();
37+
}
38+
});
39+
mountedWrappers.clear();
2540
});
2641

2742
it('provides sensible defaults and computed helpers', () => {
@@ -58,7 +73,7 @@ describe('useGeneratorControls', () => {
5873
expect(suffixSpy).toHaveBeenLastCalledWith('');
5974
});
6075

61-
it('hydrates initial text on mount and resets on unmount', async () => {
76+
it('hydrates initial text on mount when nothing is persisted', async () => {
6277
const store = useStore();
6378
const prefixSpy = vi.spyOn(store, 'updatePrefix');
6479
const suffixSpy = vi.spyOn(store, 'updateSuffix');
@@ -75,11 +90,11 @@ describe('useGeneratorControls', () => {
7590

7691
wrapper.unmount();
7792
await nextTick();
78-
expect(prefixSpy).toHaveBeenLastCalledWith('edit');
79-
expect(suffixSpy).toHaveBeenLastCalledWith('me');
93+
expect(prefixSpy).not.toHaveBeenCalledWith('edit');
94+
expect(suffixSpy).not.toHaveBeenCalledWith('me');
8095
});
8196

82-
it('handles partial initial and reset payloads', async () => {
97+
it('handles partial initial payloads without clobbering other fields', async () => {
8398
const store = useStore();
8499
const prefixSpy = vi.spyOn(store, 'updatePrefix');
85100
const suffixSpy = vi.spyOn(store, 'updateSuffix');
@@ -100,24 +115,67 @@ describe('useGeneratorControls', () => {
100115
expect(suffixSpy).not.toHaveBeenCalled();
101116
prefixOnly.wrapper.unmount();
102117
await nextTick();
118+
});
103119

104-
prefixSpy.mockClear();
105-
suffixSpy.mockClear();
120+
it('persists generator state to localStorage and query params', async () => {
121+
const store = useStore();
122+
const { api } = mountComposable();
106123

107-
const resetPrefixOnly = mountComposable({ resetText: { prefix: 'reset' } });
108-
resetPrefixOnly.wrapper.unmount();
124+
await store.updatePrefix('Share');
125+
await store.updateSuffix('Logo');
126+
store.font = 'Open Sans';
127+
api.prefixColor.value = '#123123';
128+
api.suffixColor.value = '#abcdef';
129+
api.postfixBgColor.value = '#654321';
130+
api.fontSize.value = 110;
131+
api.transparentBg.value = true;
132+
api.reverseHighlight.value = true;
109133
await nextTick();
110-
expect(prefixSpy).toHaveBeenCalledWith('reset');
111-
expect(suffixSpy).not.toHaveBeenCalled();
112134

113-
prefixSpy.mockClear();
114-
suffixSpy.mockClear();
135+
const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));
136+
expect(saved.prefix).toBe('Share');
137+
expect(saved.suffix).toBe('Logo');
138+
expect(saved.font).toBe('Open Sans');
139+
expect(saved.prefixColor).toBe('#123123');
140+
expect(saved.transparentBg).toBe(true);
141+
expect(window.location.search).toContain('prefix=Share');
142+
expect(window.location.search).toContain('reverseHighlight=1');
143+
});
115144

116-
const resetSuffixOnly = mountComposable({ resetText: { suffix: 'again' } });
117-
resetSuffixOnly.wrapper.unmount();
145+
it('restores state from storage and lets query params override it', async () => {
146+
window.localStorage.setItem(
147+
GENERATOR_STATE_STORAGE_KEY,
148+
JSON.stringify({
149+
prefix: 'StoredPrefix',
150+
suffix: 'StoredSuffix',
151+
font: 'Lora',
152+
prefixColor: '#101010',
153+
suffixColor: '#202020',
154+
postfixBgColor: '#303030',
155+
fontSize: 80,
156+
transparentBg: true,
157+
reverseHighlight: false
158+
})
159+
);
160+
window.history.replaceState(
161+
null,
162+
'',
163+
'/?prefix=QueryPrefix&suffixColor=%23aa00aa&reverseHighlight=1'
164+
);
165+
166+
const { api } = mountComposable({ initialText: { prefix: 'Default', suffix: 'Values' } });
167+
const store = useStore();
118168
await nextTick();
119-
expect(suffixSpy).toHaveBeenCalledWith('again');
120-
expect(prefixSpy).not.toHaveBeenCalled();
169+
170+
expect(store.prefix).toBe('QueryPrefix');
171+
expect(store.suffix).toBe('StoredSuffix');
172+
expect(store.font).toBe('Lora');
173+
expect(api.prefixColor.value).toBe('#101010');
174+
expect(api.suffixColor.value).toBe('#aa00aa');
175+
expect(api.postfixBgColor.value).toBe('#303030');
176+
expect(api.fontSize.value).toBe(80);
177+
expect(api.transparentBg.value).toBe(true);
178+
expect(api.reverseHighlight.value).toBe(true);
121179
});
122180

123181
it('builds the Twitter intent url with the expected payload', () => {
@@ -132,4 +190,111 @@ describe('useGeneratorControls', () => {
132190

133191
openSpy.mockRestore();
134192
});
193+
194+
it('hydrates correctly even when store updates resolve synchronously', async () => {
195+
const store = useStore();
196+
const originalPrefix = store.updatePrefix;
197+
const originalSuffix = store.updateSuffix;
198+
199+
store.updatePrefix = vi.fn((text) => {
200+
store.prefix = text;
201+
return text;
202+
});
203+
store.updateSuffix = vi.fn((text) => {
204+
store.suffix = text;
205+
return text;
206+
});
207+
208+
mountComposable({ initialText: { prefix: 'Sync', suffix: 'State' } });
209+
await nextTick();
210+
expect(store.updatePrefix).toHaveBeenCalledWith('Sync');
211+
expect(store.updateSuffix).toHaveBeenCalledWith('State');
212+
expect(store.prefix).toBe('Sync');
213+
expect(store.suffix).toBe('State');
214+
215+
store.updatePrefix = originalPrefix;
216+
store.updateSuffix = originalSuffix;
217+
});
218+
219+
it('resets text on unmount when persistence is disabled', async () => {
220+
const store = useStore();
221+
await store.updatePrefix('Tmp');
222+
await store.updateSuffix('State');
223+
224+
const prefixSpy = vi.spyOn(store, 'updatePrefix');
225+
const suffixSpy = vi.spyOn(store, 'updateSuffix');
226+
227+
const { wrapper } = mountComposable({
228+
resetText: { prefix: 'edit', suffix: 'me' },
229+
persistenceEnabled: false
230+
});
231+
232+
await nextTick();
233+
prefixSpy.mockClear();
234+
suffixSpy.mockClear();
235+
236+
wrapper.unmount();
237+
await nextTick();
238+
239+
expect(prefixSpy).toHaveBeenCalledWith('edit');
240+
expect(suffixSpy).toHaveBeenCalledWith('me');
241+
});
242+
243+
it('does not reset prefix when resetText omits it', async () => {
244+
const store = useStore();
245+
const prefixSpy = vi.spyOn(store, 'updatePrefix');
246+
const suffixSpy = vi.spyOn(store, 'updateSuffix');
247+
248+
const { wrapper } = mountComposable({
249+
resetText: { suffix: 'stay' },
250+
persistenceEnabled: false
251+
});
252+
253+
await nextTick();
254+
prefixSpy.mockClear();
255+
suffixSpy.mockClear();
256+
257+
wrapper.unmount();
258+
await nextTick();
259+
260+
expect(prefixSpy).not.toHaveBeenCalled();
261+
expect(suffixSpy).toHaveBeenCalledWith('stay');
262+
});
263+
264+
it('does not reset suffix when resetText omits it', async () => {
265+
const store = useStore();
266+
const prefixSpy = vi.spyOn(store, 'updatePrefix');
267+
const suffixSpy = vi.spyOn(store, 'updateSuffix');
268+
269+
const { wrapper } = mountComposable({
270+
resetText: { prefix: 'back' },
271+
persistenceEnabled: false
272+
});
273+
274+
await nextTick();
275+
prefixSpy.mockClear();
276+
suffixSpy.mockClear();
277+
278+
wrapper.unmount();
279+
await nextTick();
280+
281+
expect(prefixSpy).toHaveBeenCalledWith('back');
282+
expect(suffixSpy).not.toHaveBeenCalled();
283+
});
284+
285+
it('continues hydrating when a task rejects', async () => {
286+
const store = useStore();
287+
const originalPrefix = store.updatePrefix;
288+
const rejection = new Error('hydrate failure');
289+
store.updatePrefix = vi.fn(() => Promise.reject(rejection));
290+
const suffixSpy = vi.spyOn(store, 'updateSuffix');
291+
292+
mountComposable({ initialText: { prefix: 'Only', suffix: 'Fans' } });
293+
await new Promise((resolve) => setTimeout(resolve, 0));
294+
295+
expect(store.updatePrefix).toHaveBeenCalledWith('Only');
296+
expect(suffixSpy).toHaveBeenCalledWith('Fans');
297+
298+
store.updatePrefix = originalPrefix;
299+
});
135300
});

0 commit comments

Comments
 (0)