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
6 changes: 6 additions & 0 deletions frontend/public/assets/i18n/en/home.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
"activateAction": "Download to this device",
"deleteAction": "Delete project",
"cancelSyncTooltip": "Cancel sync ({{progress}}% complete)",
"sort": {
"tooltip": "Sort projects",
"updated": "Recently updated",
"created": "Recently created",
"title": "Title (A–Z)"
},
"tooltips": {
"onlineOnly": "Only available in online mode",
"offline": "Cannot sync while offline",
Expand Down
72 changes: 55 additions & 17 deletions frontend/scripts/merge-lcov.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
* by line, function name and branch id. Thresholds mirror
* `coverageThresholds` in angular.json and are checked on the merged totals,
* which is what the unsharded `ng test` run would have enforced.
*
* Branch records need one caveat: V8 numbers branch blocks per process, so a
* shard that merely imported a file reports its branches under different
* block ids than the shard that exercised it. Keying on block id would then
* invent phantom zero-hit branches (SonarCloud sees twice the conditions,
* half uncovered). Branches are therefore merged positionally per source
* line: each record's entries for a line are sorted by (block, branch) and
* summed index-by-index, which is stable across processes.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
Expand All @@ -30,7 +38,7 @@ if (inputs.length === 0) {
process.exit(2);
}

/** @type {Map<string, {fnLines: Map<string, number>, fnHits: Map<string, number>, lines: Map<number, number>, branches: Map<string, {line: number, block: string, branch: string, hits: number}>}>} */
/** @type {Map<string, {fnLines: Map<string, number>, fnHits: Map<string, number>, lines: Map<number, number>, branches: Map<number, {block: string, branch: string, hits: number}[]>}>} */
const files = new Map();

function fileRecord(name) {
Expand All @@ -49,13 +57,42 @@ function fileRecord(name) {

const num = value => (value === '-' ? 0 : Number(value));

const byBlockThenBranch = (a, b) =>
Number(a.block) - Number(b.block) || Number(a.branch) - Number(b.branch);

/**
* Fold one file record's branch entries into the merged record, matching
* entries on the same line by position rather than by V8 block id.
*/
function flushBranches(current, pending) {
for (const [lineNo, entries] of pending) {
entries.sort(byBlockThenBranch);
const merged = current.branches.get(lineNo);
if (!merged) {
current.branches.set(lineNo, entries);
continue;
}
entries.forEach((entry, i) => {
if (i < merged.length) {
merged[i].hits += entry.hits;
} else {
merged.push(entry);
}
});
}
}

for (const input of inputs) {
let current = null;
/** BRDA entries for the record being read, grouped by line. */
let pendingBranches = new Map();
for (const raw of readFileSync(input, 'utf8').split('\n')) {
const line = raw.trim();
if (!line) continue;
if (line === 'end_of_record') {
if (current) flushBranches(current, pendingBranches);
current = null;
pendingBranches = new Map();
continue;
}
const sep = line.indexOf(':');
Expand Down Expand Up @@ -90,18 +127,13 @@ for (const input of inputs) {
}
case 'BRDA': {
const [lineNo, block, branch, hits] = value.split(',');
const id = `${lineNo},${block},${branch}`;
const existing = current.branches.get(id);
if (existing) {
existing.hits += num(hits);
} else {
current.branches.set(id, {
line: Number(lineNo),
block,
branch,
hits: num(hits),
});
const n = Number(lineNo);
let entries = pendingBranches.get(n);
if (!entries) {
entries = [];
pendingBranches.set(n, entries);
}
entries.push({ block, branch, hits: num(hits) });
break;
}
default:
Expand Down Expand Up @@ -140,13 +172,19 @@ for (const [name, rec] of [...files.entries()].sort(([a], [b]) =>
totals.lines[0] += rec.lines.size;
totals.lines[1] += lineHit;

let brFound = 0;
let brHit = 0;
for (const br of rec.branches.values()) {
if (br.hits > 0) brHit++;
out.push(`BRDA:${br.line},${br.block},${br.branch},${br.hits}`);
for (const [lineNo, entries] of [...rec.branches.entries()].sort(
(a, b) => a[0] - b[0]
)) {
for (const br of entries) {
brFound++;
if (br.hits > 0) brHit++;
out.push(`BRDA:${lineNo},${br.block},${br.branch},${br.hits}`);
}
}
out.push(`BRF:${rec.branches.size}`, `BRH:${brHit}`);
totals.branches[0] += rec.branches.size;
out.push(`BRF:${brFound}`, `BRH:${brHit}`);
totals.branches[0] += brFound;
totals.branches[1] += brHit;

out.push('end_of_record');
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/app/pages/home/home.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,34 @@
}

@if (isAuthenticated()) {
<button
mat-icon-button
[matMenuTriggerFor]="sortMenu"
[matTooltip]="'home.sort.tooltip' | transloco"
[attr.aria-label]="'home.sort.tooltip' | transloco"
data-testid="sort-projects-button"
class="sort-btn">
<mat-icon>sort</mat-icon>
</button>
<mat-menu #sortMenu="matMenu">
@for (order of sortOrders; track order) {
<button
mat-menu-item
(click)="setSortOrder(order)"
[class.active]="sortOrder() === order"
[attr.aria-checked]="sortOrder() === order"
role="menuitemradio"
[attr.data-testid]="'sort-projects-' + order">
<mat-icon>{{
sortOrder() === order
? 'radio_button_checked'
: 'radio_button_unchecked'
}}</mat-icon>
<span>{{ 'home.sort.' + order | transloco }}</span>
</button>
}
</mat-menu>

<button
mat-button
[matMenuTriggerFor]="createMenu"
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/pages/home/home.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@
}
}

.sort-btn {
color: var(--link-color) !important;

&:hover {
color: var(--link-hover-color) !important;
}
}

.sync-btn {
font-weight: 500;
color: var(--link-color) !important;
Expand Down
136 changes: 135 additions & 1 deletion frontend/src/app/pages/home/home.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
} from 'vitest';

import { translocoTestProvider } from '../../../testing/transloco-test-provider';
import { HomeComponent } from './home.component';
import { HomeComponent, PROJECT_SORT_STORAGE_KEY } from './home.component';

describe('HomeComponent', () => {
let component: HomeComponent;
Expand Down Expand Up @@ -332,6 +332,7 @@ describe('HomeComponent', () => {
fixture.destroy();
vi.restoreAllMocks();
vi.useRealTimers();
localStorage.removeItem(PROJECT_SORT_STORAGE_KEY);
});

it('should create', () => {
Expand Down Expand Up @@ -558,6 +559,139 @@ describe('HomeComponent', () => {
});
});

describe('project sort order', () => {
const sortableProjects: Project[] = [
{
id: 'a',
title: 'Zeta',
slug: 'zeta',
username: 'testuser',
createdDate: '2024-03-01T00:00:00.000Z',
updatedDate: '2024-01-10T00:00:00.000Z',
},
{
id: 'b',
title: 'alpha',
slug: 'alpha',
username: 'testuser',
createdDate: '2024-01-01T00:00:00.000Z',
updatedDate: '2024-02-10T00:00:00.000Z',
},
{
id: 'c',
title: 'Mid',
slug: 'mid',
username: 'testuser',
createdDate: '2024-02-01T00:00:00.000Z',
updatedDate: '2024-03-10T00:00:00.000Z',
},
];

const ids = () => component['allProjects']().map(i => i.project.id);

it('should default to most recently updated first', () => {
mockProjectsSignal.set(sortableProjects);
expect(component.sortOrder()).toBe('updated');
expect(ids()).toEqual(['c', 'b', 'a']);
});

it('should sort by created date descending', () => {
mockProjectsSignal.set(sortableProjects);
component.setSortOrder('created');
expect(ids()).toEqual(['a', 'c', 'b']);
});

it('should sort by title case-insensitively', () => {
mockProjectsSignal.set(sortableProjects);
component.setSortOrder('title');
expect(ids()).toEqual(['b', 'c', 'a']);
});

it('should interleave shared projects according to the sort order', () => {
mockProjectsSignal.set(sortableProjects);
component.collaboratedProjects.set([
{
projectId: 'shared',
projectSlug: 'shared',
projectTitle: 'Shared Story',
ownerUsername: 'owner',
role: 'editor',
acceptedAt: '2024-02-20T00:00:00.000Z',
} as unknown as CollaboratedProject,
]);
expect(ids()).toEqual(['c', 'shared', 'b', 'a']);
});

it('should persist the chosen order to localStorage', () => {
component.setSortOrder('title');
expect(localStorage.getItem(PROJECT_SORT_STORAGE_KEY)).toBe('title');
});

it('should restore a stored order on creation', () => {
localStorage.setItem(PROJECT_SORT_STORAGE_KEY, 'created');
const freshFixture = TestBed.createComponent(HomeComponent);
expect(freshFixture.componentInstance.sortOrder()).toBe('created');
freshFixture.destroy();
});

it('should ignore an unknown stored order', () => {
localStorage.setItem(PROJECT_SORT_STORAGE_KEY, 'bogus');
const freshFixture = TestBed.createComponent(HomeComponent);
expect(freshFixture.componentInstance.sortOrder()).toBe('updated');
freshFixture.destroy();
});

it('should fall back to the default when reading storage throws', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage unavailable');
});
const freshFixture = TestBed.createComponent(HomeComponent);
expect(freshFixture.componentInstance.sortOrder()).toBe('updated');
freshFixture.destroy();
});

it('should still apply the order in memory when writing storage throws', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota exceeded');
});
expect(() => component.setSortOrder('title')).not.toThrow();
expect(component.sortOrder()).toBe('title');
});

it('should treat missing or invalid dates as oldest', () => {
mockProjectsSignal.set([
{
id: 'valid',
title: 'Valid',
slug: 'valid',
username: 'testuser',
createdDate: '2024-01-01T00:00:00.000Z',
updatedDate: '2024-01-01T00:00:00.000Z',
},
{
id: 'invalid',
title: 'Invalid',
slug: 'invalid',
username: 'testuser',
createdDate: 'not-a-date',
updatedDate: 'not-a-date',
},
{
id: 'missing',
title: 'Missing',
slug: 'missing',
username: 'testuser',
createdDate: undefined as unknown as string,
updatedDate: undefined as unknown as string,
},
]);
// Valid date first; the two zero-timestamp entries tie and fall back to title
expect(ids()).toEqual(['valid', 'invalid', 'missing']);
component.setSortOrder('created');
expect(ids()).toEqual(['valid', 'invalid', 'missing']);
});
});

describe('login and register dialogs', () => {
it('should open login dialog', () => {
// openLoginDialog is now available instead of navigateToLogin
Expand Down
Loading