diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3e90940f6..08dcdf2dd 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,29 +1,37 @@ -on: push -name: Build Application +on: + pull_request: + push: + branches: [main] +name: Build and Test jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [22.x] + node-version: [22.x, 24.x] steps: - uses: actions/checkout@v4 - - name: Cache node modules - uses: actions/cache@v4 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: Node ${{ matrix.node-version }} + - name: Setup Node ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + - name: npm install run: | - npm i + npm install + + - name: Setup Chrome + uses: browser-actions/setup-chrome@v1 + + - name: Run tests + env: + CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }} + run: | + npx ng test --watch=false --browsers=ChromeHeadless + - name: npm run build run: | npm run build:all diff --git a/src/app/modules/map/ol/lib/map.component.ts b/src/app/modules/map/ol/lib/map.component.ts index 8f3566ace..d24a9388a 100644 --- a/src/app/modules/map/ol/lib/map.component.ts +++ b/src/app/modules/map/ol/lib/map.component.ts @@ -140,6 +140,9 @@ export class MapComponent implements OnInit, OnDestroy { } ngOnDestroy() { + if (!this.map) { + return; + } this.map.un('singleclick', this.emitSingleClickEvent); this.map.un('dblclick', this.emitDblClickEvent); this.map.un('click', this.emitClickEvent); diff --git a/src/app/modules/map/ol/lib/view.directive.ts b/src/app/modules/map/ol/lib/view.directive.ts index 2e466ff76..22c055f02 100644 --- a/src/app/modules/map/ol/lib/view.directive.ts +++ b/src/app/modules/map/ol/lib/view.directive.ts @@ -140,6 +140,9 @@ export class ViewDirective } ngOnDestroy() { + if (!this.view) { + return; + } this.view.un('change:center', this.emitCenterChange); this.view.un('change:resolution', this.emitZoomChange); this.view.un('change:rotation', this.emitRotationChange); diff --git a/src/app/modules/skresources/resources.service.spec.ts b/src/app/modules/skresources/resources.service.spec.ts new file mode 100644 index 000000000..4faeb52b0 --- /dev/null +++ b/src/app/modules/skresources/resources.service.spec.ts @@ -0,0 +1,179 @@ +import { signal } from '@angular/core'; +import { SKResourceService } from './resources.service'; +import { ChartResource } from 'src/app/types'; + +const createService = ( + listResponse: Record, + singleResponse?: ChartResource +) => { + const signalk = { + api: { + get: (_version: number, path: string) => ({ + subscribe: (next: (res: any) => void) => { + if (path.includes('/resources/charts/')) { + if (singleResponse) { + next(singleResponse) + return + } + const id = path.split('/').pop() || '' + next(listResponse[id]) + return + } + next(listResponse) + } + }) + } + }; + + const worker = { + resource$: () => ({ + subscribe: () => undefined + }) + }; + + const dialog = {}; + + const app = { + hostDef: { + url: 'http://localhost:3000', + name: 'localhost' + }, + config: { + selections: {} + }, + debug: () => undefined, + parseHttpErrorResponse: () => undefined, + sIsFetching: signal(false), + uiConfig: () => ({ + mapConstrainZoom: false + }) + }; + + return new SKResourceService( + dialog as any, + signalk as any, + worker as any, + app as any + ); +}; + +describe('SKResourceService TileJSON enrichment', () => { + const originalFetch = (globalThis as any).fetch; + const originalAbortController = (globalThis as any).AbortController; + + beforeEach(() => { + if (!(globalThis as any).AbortController) { + (globalThis as any).AbortController = class { + signal = {}; + abort() { + return undefined; + } + }; + } + }); + + afterEach(() => { + (globalThis as any).fetch = originalFetch; + (globalThis as any).AbortController = originalAbortController; + }); + + it('enriches tilejson charts with missing metadata', async () => { + const tilejson = { + bounds: [8, 53, 8.2, 53.3], + minzoom: 6, + maxzoom: 12, + format: 'pbf' + }; + + (globalThis as any).fetch = jasmine + .createSpy('fetch') + .and.callFake(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(tilejson) + }) + ); + + const service = createService({ + nautical_pm: { + name: 'Nautical', + type: 'tileJSON', + url: 'http://tiles.example/data/nautical_pm.json' + } + }); + + const list = await service.listFromServer('charts'); + const chart = list[0][1]; + + expect(chart.minZoom).toBe(6); + expect(chart.maxZoom).toBe(12); + expect(chart.bounds).toEqual([8, 53, 8.2, 53.3]); + expect(chart.format).toBe('pbf'); + }); + + it('does not fetch tilejson when metadata already present', async () => { + const fetchSpy = jasmine.createSpy('fetch'); + (globalThis as any).fetch = fetchSpy; + + const service = createService({ + sample_pm: { + name: 'Sample Raster', + type: 'tileJSON', + url: 'http://tiles.example/data/sample_pm.json', + bounds: [1, 2, 3, 4], + minzoom: 0, + maxzoom: 9, + format: 'webp' + } + }); + + const list = await service.listFromServer('charts'); + const chart = list[0][1]; + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(chart.minZoom).toBe(0); + expect(chart.maxZoom).toBe(9); + expect(chart.bounds).toEqual([1, 2, 3, 4]); + expect(chart.format).toBe('webp'); + }); + + it('enriches single chart fetch for info dialog', async () => { + const tilejson = { + bounds: [10, 20, 30, 40], + minzoom: 3, + maxzoom: 14, + format: 'pbf' + }; + + (globalThis as any).fetch = jasmine + .createSpy('fetch') + .and.callFake(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(tilejson) + }) + ); + + const service = createService( + { + sample: { + name: 'Sample', + type: 'tileJSON', + url: 'http://tiles.example/data/sample.json' + } + }, + { + name: 'Sample', + type: 'tileJSON', + url: 'http://tiles.example/data/sample.json' + } + ); + + const chart = await service.fromServer('charts', 'sample'); + + expect(chart.minZoom).toBe(3); + expect(chart.maxZoom).toBe(14); + expect(chart.bounds).toEqual([10, 20, 30, 40]); + expect(chart.format).toBe('pbf'); + }); +}); diff --git a/src/app/modules/skresources/resources.service.ts b/src/app/modules/skresources/resources.service.ts index b53fdaf02..8ae8fd371 100644 --- a/src/app/modules/skresources/resources.service.ts +++ b/src/app/modules/skresources/resources.service.ts @@ -114,6 +114,122 @@ export class SKResourceService { } return this.app.config.selections[collection].includes(id); } + + private buildChartUrl(chart: ChartResource): string | undefined { + if (!chart?.url) { + return undefined; + } + + if (chart.url.startsWith('/') || !chart.url.startsWith('http')) { + return this.app.hostDef.url + chart.url; + } + + return chart.url; + } + + private async fetchTileJson(url: string, timeoutMs = 3000): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: controller.signal + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); + } finally { + clearTimeout(timeoutId); + } + } + + private inferFormatFromUrl(url?: string): string | undefined { + if (!url) { + return undefined; + } + const cleanUrl = url.split('?')[0] ?? url; + const lowerUrl = cleanUrl.toLowerCase(); + if (lowerUrl.endsWith('.png')) { + return 'png'; + } + if (lowerUrl.endsWith('.jpg') || lowerUrl.endsWith('.jpeg')) { + return 'jpg'; + } + if (lowerUrl.endsWith('.webp')) { + return 'webp'; + } + return undefined; + } + + private inferFormatFromTiles(tilejson: any): string | undefined { + if (Array.isArray(tilejson?.tiles) && tilejson.tiles.length > 0) { + return this.inferFormatFromUrl(tilejson.tiles[0]); + } + return undefined; + } + + private async enrichChartFromTileJson( + chart: ChartResource + ): Promise { + if (!chart?.type || chart.type.toLowerCase() !== 'tilejson') { + return chart; + } + + const fetchUrl = this.buildChartUrl(chart); + if (!fetchUrl) { + return chart; + } + + const needsMeta = + typeof chart.minzoom === 'undefined' || + typeof chart.maxzoom === 'undefined' || + !Array.isArray(chart.bounds) || + typeof chart.format === 'undefined'; + + if (!needsMeta) { + return chart; + } + + try { + const tilejson = await this.fetchTileJson(fetchUrl); + const vectorLayers = Array.isArray(tilejson?.vector_layers) + ? tilejson.vector_layers + .map((layer: { id?: string }) => layer?.id) + .filter((id: string) => typeof id === 'string' && id.length > 0) + : undefined; + const resolvedLayers = + Array.isArray(chart.layers) && chart.layers.length + ? chart.layers + : vectorLayers; + const resolvedFormat = + typeof chart.format !== 'undefined' + ? chart.format + : typeof tilejson.format !== 'undefined' + ? tilejson.format + : this.inferFormatFromTiles(tilejson) ?? + (vectorLayers?.length ? 'pbf' : chart.format); + return { + ...chart, + bounds: Array.isArray(chart.bounds) ? chart.bounds : tilejson.bounds, + minzoom: + typeof chart.minzoom !== 'undefined' + ? chart.minzoom + : tilejson.minzoom, + maxzoom: + typeof chart.maxzoom !== 'undefined' + ? chart.maxzoom + : tilejson.maxzoom, + format: resolvedFormat, + layers: resolvedLayers ?? chart.layers + }; + } catch (_err) { + return chart; + } + } /** * @description Add resource ids to selection list * @param collection @@ -284,15 +400,44 @@ export class SKResourceService { ); skf?.subscribe( (res: Routes | Waypoints | Regions | Notes | Charts | Tracks) => { - const list: any = []; - Object.keys(res).forEach((id: string) => { - list.push([ - id, - this.transform(collection, res[id], id), - !this.selectionIsFiltered(collection) - ? true - : this.selectionHas(collection, id) - ]); + const ids = Object.keys(res); + + if (collection === 'charts') { + (async () => { + try { + const list = (await Promise.all( + ids.map(async (id: string) => { + const enriched = await this.enrichChartFromTileJson( + res[id] as ChartResource + ); + return [ + id, + this.transform(collection, enriched, id), + !this.selectionIsFiltered(collection) + ? true + : this.selectionHas(collection, id) + ] as T; + }) + )) as T[]; + resolve(list); + } catch (err) { + reject(err as HttpErrorResponse); + } + })(); + return; + } + + const list: T[] = []; + ids.forEach((id: string) => { + list.push( + [ + id, + this.transform(collection, res[id], id), + !this.selectionIsFiltered(collection) + ? true + : this.selectionHas(collection, id) + ] as T + ); }); resolve(list); }, @@ -319,7 +464,22 @@ export class SKResourceService { | RegionResource | NoteResource | ChartResource - ) => resolve(this.transform(collection, res, id)), + ) => { + if (collection === 'charts') { + (async () => { + try { + const enriched = await this.enrichChartFromTileJson( + res as ChartResource + ); + resolve(this.transform(collection, enriched, id)); + } catch (err) { + reject(err as HttpErrorResponse); + } + })(); + return; + } + resolve(this.transform(collection, res, id)); + }, (err: HttpErrorResponse) => reject(err) ); }); @@ -619,6 +779,12 @@ export class SKResourceService { chart.url = this.app.hostDef.url + chart.url; } } + if ( + typeof chart.format === 'undefined' && + chart.type?.toLowerCase() === 'tilelayer' + ) { + chart.format = this.inferFormatFromUrl(chart.url); + } return new SKChart(chart); } diff --git a/src/polyfills.ts b/src/polyfills.ts new file mode 100644 index 000000000..445ac06dc --- /dev/null +++ b/src/polyfills.ts @@ -0,0 +1 @@ +// Required for tsconfig.spec.json; runtime polyfills are configured in angular.json. diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 000000000..f822d6386 --- /dev/null +++ b/src/test.ts @@ -0,0 +1,11 @@ +import 'zone.js/testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +);