Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions web/src/app/store/domain/timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,4 +776,87 @@ describe('Timeline', () => {
});
});
});

describe('hasLogInEvents, hasLogInRevisions, and hasLog', () => {
it('should correctly check if the timeline contains the log using binary search', () => {
internPool.addStrings([
{ id: 1, value: 'timeline-label' },
{ id: 2, value: 'user-name' },
{ id: 3, value: 'log-summary' },
]);

const rawTimelines: TimelineDTO[] = [
{
id: 10,
timelineTypeId: 1,
nameStringId: 1,
parentTimelineId: 0,
revisionIds: [100],
eventIds: [200],
},
];

const rawRevisions: RevisionDTO[] = [
{
id: 100,
logId: 1,
changedTime: 100n,
principalStringId: 2,
verbTypeId: 1,
stateTypeId: 1,
},
];

const rawEvents: EventDTO[] = [
{
id: 200,
logId: 2,
},
];

const rawLogs: LogDTO[] = [
{
id: 1,
ts: 100n,
logTypeId: 1,
severityTypeId: 1,
summaryStringId: 3,
},
{
id: 2,
ts: 200n,
logTypeId: 1,
severityTypeId: 1,
summaryStringId: 3,
},
{
id: 3,
ts: 300n,
logTypeId: 1,
severityTypeId: 1,
summaryStringId: 3,
},
];

logStore.initialize(rawLogs, 3);
timelineStore.initialize(rawTimelines, 1, rawRevisions, 1, rawEvents, 1);

const timeline = timelineStore.getTimeline(10);
const log1 = logStore.getLog(1);
const log2 = logStore.getLog(2);
const log3 = logStore.getLog(3);

expect(timeline.hasLogInRevisions(log1)).toBe(true);
expect(timeline.hasLogInEvents(log1)).toBe(false);
expect(timeline.hasLog(log1)).toBe(true);

expect(timeline.hasLogInRevisions(log2)).toBe(false);
expect(timeline.hasLogInEvents(log2)).toBe(true);
expect(timeline.hasLog(log2)).toBe(true);

expect(timeline.hasLogInRevisions(log3)).toBe(false);
expect(timeline.hasLogInEvents(log3)).toBe(false);
expect(timeline.hasLog(log3)).toBe(false);
});
});
});
33 changes: 33 additions & 0 deletions web/src/app/store/domain/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,4 +442,37 @@ export class Timeline {
}
return idx > 0 ? arr[idx - 1] : null;
}

/**
* Checks if this timeline contains the specified log within its events using binary search.
*/
public hasLogInEvents(log: ReadonlyDomainElement<Log>): boolean {
const events = this.events;
const eIdx = bisectLeft(
events,
log.logIndex,
(item, target) => item.logIndex - target,
);
return eIdx < events.length && events[eIdx].logIndex === log.logIndex;
}

/**
* Checks if this timeline contains the specified log within its revisions using binary search.
*/
public hasLogInRevisions(log: ReadonlyDomainElement<Log>): boolean {
const revisions = this.revisions;
const rIdx = bisectLeft(
revisions,
log.logIndex,
(item, target) => item.logIndex - target,
);
return rIdx < revisions.length && revisions[rIdx].logIndex === log.logIndex;
}

/**
* Checks if this timeline contains the specified log using binary search.
*/
public hasLog(log: ReadonlyDomainElement<Log>): boolean {
return this.hasLogInEvents(log) || this.hasLogInRevisions(log);
}
}
235 changes: 235 additions & 0 deletions web/src/app/timeline/components/timeline-frame.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TimelineFrameComponent } from 'src/app/timeline/components/timeline-frame.component';
import { Timeline, Event } from 'src/app/store/domain/timeline';
import { TimelineStore } from 'src/app/store/domain/timeline-store';
import { Log } from 'src/app/store/domain/log';
import { LogStore } from 'src/app/store/domain/log-store';
import {
TimelineHighlightType,
TimelineChartItemHighlightType,
} from 'src/app/timeline/components/interaction-model';
import { TimelineType } from 'src/app/store/domain/style';
import { StyleStoreLike } from 'src/app/store/domain/style-store';
import {
generateDefaultChartStyle,
generateDefaultRulerStyle,
} from 'src/app/timeline/components/style-model-v2';

import { ReadonlyDomainElement } from 'src/app/store/domain/types';

const mockTimelineType: TimelineType = {
id: 0,
label: 'mock-type',
description: 'mock type description',
icon: 'timeline',
backgroundColor: { r: 0, g: 0, b: 0, a: 1 },
foregroundColor: { r: 1, g: 1, b: 1, a: 1 },
typeChipBackgroundColor: { r: 0, g: 0, b: 0, a: 1 },
typeChipForegroundColor: { r: 1, g: 1, b: 1, a: 1 },
visible: true,
sortPriority: 0,
height: 24,
};

class MockTimeline extends Timeline {
private mockEvents: Event[] = [];

constructor(id: number) {
super(id, null as unknown as TimelineStore);
}

public setEvents(events: Event[]): void {
this.mockEvents = events;
}

override get events(): readonly Event[] {
return this.mockEvents;
}

override get revisions(): readonly never[] {
return [];
}

override get type(): ReadonlyDomainElement<TimelineType> {
return mockTimelineType;
}
}

