1/*
2 * Copyright (C) 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {DragDropModule} from '@angular/cdk/drag-drop';
18import {ChangeDetectionStrategy} from '@angular/core';
19import {ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
20import {FormsModule, ReactiveFormsModule} from '@angular/forms';
21import {MatButtonModule} from '@angular/material/button';
22import {MatFormFieldModule} from '@angular/material/form-field';
23import {MatIconModule} from '@angular/material/icon';
24import {MatInputModule} from '@angular/material/input';
25import {MatSelectModule} from '@angular/material/select';
26import {MatTooltipModule} from '@angular/material/tooltip';
27import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
28import {assertDefined} from 'common/assert_utils';
29import {TimeRange} from 'common/time';
30import {TimestampConverterUtils} from 'test/unit/timestamp_converter_utils';
31import {dragElement} from 'test/utils';
32import {TracePosition} from 'trace/trace_position';
33import {MIN_SLIDER_WIDTH, SliderComponent} from './slider_component';
34
35describe('SliderComponent', () => {
36  let fixture: ComponentFixture<SliderComponent>;
37  let component: SliderComponent;
38  let htmlElement: HTMLElement;
39  const time100 = TimestampConverterUtils.makeRealTimestamp(100n);
40  const time125 = TimestampConverterUtils.makeRealTimestamp(125n);
41  const time126 = TimestampConverterUtils.makeRealTimestamp(126n);
42  const time150 = TimestampConverterUtils.makeRealTimestamp(150n);
43  const time175 = TimestampConverterUtils.makeRealTimestamp(175n);
44  const time200 = TimestampConverterUtils.makeRealTimestamp(200n);
45
46  beforeEach(async () => {
47    await TestBed.configureTestingModule({
48      imports: [
49        FormsModule,
50        MatButtonModule,
51        MatFormFieldModule,
52        MatInputModule,
53        MatIconModule,
54        MatSelectModule,
55        MatTooltipModule,
56        ReactiveFormsModule,
57        BrowserAnimationsModule,
58        DragDropModule,
59      ],
60      declarations: [SliderComponent],
61    })
62      .overrideComponent(SliderComponent, {
63        set: {changeDetection: ChangeDetectionStrategy.Default},
64      })
65      .compileComponents();
66    fixture = TestBed.createComponent(SliderComponent);
67    component = fixture.componentInstance;
68    htmlElement = fixture.nativeElement;
69
70    component.fullRange = new TimeRange(time100, time200);
71    component.zoomRange = new TimeRange(time125, time175);
72    component.currentPosition = TracePosition.fromTimestamp(time150);
73    component.timestampConverter = TimestampConverterUtils.TIMESTAMP_CONVERTER;
74
75    fixture.detectChanges();
76  });
77
78  it('can be created', () => {
79    expect(component).toBeTruthy();
80  });
81
82  it('reposition properly on zoom', () => {
83    fixture.detectChanges();
84    component.ngOnChanges({
85      zoomRange: {
86        firstChange: true,
87        isFirstChange: () => true,
88        previousValue: undefined,
89        currentValue: component.zoomRange,
90      },
91    });
92    fixture.detectChanges();
93
94    const sliderWitdth = component.sliderBox.nativeElement.offsetWidth;
95    expect(component.sliderWidth).toBe(sliderWitdth / 2);
96    expect(component.dragPosition.x).toBe(sliderWitdth / 4);
97  });
98
99  it('has min width', () => {
100    component.fullRange = new TimeRange(time100, time200);
101    component.zoomRange = new TimeRange(time125, time126);
102
103    fixture.detectChanges();
104    component.ngOnChanges({
105      zoomRange: {
106        firstChange: true,
107        isFirstChange: () => true,
108        previousValue: undefined,
109        currentValue: component.zoomRange,
110      },
111    });
112    fixture.detectChanges();
113
114    const sliderWidth = component.sliderBox.nativeElement.offsetWidth;
115    expect(component.sliderWidth).toBe(MIN_SLIDER_WIDTH);
116    expect(component.dragPosition.x).toBe(
117      sliderWidth / 4 - MIN_SLIDER_WIDTH / 2,
118    );
119  });
120
121  it('repositions slider on resize', () => {
122    const slider = assertDefined(htmlElement.querySelector('.slider'));
123    const cursor = assertDefined(htmlElement.querySelector('.cursor'));
124
125    fixture.detectChanges();
126
127    const initialSliderXPos = slider.getBoundingClientRect().left;
128    const initialCursorXPos = cursor.getBoundingClientRect().left;
129
130    spyOnProperty(
131      component.sliderBox.nativeElement,
132      'offsetWidth',
133      'get',
134    ).and.returnValue(100);
135    expect(component.sliderBox.nativeElement.offsetWidth).toBe(100);
136
137    htmlElement.style.width = '587px';
138    component.onResize({} as Event);
139    fixture.detectChanges();
140
141    expect(initialSliderXPos).not.toBe(slider.getBoundingClientRect().left);
142    expect(initialCursorXPos).not.toBe(cursor.getBoundingClientRect().left);
143  });
144
145  it('draws current position cursor', () => {
146    fixture.detectChanges();
147    component.ngOnChanges({
148      currentPosition: {
149        firstChange: true,
150        isFirstChange: () => true,
151        previousValue: undefined,
152        currentValue: component.currentPosition,
153      },
154    });
155    fixture.detectChanges();
156
157    const sliderBox = assertDefined(
158      htmlElement.querySelector('#timeline-slider-box'),
159    );
160    const cursor = assertDefined(htmlElement.querySelector('.cursor'));
161    const sliderBoxRect = sliderBox.getBoundingClientRect();
162    expect(cursor.getBoundingClientRect().left).toBeCloseTo(
163      (sliderBoxRect.left + sliderBoxRect.right) / 2,
164      0,
165    );
166  });
167
168  it('moving slider around updates zoom', fakeAsync(async () => {
169    fixture.detectChanges();
170
171    const initialZoom = assertDefined(component.zoomRange);
172
173    let lastZoomUpdate: TimeRange | undefined = undefined;
174    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
175      (zoom) => {
176        lastZoomUpdate = zoom;
177      },
178    );
179
180    const slider = htmlElement.querySelector('.slider .handle');
181    expect(slider).toBeTruthy();
182    expect(window.getComputedStyle(assertDefined(slider)).visibility).toBe(
183      'visible',
184    );
185
186    dragElement(fixture, assertDefined(slider), 100, 8);
187
188    expect(zoomChangedSpy).toHaveBeenCalled();
189
190    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
191    expect(finalZoom.from).not.toBe(initialZoom.from);
192    expect(finalZoom.to).not.toBe(initialZoom.to);
193    expect(finalZoom.to.minus(finalZoom.from.getValueNs()).getValueNs()).toBe(
194      initialZoom.to.minus(initialZoom.from.getValueNs()).getValueNs(),
195    );
196  }));
197
198  it('moving slider left pointer around updates zoom', fakeAsync(async () => {
199    fixture.detectChanges();
200
201    const initialZoom = assertDefined(component.zoomRange);
202
203    let lastZoomUpdate: TimeRange | undefined = undefined;
204    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
205      (zoom) => {
206        lastZoomUpdate = zoom;
207      },
208    );
209
210    const leftCropper = htmlElement.querySelector('.slider .cropper.left');
211    expect(leftCropper).toBeTruthy();
212    expect(window.getComputedStyle(assertDefined(leftCropper)).visibility).toBe(
213      'visible',
214    );
215
216    dragElement(fixture, assertDefined(leftCropper), 5, 0);
217
218    expect(zoomChangedSpy).toHaveBeenCalled();
219
220    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
221    expect(finalZoom.from).not.toBe(initialZoom.from);
222    expect(finalZoom.to).toBe(initialZoom.to);
223  }));
224
225  it('moving slider right pointer around updates zoom', fakeAsync(async () => {
226    fixture.detectChanges();
227
228    const initialZoom = assertDefined(component.zoomRange);
229
230    let lastZoomUpdate: TimeRange | undefined = undefined;
231    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
232      (zoom) => {
233        lastZoomUpdate = zoom;
234      },
235    );
236
237    const rightCropper = htmlElement.querySelector('.slider .cropper.right');
238    expect(rightCropper).toBeTruthy();
239    expect(
240      window.getComputedStyle(assertDefined(rightCropper)).visibility,
241    ).toBe('visible');
242
243    dragElement(fixture, assertDefined(rightCropper), 5, 0);
244
245    expect(zoomChangedSpy).toHaveBeenCalled();
246
247    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
248    expect(finalZoom.from).toBe(initialZoom.from);
249    expect(finalZoom.to).not.toBe(initialZoom.to);
250  }));
251
252  it('cannot slide left cropper past edges', fakeAsync(() => {
253    component.zoomRange = component.fullRange;
254    fixture.detectChanges();
255
256    const initialZoom = assertDefined(component.zoomRange);
257
258    let lastZoomUpdate: TimeRange | undefined = undefined;
259    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
260      (zoom) => {
261        lastZoomUpdate = zoom;
262      },
263    );
264
265    const leftCropper = htmlElement.querySelector('.slider .cropper.left');
266    expect(leftCropper).toBeTruthy();
267    expect(window.getComputedStyle(assertDefined(leftCropper)).visibility).toBe(
268      'visible',
269    );
270
271    dragElement(fixture, assertDefined(leftCropper), -5, 0);
272
273    expect(zoomChangedSpy).toHaveBeenCalled();
274
275    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
276    expect(finalZoom.from.getValueNs()).toBe(initialZoom.from.getValueNs());
277    expect(finalZoom.to.getValueNs()).toBe(initialZoom.to.getValueNs());
278  }));
279
280  it('cannot slide right cropper past edges', fakeAsync(() => {
281    component.zoomRange = component.fullRange;
282    fixture.detectChanges();
283
284    const initialZoom = assertDefined(component.zoomRange);
285
286    let lastZoomUpdate: TimeRange | undefined = undefined;
287    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
288      (zoom) => {
289        lastZoomUpdate = zoom;
290      },
291    );
292
293    const rightCropper = htmlElement.querySelector('.slider .cropper.right');
294    expect(rightCropper).toBeTruthy();
295    expect(
296      window.getComputedStyle(assertDefined(rightCropper)).visibility,
297    ).toBe('visible');
298
299    dragElement(fixture, assertDefined(rightCropper), 5, 0);
300
301    expect(zoomChangedSpy).toHaveBeenCalled();
302
303    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
304    expect(finalZoom.from.getValueNs()).toBe(initialZoom.from.getValueNs());
305    expect(finalZoom.to.getValueNs()).toBe(initialZoom.to.getValueNs());
306  }));
307
308  it('cannot slide left cropper past right cropper', fakeAsync(() => {
309    component.zoomRange = new TimeRange(time125, time125);
310    fixture.detectChanges();
311
312    const initialZoom = assertDefined(component.zoomRange);
313
314    let lastZoomUpdate: TimeRange | undefined = undefined;
315    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
316      (zoom) => {
317        lastZoomUpdate = zoom;
318      },
319    );
320
321    const leftCropper = htmlElement.querySelector('.slider .cropper.left');
322    expect(leftCropper).toBeTruthy();
323    expect(window.getComputedStyle(assertDefined(leftCropper)).visibility).toBe(
324      'visible',
325    );
326
327    dragElement(fixture, assertDefined(leftCropper), 100, 0);
328
329    expect(zoomChangedSpy).toHaveBeenCalled();
330
331    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
332    expect(finalZoom.from.getValueNs()).toBe(initialZoom.from.getValueNs());
333    expect(finalZoom.to.getValueNs()).toBe(initialZoom.to.getValueNs());
334  }));
335
336  it('cannot slide right cropper past left cropper', fakeAsync(() => {
337    component.zoomRange = new TimeRange(time125, time125);
338    fixture.detectChanges();
339
340    const initialZoom = assertDefined(component.zoomRange);
341
342    let lastZoomUpdate: TimeRange | undefined = undefined;
343    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
344      (zoom) => {
345        lastZoomUpdate = zoom;
346      },
347    );
348
349    const rightCropper = htmlElement.querySelector('.slider .cropper.right');
350    expect(rightCropper).toBeTruthy();
351    expect(
352      window.getComputedStyle(assertDefined(rightCropper)).visibility,
353    ).toBe('visible');
354
355    dragElement(fixture, assertDefined(rightCropper), -100, 0);
356
357    expect(zoomChangedSpy).toHaveBeenCalled();
358
359    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
360    expect(finalZoom.from.getValueNs()).toBe(initialZoom.from.getValueNs());
361    expect(finalZoom.to.getValueNs()).toBe(initialZoom.to.getValueNs());
362  }));
363
364  it('cannot move slider past edges', fakeAsync(() => {
365    component.zoomRange = component.fullRange;
366    fixture.detectChanges();
367
368    const initialZoom = assertDefined(component.zoomRange);
369
370    let lastZoomUpdate: TimeRange | undefined = undefined;
371    const zoomChangedSpy = spyOn(component.onZoomChanged, 'emit').and.callFake(
372      (zoom) => {
373        lastZoomUpdate = zoom;
374      },
375    );
376
377    const slider = htmlElement.querySelector('.slider .handle');
378    expect(slider).toBeTruthy();
379    expect(window.getComputedStyle(assertDefined(slider)).visibility).toBe(
380      'visible',
381    );
382
383    dragElement(fixture, assertDefined(slider), 100, 8);
384
385    expect(zoomChangedSpy).toHaveBeenCalled();
386
387    const finalZoom = assertDefined<TimeRange>(lastZoomUpdate);
388    expect(finalZoom.from.getValueNs()).toBe(initialZoom.from.getValueNs());
389    expect(finalZoom.to.getValueNs()).toBe(initialZoom.to.getValueNs());
390  }));
391});
392