Implement memoization in the book card to reduce CPU usage during scrolling (#2198)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-01-07 14:13:42 -07:00
committed by GitHub
parent bd3df812b7
commit c7afe6ec13
5 changed files with 143 additions and 417 deletions

View File

@@ -1,24 +1,16 @@
<div class="book-card"
[class.selected]="isSelected"
(mouseover)="isHovered = true"
(mouseout)="isHovered = false"
(click)="onCardClick($event)">
<div class="cover-container" [ngClass]="{ 'shimmer': !isImageLoaded, 'center-info-btn': isSeriesViewActive() }">
<div
class="cover-container"
[ngClass]="{
'center-info-btn': isSeriesViewActive(),
'loaded': isImageLoaded
}">
<img
[src]="urlHelper.getThumbnailUrl(book.id, book.metadata?.coverUpdatedOn)"
class="book-cover"
[class.loaded]="isImageLoaded"
alt="Cover of {{ displayTitle }}"
loading="lazy"
(load)="onImageLoad()"/>
</div>
<div class="cover-container" [ngClass]="{ 'center-info-btn': _isSeriesViewActive, 'loaded': isImageLoaded }">
<img
[src]="coverImageUrl"
class="book-cover"
[class.loaded]="isImageLoaded"
[alt]="'Cover of ' + displayTitle"
loading="lazy"
decoding="async"
(load)="onImageLoad()"/>
@if (!book.seriesCount && book.metadata?.seriesNumber != null) {
<div class="series-number-overlay">
@@ -26,19 +18,21 @@
</div>
}
@if (book.seriesCount && book.seriesCount! >= 1) {
<div class="series-items-count-overlay" [pTooltip]="'Series collapsed: ' + book.seriesCount + ' books'" tooltipPosition="right">
@if (book.seriesCount && book.seriesCount >= 1) {
<div class="series-items-count-overlay" [pTooltip]="seriesCountTooltip" tooltipPosition="right">
<i class="pi pi-book" style="font-size: 0.65rem; margin-right: 2px;"></i>{{ book.seriesCount }}
</div>
}
@if (isSeriesViewActive()) {
@if (_isSeriesViewActive) {
<p-button [rounded]="true" icon="pi pi-info" class="info-btn" (click)="openSeriesInfo()"></p-button>
} @else {
<p-button [rounded]="true" icon="pi pi-info" class="info-btn" (click)="openBookInfo(book)"></p-button>
}
<p-button [hidden]="isSeriesViewActive() || !canReadBook()" [rounded]="true" icon="pi pi-book" class="read-btn" (click)="readBook(book)"></p-button>
@if (!_isSeriesViewActive && _canReadBook) {
<p-button [rounded]="true" icon="pi pi-book" class="read-btn" (click)="readBook(book)"></p-button>
}
@if (isCheckboxEnabled) {
<p-checkbox
@@ -49,51 +43,50 @@
(onChange)="toggleSelection($event)">
</p-checkbox>
}
<div class="cover-progress-bar-container">
@if (progressPercentage !== null) {
<p-progressBar
[value]="progressPercentage"
[showValue]="false"
class="cover-progress-bar"
[ngClass]="{ 'progress-complete': progressPercentage === 100, 'progress-incomplete': progressPercentage < 100 }">
</p-progressBar>
}
@if (koProgressPercentage !== null) {
<p-progressBar
[value]="koProgressPercentage"
[showValue]="false"
class="cover-progress-bar"
[ngClass]="[
koProgressPercentage === 100 ? 'progress-complete-ko' : 'progress-incomplete-ko'
]">
</p-progressBar>
}
@if (koboProgressPercentage !== null) {
<p-progressBar
[value]="koboProgressPercentage"
[showValue]="false"
class="cover-progress-bar"
[ngClass]="[
koboProgressPercentage === 100 ? 'progress-complete-kobo' : 'progress-incomplete-kobo'
]">
</p-progressBar>
}
</div>
@if (hasProgress) {
<div class="cover-progress-bar-container">
@if (_progressPercentage !== null) {
<p-progressBar
[value]="_progressPercentage"
[showValue]="false"
class="cover-progress-bar"
[ngClass]="{ 'progress-complete': _progressPercentage === 100, 'progress-incomplete': _progressPercentage < 100 }">
</p-progressBar>
}
@if (_koProgressPercentage !== null) {
<p-progressBar
[value]="_koProgressPercentage"
[showValue]="false"
class="cover-progress-bar"
[ngClass]="_koProgressPercentage === 100 ? 'progress-complete-ko' : 'progress-incomplete-ko'">
</p-progressBar>
}
@if (_koboProgressPercentage !== null) {
<p-progressBar
[value]="_koboProgressPercentage"
[showValue]="false"
class="cover-progress-bar"
[ngClass]="_koboProgressPercentage === 100 ? 'progress-complete-kobo' : 'progress-incomplete-kobo'">
</p-progressBar>
}
</div>
}
</div>
<div [hidden]="bottomBarHidden">
@if (!bottomBarHidden) {
<div class="book-title-container flex items-center">
@if (shouldShowStatusIcon()) {
@if (_shouldShowStatusIcon) {
<div class="read-status-indicator"
[ngClass]="getReadStatusClass()"
[pTooltip]="'Status: ' + getReadStatusTooltip()"
[ngClass]="_readStatusClass"
[pTooltip]="readStatusTooltip"
tooltipPosition="bottom">
<i [class]="getReadStatusIcon()" style="font-size: 0.9rem"></i>
<i [class]="_readStatusIcon" style="font-size: 0.9rem"></i>
</div>
}
<h4 class="book-title m-0 pl-2"
tooltipPosition="bottom"
[pTooltip]="'Title: ' + displayTitle">
[pTooltip]="titleTooltip">
{{ displayTitle }}
</h4>
<p-tieredmenu #menu [model]="items" [popup]="true" appendTo="body" (onShow)="onMenuShow()" (onHide)="onMenuHide()"></p-tieredmenu>
@@ -105,5 +98,5 @@
icon="pi pi-ellipsis-v">
</p-button>
</div>
</div>
}
</div>

View File

@@ -3,7 +3,6 @@
border-radius: 8px 8px 8px 8px;
position: relative;
overflow: hidden;
transition: border 0.2s;
&.selected {
border: 2px solid var(--primary-color);
@@ -20,7 +19,6 @@
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease;
}
.cover-container.loaded {
@@ -32,11 +30,11 @@
height: 100%;
object-fit: cover;
border-radius: 8px 8px 0 0;
transition: opacity 0.3s ease-in;
opacity: 0;
transition: opacity 0.2s ease-in;
}
.cover-container img.loaded {
.book-cover.loaded {
opacity: 1;
}
@@ -49,7 +47,7 @@
height: 100%;
background: radial-gradient(circle, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0.6) 100%);
opacity: 0;
transition: opacity 0.2s ease-in-out;
transition: opacity 0.15s ease-in-out;
pointer-events: none;
}
@@ -80,8 +78,9 @@
transform: translateX(-50%);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
transition: opacity 0.15s ease, visibility 0.15s ease;
z-index: 2;
will-change: opacity, visibility;
}
.info-btn {
@@ -109,8 +108,9 @@
right: 8px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
transition: opacity 0.15s ease, visibility 0.15s ease;
z-index: 2;
will-change: opacity, visibility;
}
.select-checkbox .p-checkbox {

View File

@@ -1,4 +1,4 @@
import {Component, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild} from '@angular/core';
import {TooltipModule} from "primeng/tooltip";
import {AdditionalFile, Book, ReadStatus} from '../../../model/book.model';
import {Button} from 'primeng/button';
@@ -29,7 +29,8 @@ import {BookNavigationService} from '../../../service/book-navigation.service';
templateUrl: './book-card.component.html',
styleUrls: ['./book-card.component.scss'],
imports: [Button, MenuModule, CheckboxModule, FormsModule, NgClass, TieredMenu, ProgressBar, TooltipModule],
standalone: true
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
@@ -48,7 +49,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
@ViewChild('checkboxElem') checkboxElem!: ElementRef<HTMLInputElement>;
items: MenuItem[] | undefined;
isHovered: boolean = false;
isImageLoaded: boolean = false;
isSubMenuLoading = false;
private additionalFilesLoaded = false;
@@ -63,13 +63,31 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
private confirmationService = inject(ConfirmationService);
private bookDialogHelperService = inject(BookDialogHelperService);
private bookNavigationService = inject(BookNavigationService);
private cdr = inject(ChangeDetectorRef);
protected _progressPercentage: number | null = null;
protected _koProgressPercentage: number | null = null;
protected _koboProgressPercentage: number | null = null;
protected _displayTitle: string | undefined = undefined;
protected _canReadBook: boolean = true;
protected _isSeriesViewActive: boolean = false;
protected _coverImageUrl: string = '';
protected _readStatusIcon: string = '';
protected _readStatusClass: string = '';
protected _readStatusTooltip: string = '';
protected _shouldShowStatusIcon: boolean = false;
protected _seriesCountTooltip: string = '';
protected _titleTooltip: string = '';
protected _hasProgress: boolean = false;
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
private destroy$ = new Subject<void>();
protected readStatusHelper = inject(ReadStatusHelper);
private user: User | null = null;
private menuInitialized = false;
ngOnInit(): void {
this.computeAllMemoizedValues();
this.userService.userState$
.pipe(
filter(userState => !!userState?.user && userState.loaded),
@@ -79,50 +97,79 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
.subscribe(userState => {
this.user = userState.user;
this.metadataCenterViewMode = userState.user?.userSettings?.metadataCenterViewMode ?? 'route';
this.initMenu();
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['book'] && !changes['book'].firstChange) {
this.additionalFilesLoaded = false;
this.initMenu();
if (changes['book']) {
this.computeAllMemoizedValues();
if (!changes['book'].firstChange && this.menuInitialized) {
this.additionalFilesLoaded = false;
this.initMenu();
}
}
if (changes['seriesViewEnabled'] || changes['isSeriesCollapsed']) {
this._isSeriesViewActive = this.seriesViewEnabled && !!this.book.seriesCount && this.book.seriesCount >= 1;
this._displayTitle = (this.isSeriesCollapsed && this.book.metadata?.seriesName) ? this.book.metadata?.seriesName : this.book.metadata?.title;
this._titleTooltip = 'Title: ' + this._displayTitle;
}
}
get progressPercentage(): number | null {
if (this.book.epubProgress?.percentage != null) {
return this.book.epubProgress.percentage;
}
if (this.book.pdfProgress?.percentage != null) {
return this.book.pdfProgress.percentage;
}
if (this.book.cbxProgress?.percentage != null) {
return this.book.cbxProgress.percentage;
}
return null;
private computeAllMemoizedValues(): void {
this._progressPercentage = this.book.epubProgress?.percentage
?? this.book.pdfProgress?.percentage
?? this.book.cbxProgress?.percentage
?? null;
this._koProgressPercentage = this.book.koreaderProgress?.percentage ?? null;
this._koboProgressPercentage = this.book.koboProgress?.percentage ?? null;
this._hasProgress = this._progressPercentage !== null || this._koProgressPercentage !== null || this._koboProgressPercentage !== null;
this._isSeriesViewActive = this.seriesViewEnabled && !!this.book.seriesCount && this.book.seriesCount >= 1;
this._displayTitle = (this.isSeriesCollapsed && this.book.metadata?.seriesName)
? this.book.metadata?.seriesName
: this.book.metadata?.title;
this._canReadBook = this.book?.bookType !== 'FB2';
this._coverImageUrl = this.urlHelper.getThumbnailUrl(this.book.id, this.book.metadata?.coverUpdatedOn);
this._readStatusIcon = this.readStatusHelper.getReadStatusIcon(this.book.readStatus);
this._readStatusClass = this.readStatusHelper.getReadStatusClass(this.book.readStatus);
this._readStatusTooltip = this.readStatusHelper.getReadStatusTooltip(this.book.readStatus);
this._shouldShowStatusIcon = this.readStatusHelper.shouldShowStatusIcon(this.book.readStatus);
this._seriesCountTooltip = 'Series collapsed: ' + this.book.seriesCount + ' books';
this._titleTooltip = 'Title: ' + this._displayTitle;
}
get koProgressPercentage(): number | null {
if (this.book.koreaderProgress?.percentage != null) {
return this.book.koreaderProgress.percentage;
}
return null;
get hasProgress(): boolean {
return this._hasProgress;
}
get koboProgressPercentage(): number | null {
if (this.book.koboProgress?.percentage != null) {
return this.book.koboProgress.percentage;
}
return null;
get seriesCountTooltip(): string {
return this._seriesCountTooltip;
}
get titleTooltip(): string {
return this._titleTooltip;
}
get readStatusTooltip(): string {
return this._readStatusTooltip;
}
get displayTitle(): string | undefined {
return (this.isSeriesCollapsed && this.book.metadata?.seriesName) ? this.book.metadata?.seriesName : this.book.metadata?.title;
return this._displayTitle;
}
get coverImageUrl(): string {
return this._coverImageUrl;
}
onImageLoad(): void {
this.isImageLoaded = true;
this.cdr.markForCheck();
}
readBook(book: Book): void {
@@ -138,19 +185,28 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
}
onMenuToggle(event: Event, menu: TieredMenu): void {
if (!this.menuInitialized) {
this.menuInitialized = true;
this.initMenu();
this.cdr.markForCheck();
}
menu.toggle(event);
if (!this.additionalFilesLoaded && !this.isSubMenuLoading && this.needsAdditionalFilesData()) {
this.isSubMenuLoading = true;
this.cdr.markForCheck();
this.bookService.getBookByIdFromAPI(this.book.id, true).subscribe({
next: (book) => {
this.book = book;
this.additionalFilesLoaded = true;
this.isSubMenuLoading = false;
this.initMenu();
this.cdr.markForCheck();
},
error: () => {
this.isSubMenuLoading = false;
this.cdr.markForCheck();
}
});
}
@@ -671,10 +727,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
return sizeKb != null ? `${(sizeKb / 1024).toFixed(2)} MB` : '-';
}
canReadBook(): boolean {
return this.book?.bookType !== 'FB2';
}
private lastMouseEvent: MouseEvent | null = null;
captureMouseEvent(event: MouseEvent): void {
@@ -719,24 +771,4 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
this.destroy$.next();
this.destroy$.complete();
}
getReadStatusIcon(): string {
return this.readStatusHelper.getReadStatusIcon(this.book.readStatus);
}
getReadStatusClass(): string {
return this.readStatusHelper.getReadStatusClass(this.book.readStatus);
}
getReadStatusTooltip(): string {
return this.readStatusHelper.getReadStatusTooltip(this.book.readStatus);
}
shouldShowStatusIcon(): boolean {
return this.readStatusHelper.shouldShowStatusIcon(this.book.readStatus);
}
isSeriesViewActive(): boolean {
return this.seriesViewEnabled && !!this.book.seriesCount && this.book.seriesCount! >= 1;
}
}

View File

@@ -1,49 +0,0 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {LocalStorageService} from './local-storage.service';
describe('LocalStorageService', () => {
let service: LocalStorageService;
beforeEach(() => {
service = new LocalStorageService();
vi.spyOn(window.localStorage, 'getItem').mockClear();
vi.spyOn(window.localStorage, 'setItem').mockClear();
vi.spyOn(window.localStorage, 'removeItem').mockClear();
});
it('should get value from localStorage', () => {
window.localStorage.setItem('test', JSON.stringify({foo: 'bar'}));
const result = service.get<{foo: string}>('test');
expect(result).toEqual({foo: 'bar'});
});
it('should return null if key does not exist', () => {
window.localStorage.removeItem('notfound');
const result = service.get('notfound');
expect(result).toBeNull();
});
it('should handle JSON parse error and return null', () => {
window.localStorage.setItem('bad', 'not-json');
const result = service.get('bad');
expect(result).toBeNull();
});
it('should set value in localStorage', () => {
service.set('foo', {bar: 123});
const stored = window.localStorage.getItem('foo');
expect(stored).toBe(JSON.stringify({bar: 123}));
});
it('should handle set error gracefully', () => {
vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { throw new Error('fail'); });
expect(() => service.set('fail', {a: 1})).not.toThrow();
});
it('should remove value from localStorage', () => {
window.localStorage.setItem('toremove', '1');
service.remove('toremove');
expect(window.localStorage.getItem('toremove')).toBeNull();
});
});

View File

@@ -1,250 +0,0 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {TestBed} from '@angular/core/testing';
import {EnvironmentInjector, runInInjectionContext} from '@angular/core';
import {BookType} from '../../features/book/model/book.model';
import {ReadingSessionService, ReadingSession} from './reading-session.service';
import {ReadingSessionApiService, CreateReadingSessionDto} from './reading-session-api.service';
import {of} from 'rxjs';
describe('ReadingSessionService', () => {
let service: ReadingSessionService;
let apiServiceMock: any;
const now = new Date();
const mockSession: ReadingSession = {
bookId: 42,
bookType: 'PDF' as BookType,
startTime: now,
startLocation: 'start-cfi',
startProgress: 10
};
beforeEach(() => {
apiServiceMock = {
createSession: vi.fn().mockReturnValue(of(void 0)),
sendSessionBeacon: vi.fn().mockReturnValue(true)
};
TestBed.configureTestingModule({
providers: [
ReadingSessionService,
{provide: ReadingSessionApiService, useValue: apiServiceMock}
]
});
const injector = TestBed.inject(EnvironmentInjector);
service = runInInjectionContext(injector, () => TestBed.inject(ReadingSessionService));
});
it('should start a session', () => {
service.startSession(1, 'PDF', 'loc', 5);
expect(service.isSessionActive()).toBe(true);
});
it('should end a session and send to backend if duration is sufficient', () => {
vi.useFakeTimers();
service.startSession(1, 'PDF', 'loc', 5);
// Simulate session duration > MIN_SESSION_DURATION_SECONDS
(service as any).currentSession!.startTime = new Date(Date.now() - 60000);
service.endSession('endloc', 10);
expect(apiServiceMock.createSession).toHaveBeenCalled();
expect(service.isSessionActive()).toBe(false);
vi.useRealTimers();
});
it('should discard session if duration is too short', () => {
vi.useFakeTimers();
service.startSession(1, 'PDF', 'loc', 5);
(service as any).currentSession!.startTime = new Date(Date.now() - 10000);
service.endSession('endloc', 10);
expect(apiServiceMock.createSession).not.toHaveBeenCalled();
expect(service.isSessionActive()).toBe(false);
vi.useRealTimers();
});
it('should update progress', () => {
service.startSession(1, 'PDF', 'loc', 5);
service.updateProgress('newloc', 15);
const session = (service as any).currentSession;
expect(session.endLocation).toBe('newloc');
expect(session.endProgress).toBe(15);
});
it('should end session synchronously with sendSessionBeacon', () => {
service.startSession(1, 'PDF', 'loc', 5);
(service as any).currentSession!.startTime = new Date(Date.now() - 60000);
service['endSessionSync']();
expect(apiServiceMock.sendSessionBeacon).toHaveBeenCalled();
expect(service.isSessionActive()).toBe(false);
});
it('should not send beacon if session duration is too short', () => {
service.startSession(1, 'PDF', 'loc', 5);
(service as any).currentSession!.startTime = new Date(Date.now() - 10000);
service['endSessionSync']();
expect(apiServiceMock.sendSessionBeacon).not.toHaveBeenCalled();
expect(service.isSessionActive()).toBe(false);
});
it('should return false for isSessionActive when no session', () => {
expect(service.isSessionActive()).toBe(false);
});
it('should build session data correctly', () => {
const endTime = new Date(now.getTime() + 60000);
const session: ReadingSession = {
bookId: 1,
bookType: 'PDF',
startTime: now,
endTime,
durationSeconds: 60,
startProgress: 10,
endProgress: 20,
progressDelta: 10,
startLocation: 'loc1',
endLocation: 'loc2'
};
const dto = (service as any).buildSessionData(session, endTime, 60);
expect(dto.bookId).toBe(1);
expect(dto.bookType).toBe('PDF');
expect(dto.durationSeconds).toBe(60);
expect(dto.startProgress).toBe(10);
expect(dto.endProgress).toBe(20);
expect(dto.progressDelta).toBe(10);
expect(dto.startLocation).toBe('loc1');
expect(dto.endLocation).toBe('loc2');
expect(typeof dto.startTime).toBe('string');
expect(typeof dto.endTime).toBe('string');
expect(dto.durationFormatted).toBe('1m 0s');
});
it('should format duration correctly', () => {
expect((service as any).formatDuration(3661)).toBe('1h 1m 1s');
expect((service as any).formatDuration(61)).toBe('1m 1s');
expect((service as any).formatDuration(10)).toBe('10s');
});
});
describe('ReadingSessionService - API Contract Tests', () => {
let service: ReadingSessionService;
let apiServiceMock: any;
beforeEach(() => {
apiServiceMock = {
createSession: vi.fn().mockReturnValue(of(void 0)),
sendSessionBeacon: vi.fn().mockReturnValue(true)
};
TestBed.configureTestingModule({
providers: [
ReadingSessionService,
{provide: ReadingSessionApiService, useValue: apiServiceMock}
]
});
const injector = TestBed.inject(EnvironmentInjector);
service = runInInjectionContext(injector, () => TestBed.inject(ReadingSessionService));
});
it('should call createSession with correct payload', () => {
const session: ReadingSession = {
bookId: 1,
bookType: 'PDF',
startTime: new Date(Date.now() - 60000),
endTime: new Date(),
durationSeconds: 60,
startProgress: 10,
endProgress: 20,
progressDelta: 10,
startLocation: 'loc1',
endLocation: 'loc2'
};
(service as any).sendSessionToBackend(session);
expect(apiServiceMock.createSession).toHaveBeenCalledWith(expect.objectContaining({
bookId: 1,
bookType: 'PDF',
durationSeconds: 60,
startProgress: 10,
endProgress: 20,
progressDelta: 10,
startLocation: 'loc1',
endLocation: 'loc2'
}));
});
it('should call sendSessionBeacon with correct payload', () => {
const session: ReadingSession = {
bookId: 2,
bookType: 'EPUB',
startTime: new Date(Date.now() - 120000),
startProgress: 0,
endProgress: 100,
progressDelta: 100,
startLocation: 'start',
endLocation: 'end'
};
const endTime = new Date();
const durationSeconds = 120;
const dto = (service as any).buildSessionData(session, endTime, durationSeconds);
(service as any).currentSession = session;
apiServiceMock.sendSessionBeacon.mockClear();
(service as any).endSessionSync();
expect(apiServiceMock.sendSessionBeacon).toHaveBeenCalledWith(expect.objectContaining({
bookId: 2,
bookType: 'EPUB',
startLocation: 'start',
endLocation: 'end'
}));
});
it('should not call createSession if endTime or durationSeconds missing', () => {
const session: ReadingSession = {
bookId: 1,
bookType: 'PDF',
startTime: new Date()
// missing endTime and durationSeconds
};
(service as any).sendSessionToBackend(session);
expect(apiServiceMock.createSession).not.toHaveBeenCalled();
});
it('should not call sendSessionBeacon if duration too short', () => {
const session: ReadingSession = {
bookId: 1,
bookType: 'PDF',
startTime: new Date(Date.now() - 10000)
};
(service as any).currentSession = session;
apiServiceMock.sendSessionBeacon.mockClear();
(service as any).endSessionSync();
expect(apiServiceMock.sendSessionBeacon).not.toHaveBeenCalled();
});
it('should send correct types in CreateReadingSessionDto', () => {
const session: ReadingSession = {
bookId: 3,
bookType: 'CBX',
startTime: new Date(Date.now() - 180000),
endTime: new Date(),
durationSeconds: 180,
startProgress: 0,
endProgress: 50,
progressDelta: 50,
startLocation: 'foo',
endLocation: 'bar'
};
const dto: CreateReadingSessionDto = (service as any).buildSessionData(session, session.endTime!, session.durationSeconds!);
expect(typeof dto.bookId).toBe('number');
expect(typeof dto.bookType).toBe('string');
expect(typeof dto.startTime).toBe('string');
expect(typeof dto.endTime).toBe('string');
expect(typeof dto.durationSeconds).toBe('number');
expect(typeof dto.durationFormatted).toBe('string');
expect(typeof dto.startProgress).toBe('number');
expect(typeof dto.endProgress).toBe('number');
expect(typeof dto.progressDelta).toBe('number');
expect(typeof dto.startLocation).toBe('string');
expect(typeof dto.endLocation).toBe('string');
});
});