mirror of https://github.com/ghostfolio/ghostfolio
11 changed files with 402 additions and 38 deletions
@ -0,0 +1,56 @@ |
|||||
|
import { |
||||
|
AUTO_REFRESH_INTERVAL_MS, |
||||
|
AutoRefreshService |
||||
|
} from './auto-refresh.service'; |
||||
|
|
||||
|
describe('AutoRefreshService', () => { |
||||
|
const setVisibility = (state: 'hidden' | 'visible') => { |
||||
|
Object.defineProperty(document, 'visibilityState', { |
||||
|
configurable: true, |
||||
|
get: () => state |
||||
|
}); |
||||
|
document.dispatchEvent(new Event('visibilitychange')); |
||||
|
}; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
jest.useFakeTimers(); |
||||
|
setVisibility('visible'); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
jest.useRealTimers(); |
||||
|
}); |
||||
|
|
||||
|
it('emits at the interval while the page is visible', () => { |
||||
|
const service = new AutoRefreshService(); |
||||
|
const next = jest.fn(); |
||||
|
const subscription = service.refresh$.subscribe(next); |
||||
|
|
||||
|
jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS - 1); |
||||
|
expect(next).not.toHaveBeenCalled(); |
||||
|
|
||||
|
jest.advanceTimersByTime(1); |
||||
|
expect(next).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS * 2); |
||||
|
expect(next).toHaveBeenCalledTimes(3); |
||||
|
|
||||
|
subscription.unsubscribe(); |
||||
|
}); |
||||
|
|
||||
|
it('pauses while hidden and refreshes immediately when visible again', () => { |
||||
|
const service = new AutoRefreshService(); |
||||
|
const next = jest.fn(); |
||||
|
const subscription = service.refresh$.subscribe(next); |
||||
|
|
||||
|
setVisibility('hidden'); |
||||
|
jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS * 3); |
||||
|
expect(next).not.toHaveBeenCalled(); |
||||
|
|
||||
|
setVisibility('visible'); |
||||
|
jest.advanceTimersByTime(0); |
||||
|
expect(next).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
subscription.unsubscribe(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,45 @@ |
|||||
|
import { Service } from '@angular/core'; |
||||
|
import { EMPTY, fromEvent, Observable, timer } from 'rxjs'; |
||||
|
import { |
||||
|
distinctUntilChanged, |
||||
|
map, |
||||
|
share, |
||||
|
startWith, |
||||
|
switchMap |
||||
|
} from 'rxjs/operators'; |
||||
|
|
||||
|
export const AUTO_REFRESH_INTERVAL_MS = 15_000; |
||||
|
|
||||
|
/** |
||||
|
* Emits at a fixed interval while the page is visible so that pages can |
||||
|
* refresh their numbers silently. The timer stops while the tab is hidden |
||||
|
* and fires once immediately when the tab becomes visible again. |
||||
|
*/ |
||||
|
@Service() |
||||
|
export class AutoRefreshService { |
||||
|
public readonly refresh$: Observable<void>; |
||||
|
|
||||
|
public constructor() { |
||||
|
let isFirstActivation = true; |
||||
|
|
||||
|
this.refresh$ = fromEvent(document, 'visibilitychange').pipe( |
||||
|
startWith(undefined), |
||||
|
map(() => document.visibilityState === 'visible'), |
||||
|
distinctUntilChanged(), |
||||
|
switchMap((isVisible) => { |
||||
|
if (!isVisible) { |
||||
|
return EMPTY; |
||||
|
} |
||||
|
|
||||
|
const dueTime = isFirstActivation ? AUTO_REFRESH_INTERVAL_MS : 0; |
||||
|
|
||||
|
isFirstActivation = false; |
||||
|
|
||||
|
return timer(dueTime, AUTO_REFRESH_INTERVAL_MS).pipe( |
||||
|
map(() => undefined) |
||||
|
); |
||||
|
}), |
||||
|
share() |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
import { getValueChangeDirection } from './value-change.helper'; |
||||
|
|
||||
|
describe('getValueChangeDirection', () => { |
||||
|
it('detects an increase', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: 101, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBe('up'); |
||||
|
}); |
||||
|
|
||||
|
it('detects a decrease', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: 99.5, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBe('down'); |
||||
|
}); |
||||
|
|
||||
|
it('ignores changes below the displayed precision', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: 100.001, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('ignores values which are not finite numbers', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: NaN, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBeUndefined(); |
||||
|
|
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: '101', |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,32 @@ |
|||||
|
/** |
||||
|
* Returns the direction in which a displayed number has changed, or |
||||
|
* `undefined` if it is unchanged at the displayed precision. |
||||
|
*/ |
||||
|
export function getValueChangeDirection({ |
||||
|
currentValue, |
||||
|
digits, |
||||
|
previousValue |
||||
|
}: { |
||||
|
currentValue: unknown; |
||||
|
digits: number; |
||||
|
previousValue: unknown; |
||||
|
}): 'down' | 'up' | undefined { |
||||
|
if ( |
||||
|
typeof currentValue !== 'number' || |
||||
|
typeof previousValue !== 'number' || |
||||
|
!Number.isFinite(currentValue) || |
||||
|
!Number.isFinite(previousValue) |
||||
|
) { |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
const factor = 10 ** digits; |
||||
|
const difference = |
||||
|
Math.round(currentValue * factor) - Math.round(previousValue * factor); |
||||
|
|
||||
|
if (difference === 0) { |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
return difference > 0 ? 'up' : 'down'; |
||||
|
} |
||||
Loading…
Reference in new issue