mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user