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
62 changes: 62 additions & 0 deletions apps/web-e2e/src/device-id-compat.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';

test.describe('Device ID / URL 互換', () => {
test('canonical id shows the device detail page', async ({ page }) => {
await page.goto('/devices/ADS1015');

await expect(page).toHaveURL(/\/devices\/ADS1015$/);
await expect(page.getByRole('heading', { name: 'ADS1015', exact: true })).toBeVisible();
});

test('legacy dashboard id redirects to the canonical url', async ({
page,
}) => {
await page.goto('/devices/i2c-ads1015');

await expect(page).toHaveURL(/\/devices\/ADS1015$/);
await expect(page.getByRole('heading', { name: 'ADS1015', exact: true })).toBeVisible();
});

test('case-insensitive id redirects to the canonical url', async ({
page,
}) => {
await page.goto('/devices/ads1015');

await expect(page).toHaveURL(/\/devices\/ADS1015$/);
await expect(page.getByRole('heading', { name: 'ADS1015', exact: true })).toBeVisible();
});

test('unknown id shows not found', async ({ page }) => {
await page.goto('/devices/not-a-real-device');

await expect(page).toHaveURL(/\/devices\/not-a-real-device$/);
await expect(
page.getByRole('heading', { name: 'デバイスが見つかりません' }),
).toBeVisible();
await expect(
page.getByText('「not-a-real-device」は登録されていません。'),
).toBeVisible();
await expect(page.getByRole('link', { name: '一覧に戻る' })).toBeVisible();
});

test('list navigation uses the canonical id and survives reload', async ({
page,
}) => {
await page.goto('/');
await page.getByLabel('検索').fill('ADS1015');

const row = page.locator('choh-device-list-item').filter({
hasText: 'ADS1015',
});
await expect(row).toBeVisible();
await row.click();

await expect(page).toHaveURL(/\/devices\/ADS1015$/);
await expect(page.getByRole('heading', { name: 'ADS1015', exact: true })).toBeVisible();

await page.reload();

await expect(page).toHaveURL(/\/devices\/ADS1015$/);
await expect(page.getByRole('heading', { name: 'ADS1015', exact: true })).toBeVisible();
});
});
2 changes: 2 additions & 0 deletions apps/web/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Route } from '@angular/router';
import { redirectLegacyDeviceIdGuard } from './guards/redirect-legacy-device-id.guard';

export const appRoutes: Route[] = [
{
Expand All @@ -10,6 +11,7 @@ export const appRoutes: Route[] = [
},
{
path: 'devices/:id',
canActivate: [redirectLegacyDeviceIdGuard],
loadComponent: () =>
import('./pages/device-detail-page/device-detail-page.component').then(
(m) => m.DeviceDetailPageComponent,
Expand Down
107 changes: 107 additions & 0 deletions apps/web/src/app/guards/redirect-legacy-device-id.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { TestBed } from '@angular/core/testing';
import {
convertToParamMap,
provideRouter,
RedirectCommand,
type ActivatedRouteSnapshot,
type RouterStateSnapshot,
} from '@angular/router';
import {
DEVICE_REPOSITORY,
type DeviceRepository,
} from '@chirimen-device-dashboard/libs-data-access';
import type { DeviceInfo } from '@chirimen-device-dashboard/shared-types';
import { firstValueFrom, of, throwError, type Observable } from 'rxjs';
import { beforeEach, describe, expect, it } from 'vitest';
import { redirectLegacyDeviceIdGuard } from './redirect-legacy-device-id.guard';

const ads1015: DeviceInfo = {
id: 'ADS1015',
deviceName: 'ADS1015',
tag: 'I2C',
category: 'ADC',
description: '',
image: '',
product: { url: '', example: [] },
};

function createRepository(
getImpl: DeviceRepository['get'],
): DeviceRepository {
return {
list: () => of([]),
get: getImpl,
};
}

function runGuard(id: string) {
const route = {
paramMap: convertToParamMap({ id }),
} as ActivatedRouteSnapshot;
const state = {} as RouterStateSnapshot;

return TestBed.runInInjectionContext(() =>
redirectLegacyDeviceIdGuard(route, state),
) as Observable<boolean | RedirectCommand>;
}

describe('redirectLegacyDeviceIdGuard', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: DEVICE_REPOSITORY,
useValue: createRepository((id) =>
of(id === 'missing' ? null : ads1015),
),
},
],
});
});

it('allows navigation when the requested id is already canonical', async () => {
const result = await firstValueFrom(runGuard('ADS1015'));
expect(result).toBe(true);
});

it('redirects a legacy id to the canonical url', async () => {
const result = await firstValueFrom(runGuard('i2c-ads1015'));

expect(result).toBeInstanceOf(RedirectCommand);
const redirect = result as RedirectCommand;
expect(redirect.redirectTo.toString()).toBe('/devices/ADS1015');
});

it('redirects a case-insensitive id to the canonical url', async () => {
const result = await firstValueFrom(runGuard('ads1015'));

expect(result).toBeInstanceOf(RedirectCommand);
expect((result as RedirectCommand).redirectTo.toString()).toBe(
'/devices/ADS1015',
);
});

it('allows unknown ids so the detail page can show not found', async () => {
const result = await firstValueFrom(runGuard('missing'));
expect(result).toBe(true);
});

it('allows navigation when device loading fails', async () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: DEVICE_REPOSITORY,
useValue: createRepository(() =>
throwError(() => new Error('Failed to load devices: timeout')),
),
},
],
});

const result = await firstValueFrom(runGuard('ADS1015'));
expect(result).toBe(true);
});
});
26 changes: 26 additions & 0 deletions apps/web/src/app/guards/redirect-legacy-device-id.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { inject } from '@angular/core';
import {
RedirectCommand,
Router,
type CanActivateFn,
} from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { DEVICE_REPOSITORY } from '@chirimen-device-dashboard/libs-data-access';

export const redirectLegacyDeviceIdGuard: CanActivateFn = (route) => {
const repository = inject(DEVICE_REPOSITORY);
const router = inject(Router);
const id = route.paramMap.get('id') ?? '';

return repository.get(id).pipe(
map((device) => {
if (device && device.id !== id) {
return new RedirectCommand(router.createUrlTree(['/devices', device.id]), {
replaceUrl: true,
});
}
return true;
}),
catchError(() => of(true)),
);
};
8 changes: 8 additions & 0 deletions libs/devices/data-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@ export {
adaptCertifiedDevice,
adaptCertifiedDevicesJson,
} from './lib/certified-device.adapter';
export {
buildDeviceIdIndex,
parseCertifiedAliases,
resolveDeviceId,
type DeviceIdIndex,
type DeviceIdMatchKind,
type DeviceIdResolution,
} from './lib/resolve-device-id';

58 changes: 58 additions & 0 deletions libs/devices/data-access/src/lib/json-device.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,64 @@ describe('JsonDeviceRepository', () => {
);
});

it('get() resolves tag-prefixed legacy ids', async () => {
const device = await firstValueFrom(repo.get('i2c-device-1'));

expect(device?.id).toBe('device-1');
expect(device?.deviceName).toBe('Test Device 1');
});

it('get() resolves canonical ids case-insensitively', async () => {
const device = await firstValueFrom(repo.get('DEVICE-1'));

expect(device?.id).toBe('device-1');
});

it('get() resolves certified example aliases to the canonical device', async () => {
const ads1015: CertifiedDevice = {
id: 'ADS1015',
directory: 'devices/ADS1015',
meta: {
id: 'ADS1015',
model: 'ADS1015',
tag: 'I2C',
category: 'ADC',
description: 'ADC',
image: '',
productUrl: '',
examples: [],
circuit: null,
datasheet: null,
reference: null,
},
};
fetchMock.mockResolvedValueOnce(
okResponse({
...sampleJson,
aliases: {
...sampleJson.aliases,
exampleNameAliases: {
ADS1015: {
directoryId: 'ADS1015',
exampleDeviceId: 'ads1015',
legacyExampleNames: ['I2C-ADS1015', 'ads1015', 'ADS1015'],
},
},
},
devices: [ads1015],
}),
);

const aliasRepo = new JsonDeviceRepository();
const byLegacy = await firstValueFrom(aliasRepo.get('i2c-ads1015'));
const byAlias = await firstValueFrom(aliasRepo.get('I2C-ADS1015'));
const byCase = await firstValueFrom(aliasRepo.get('ads1015'));

expect(byLegacy?.id).toBe('ADS1015');
expect(byAlias?.id).toBe('ADS1015');
expect(byCase?.id).toBe('ADS1015');
});

it('errors on unsupported certified devices version', async () => {
fetchMock.mockResolvedValueOnce(
okResponse({
Expand Down
32 changes: 24 additions & 8 deletions libs/devices/data-access/src/lib/json-device.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import {
} from './certified-devices.config';
import { isSupportedCertifiedDevicesVersion } from './certified-devices.types';
import type { DeviceRepository } from './device.repository';
import {
buildDeviceIdIndex,
parseCertifiedAliases,
resolveDeviceId,
type DeviceIdIndex,
} from './resolve-device-id';

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
Expand All @@ -25,10 +31,15 @@ function isTimeoutError(error: unknown): boolean {
);
}

async function loadDevices(
interface DeviceCatalog {
devices: DeviceInfo[];
index: DeviceIdIndex;
}

async function loadCatalog(
url: string,
timeoutMs: number,
): Promise<DeviceInfo[]> {
): Promise<DeviceCatalog> {
let response: Response;
try {
response = await fetch(url, {
Expand Down Expand Up @@ -61,28 +72,33 @@ async function loadDevices(
throw loadError('unsupported version');
}

return adaptCertifiedDevicesJson(payload);
const devices = adaptCertifiedDevicesJson(payload);
const aliases = parseCertifiedAliases(payload['aliases']);
return {
devices,
index: buildDeviceIdIndex(devices, aliases),
};
}

export class JsonDeviceRepository implements DeviceRepository {
private readonly devices$: Observable<DeviceInfo[]>;
private readonly catalog$: Observable<DeviceCatalog>;

constructor(
url = DEFAULT_CERTIFIED_DEVICES_JSON_URL,
timeoutMs = CERTIFIED_DEVICES_FETCH_TIMEOUT_MS,
) {
this.devices$ = from(loadDevices(url, timeoutMs)).pipe(
this.catalog$ = from(loadCatalog(url, timeoutMs)).pipe(
shareReplay({ bufferSize: 1, refCount: true }),
);
}

list(): Observable<DeviceInfo[]> {
return this.devices$;
return this.catalog$.pipe(map((catalog) => catalog.devices));
}

get(id: string): Observable<DeviceInfo | null> {
return this.devices$.pipe(
map((devices) => devices.find((d) => d.id === id) ?? null),
return this.catalog$.pipe(
map((catalog) => resolveDeviceId(catalog.index, id)?.device ?? null),
);
}
}
Loading
Loading