class MockEvent extends Event {
private readonly mockLogIndex: number;

constructor(id: number, timelineId: number, logIndex: number) {
super(id, timelineId, null as unknown as TimelineStore);
this.mockLogIndex = logIndex;
}

override get logIndex(): number {
return this.mockLogIndex;
}
}

class MockLog extends Log {
private readonly _logIndex: number;

constructor(logIndex: number) {
super(0, null as unknown as LogStore);
this._logIndex = logIndex;
}

override get logIndex(): number {
return this._logIndex;
}
}

@Component({
selector: 'khi-testing-timeline-frame',
standalone: true,
imports: [TimelineFrameComponent],
template: '',
})
class TestingTimelineFrameComponent extends TimelineFrameComponent {
// eslint-disable-next-line @angular-eslint/no-empty-lifecycle-method
override ngAfterViewInit(): void {}

public getSelectedLogTimelineExposed(): ReadonlyDomainElement<Timeline> | null {
return this.selectedLogTimeline();
}
}

const mockStyleStore: StyleStoreLike = {
severities: [],
logTypes: [],
verbs: [],
revisionStates: [],
timelineTypes: [],
getSeverity: () => {
throw new Error();
},
getLogType: () => {
throw new Error();
},
getVerb: () => {
throw new Error();
},
getRevisionState: () => {
throw new Error();
},
getTimelineType: () => {
throw new Error();
},
getIconAtlas: () => undefined,
};

describe('TimelineFrameComponent', () => {
let component: TestingTimelineFrameComponent;
let fixture: ComponentFixture<TestingTimelineFrameComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestingTimelineFrameComponent],
}).compileComponents();

fixture = TestBed.createComponent(TestingTimelineFrameComponent);
component = fixture.componentInstance;

fixture.componentRef.setInput('chartStyle', generateDefaultChartStyle());
fixture.componentRef.setInput(
'rulerStyle',
generateDefaultRulerStyle(mockStyleStore),
);
fixture.componentRef.setInput('styleStore', mockStyleStore);

const mockLogs: Log[] = [];
for (let i = 0; i <= 20; i++) {
mockLogs.push(new MockLog(i));
}
fixture.componentRef.setInput('allLogsWithoutFilter', mockLogs);

fixture.detectChanges();
});

it('should resolve the first timeline containing the log if no timeline is selected', () => {
const timelineA = new MockTimeline(1);
const timelineB = new MockTimeline(2);

const eventA = new MockEvent(101, 1, 10);
const eventB = new MockEvent(102, 2, 10);

timelineA.setEvents([eventA]);
timelineB.setEvents([eventB]);

fixture.componentRef.setInput('timelines', [timelineA, timelineB]);
fixture.componentRef.setInput('timelineChartItemHighlights', {
10: TimelineChartItemHighlightType.Selected,
});
fixture.detectChanges();

expect(component.getSelectedLogTimelineExposed()).toBe(timelineA);
});

it('should prioritize the currently selected timeline if it contains the log', () => {
const timelineA = new MockTimeline(1);
const timelineB = new MockTimeline(2);

const eventA = new MockEvent(101, 1, 10);
const eventB = new MockEvent(102, 2, 10);

timelineA.setEvents([eventA]);
timelineB.setEvents([eventB]);

fixture.componentRef.setInput('timelines', [timelineA, timelineB]);
fixture.componentRef.setInput('timelineHighlights', {
2: TimelineHighlightType.Selected, // Timeline B is selected
});
fixture.componentRef.setInput('timelineChartItemHighlights', {
10: TimelineChartItemHighlightType.Selected,
});
fixture.detectChanges();

expect(component.getSelectedLogTimelineExposed()).toBe(timelineB);
});

it('should fall back to the first timeline containing the log if the selected timeline does not contain it', () => {
const timelineA = new MockTimeline(1);
const timelineB = new MockTimeline(2);
const timelineC = new MockTimeline(3); // Unrelated timeline

const eventA = new MockEvent(101, 1, 10);
const eventB = new MockEvent(102, 2, 10);

timelineA.setEvents([eventA]);
timelineB.setEvents([eventB]);

fixture.componentRef.setInput('timelines', [
timelineA,
timelineB,
timelineC,
]);
fixture.componentRef.setInput('timelineHighlights', {
3: TimelineHighlightType.Selected, // Timeline C is selected, does not contain log index 10
});
fixture.componentRef.setInput('timelineChartItemHighlights', {
10: TimelineChartItemHighlightType.Selected,
});
fixture.detectChanges();

expect(component.getSelectedLogTimelineExposed()).toBe(timelineA);
});
});
21 changes: 14 additions & 7 deletions web/src/app/timeline/components/timeline-frame.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,14 +352,21 @@ export class TimelineFrameComponent implements AfterViewInit {
return null;
}
const logIndex = Number(logIndexStr);
const allLogs = this.allLogsWithoutFilter();
const log = allLogs[logIndex];
if (!log) {
return null;
}
const timelines = this.timelines();
return (
timelines.find(
(timeline) =>
timeline.events.some((e) => e.logIndex === logIndex) ||
timeline.revisions.some((r) => r.logIndex === logIndex),
) ?? null
);

// If the currently selected timeline already contains the selected log,
// we should prioritize it to prevent jumping to another timeline that shares the log.
const currentSelected = this.selectedTimeline();
if (currentSelected?.hasLog(log)) {
return currentSelected;
}
Comment thread
kyasbal marked this conversation as resolved.

return timelines.find((timeline) => timeline.hasLog(log)) ?? null;
});

/**
Expand Down
Loading