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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ jobs:
- name: Lint
run: pnpm lint

- name: Test
run: pnpm test --watch=false --browsers=ChromeHeadless

- name: Build
run: pnpm build
67 changes: 67 additions & 0 deletions src/@seed/services/map/map.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { provideHttpClient } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { MapService } from './map.service'

describe('MapService', () => {
let service: MapService
let http: HttpTestingController

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
})
service = TestBed.inject(MapService)
http = TestBed.inject(HttpTestingController)
})

afterEach(() => {
http.verify()
})

it('should cache string tract IDs before notifying the caller that status is ready', () => {
const statusReady = jasmine.createSpy('statusReady').and.callFake(() => {
expect(service.isDisadvantaged('01001020100')).toBeTrue()
expect(service.isDisadvantaged('01001020200')).toBeFalse()
})
service.checkDisadvantagedStatus(['01001020100', '01001020200']).subscribe(statusReady)

const request = http.expectOne('/api/v3/eeej/filter_disadvantaged_tracts/')
expect(request.request.method).toBe('POST')
expect(request.request.body).toEqual({ tract_ids: ['01001020100', '01001020200'] })
expect(statusReady).not.toHaveBeenCalled()

request.flush({ status: 'success', disadvantaged: ['01001020100'] })

expect(statusReady).toHaveBeenCalledTimes(1)
})

it('should immediately notify the caller when all tract statuses are cached', () => {
service.disadvantaged = { '01001020100': true, '01001020200': false }
const statusReady = jasmine.createSpy('statusReady')

service.checkDisadvantagedStatus(['01001020100', '01001020200']).subscribe(statusReady)

expect(statusReady).toHaveBeenCalledTimes(1)
http.expectNone('/api/v3/eeej/filter_disadvantaged_tracts/')
})

it('should complete without a request when there are no census tracts', () => {
const complete = jasmine.createSpy('complete')

service.checkDisadvantagedStatus([]).subscribe({ complete })

expect(complete).toHaveBeenCalledTimes(1)
http.expectNone('/api/v3/eeej/filter_disadvantaged_tracts/')
})

it('should propagate lookup errors without caching a negative status', () => {
const error = jasmine.createSpy('error')
service.checkDisadvantagedStatus(['01001020100']).subscribe({ error })

http.expectOne('/api/v3/eeej/filter_disadvantaged_tracts/').flush('Lookup failed', { status: 500, statusText: 'Internal Server Error' })

expect(error).toHaveBeenCalledTimes(1)
expect(service.disadvantaged).toEqual({})
})
})
35 changes: 17 additions & 18 deletions src/@seed/services/map/map.service.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,32 @@
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { map, tap } from 'rxjs'
import type { Observable } from 'rxjs'
import { map, of } from 'rxjs'

@Injectable({ providedIn: 'root' })
export class MapService {
private _httpClient: HttpClient = inject(HttpClient)
disadvantaged = {}
disadvantaged: Record<string, boolean> = {}

checkDisadvantagedStatus(tractIds: number[]) {
checkDisadvantagedStatus(tractIds: string[]): Observable<void> {
const idsToFetch = tractIds.filter((id) => !(id in this.disadvantaged))
if (idsToFetch.length) {
const url = '/api/v3/eeej/filter_disadvantaged_tracts/'
this._httpClient
.post<{ status: string; disadvantaged: number[] }>(url, { tract_ids: tractIds })
.pipe(
map(({ disadvantaged }) => disadvantaged),
tap((disadvantagedTracts) => {
for (const id of idsToFetch) {
this.disadvantaged[id] = disadvantagedTracts.includes(id)
}
}),
)
.subscribe()
if (!idsToFetch.length) {
return of(undefined)
}

const url = '/api/v3/eeej/filter_disadvantaged_tracts/'
return this._httpClient.post<{ status: string; disadvantaged: string[] }>(url, { tract_ids: tractIds }).pipe(
map(({ disadvantaged }) => {
for (const id of idsToFetch) {
this.disadvantaged[id] = disadvantaged.includes(id)
}
}),
)
}

isDisadvantaged(tractId: number): boolean {
isDisadvantaged(tractId: string): boolean {
if (tractId in this.disadvantaged) {
return !!this.disadvantaged[tractId]
return this.disadvantaged[tractId]
}
// TODO: address this comment
// console.error(`Tract ${tractId} hasn't previously been fetched, run checkDisadvantagedStatus first`)
Expand Down
27 changes: 27 additions & 0 deletions src/app/core/transloco/transloco-testing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { TestBed } from '@angular/core/testing'
import { TranslocoService } from '@jsverse/transloco'
import { getTranslocoTestingModule } from './transloco-testing'

describe('getTranslocoTestingModule', () => {
it('should configure English without requiring a translation dictionary', async () => {
await TestBed.configureTestingModule({
imports: [getTranslocoTestingModule()],
}).compileComponents()

const service = TestBed.inject(TranslocoService)
expect(service.getDefaultLang()).toBe('en_US')
expect(service.getActiveLang()).toBe('en_US')
expect(service.getTranslation('en_US')).toEqual({})
})

it('should preload test-specific translations and support interpolation', async () => {
const translations = { Greeting: 'Hello {{ name }}' }
await TestBed.configureTestingModule({
imports: [getTranslocoTestingModule(translations)],
}).compileComponents()

const service = TestBed.inject(TranslocoService)
expect(service.getTranslation('en_US')).toEqual(translations)
expect(service.translate('Greeting', { name: 'SEED' })).toBe('Hello SEED')
})
})
10 changes: 10 additions & 0 deletions src/app/core/transloco/transloco-testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ModuleWithProviders } from '@angular/core'
import { TranslocoTestingModule } from '@jsverse/transloco'

export function getTranslocoTestingModule(translations: Record<string, string> = {}): ModuleWithProviders<TranslocoTestingModule> {
return TranslocoTestingModule.forRoot({
langs: { en_US: translations },
translocoConfig: { availableLangs: ['en_US'], defaultLang: 'en_US' },
preloadLangs: true,
})
}
1 change: 1 addition & 0 deletions src/app/modules/inventory-list/map/map.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
<span class="text-sm">Census Tracts</span>
</mat-checkbox>
<mat-checkbox
[class.opacity-40]="!layers?.censusTractLayer?.visible"
[checked]="highlightDACs"
[disabled]="!layers?.censusTractLayer?.visible"
(change)="toggleHighlightDACs()"
Expand Down
191 changes: 191 additions & 0 deletions src/app/modules/inventory-list/map/map.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { NgTemplateOutlet } from '@angular/common'
import { provideHttpClient } from '@angular/common/http'
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'
import { NO_ERRORS_SCHEMA } from '@angular/core'
import type { ComponentFixture } from '@angular/core/testing'
import { TestBed } from '@angular/core/testing'
import { MatCheckbox } from '@angular/material/checkbox'
import { MatIconTestingModule } from '@angular/material/icon/testing'
import { By } from '@angular/platform-browser'
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
import type { Feature } from 'ol'
import VectorLayer from 'ol/layer/Vector'
import Map from 'ol/Map'
import VectorSource from 'ol/source/Vector'
import Style from 'ol/style/Style'
import View from 'ol/View'
import { EMPTY } from 'rxjs'
import { ColumnService, GroupsService, InventoryService, LabelService, OrganizationService, UserService } from '@seed/api'
import { MaterialImports } from '@seed/materials'
import { MapService } from '@seed/services/map'
import { MapComponent } from './map.component'

describe('MapComponent', () => {
let component: MapComponent
let fixture: ComponentFixture<MapComponent>
let http: HttpTestingController

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MapComponent, MatIconTestingModule],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ type: 'properties' }) } } },
{ provide: Router, useValue: {} },
{ provide: ColumnService, useValue: {} },
{ provide: GroupsService, useValue: {} },
{ provide: InventoryService, useValue: {} },
{ provide: LabelService, useValue: {} },
{ provide: OrganizationService, useValue: {} },
{ provide: UserService, useValue: { currentUser$: EMPTY } },
],
})
.overrideComponent(MapComponent, {
set: { imports: [NgTemplateOutlet, MaterialImports], schemas: [NO_ERRORS_SCHEMA] },
})
.compileComponents()

fixture = TestBed.createComponent(MapComponent)
component = fixture.componentInstance
component.censusTractLayer = new VectorLayer({ source: new VectorSource() })
http = TestBed.inject(HttpTestingController)
})

afterEach(() => {
fixture.destroy()
component.censusTractLayer.dispose()
component.map?.dispose()
http.verify()
})

for (const initiallyHighlighted of [true, false]) {
it(`should immediately invalidate census tract styles when turning highlights ${initiallyHighlighted ? 'off' : 'on'}`, () => {
component.highlightDACs = initiallyHighlighted
const layer = component.censusTractLayer
const source = layer.getSource()
const revision = layer.getRevision()
const onChange = jasmine.createSpy('onChange').and.callFake(() => {
expect(component.highlightDACs).toBe(!initiallyHighlighted)
})
layer.on('change', onChange)

component.toggleHighlightDACs()

expect(component.highlightDACs).toBe(!initiallyHighlighted)
expect(onChange).toHaveBeenCalledTimes(1)
expect(layer.getRevision()).toBeGreaterThan(revision)
expect(layer.getSource()).toBe(source)
})
}

describe('initial census tract loading', () => {
beforeEach(() => {
component.geocodedProperties = []
component.geocodedTaxlots = []
component.map = new Map({
view: new View({ center: [0, 0], zoom: 12 }),
controls: [],
interactions: [],
})
component.map.setSize([256, 256])
spyOn(window, 'fetch').and.resolveTo(
new Response(
JSON.stringify({
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { GEOID10: '01001020100' }, geometry: { type: 'Point', coordinates: [0, 0] } },
{ type: 'Feature', properties: { GEOID10: '01001020200' }, geometry: { type: 'Point', coordinates: [0, 1] } },
],
}),
),
)
})

for (const highlightEnabled of [true, false]) {
it(`should load tract styles with highlights ${highlightEnabled ? 'enabled by default' : 'disabled while loading'}`, async () => {
expect(component.highlightDACs).toBeTrue()
const layersReady = jasmine.createSpy('layersReady')
const service = TestBed.inject(MapService)
const checkStatus = service.checkDisadvantagedStatus.bind(service) as MapService['checkDisadvantagedStatus']
const statusRequested = new Promise<void>((resolve) => {
spyOn(service, 'checkDisadvantagedStatus').and.callFake((tractIds) => {
resolve()
return checkStatus(tractIds)
})
})
const loading = component.setLayers().then(layersReady)
await statusRequested
await new Promise<void>((resolve) => setTimeout(resolve, 0))

const request = http.expectOne('/api/v3/eeej/filter_disadvantaged_tracts/')
expect(request.request.body).toEqual({ tract_ids: ['01001020100', '01001020200'] })
expect(layersReady).not.toHaveBeenCalled()

if (!highlightEnabled) component.toggleHighlightDACs()
request.flush({ status: 'success', disadvantaged: ['01001020100'] })
await loading

expect(layersReady).toHaveBeenCalledTimes(1)
const layer = component.censusTractLayer
const styleFunction = layer.getStyleFunction()
for (const feature of layer.getSource().getFeatures() as Feature[]) {
const style = styleFunction(feature, 1)
expect(style).toBeInstanceOf(Style)
if (!(style instanceof Style)) throw new Error('Expected a census tract style')
const shouldHighlight = highlightEnabled && feature.get('GEOID10') === '01001020100'
expect(style.getFill()?.getColor()).toBe(shouldHighlight ? 'rgba(69, 130, 155, 0.4)' : undefined)
expect(style.getStroke()).toBeTruthy()
}
})
}
})

it('should mute the entire highlight control when census tracts are hidden and restore it when shown', async () => {
component.groupId = 1
component.data = [
{
id: 1,
bounding_box: '',
centroid: '',
derived_data: {},
extra_data: {},
files: [],
labels: [],
measures: [],
scenarios: [],
},
]
component.geocodedProperties = []
component.geocodedTaxlots = []
await component.setLayers()
component.map = new Map({ layers: [component.censusTractLayer], controls: [], interactions: [] })
fixture.detectChanges()

const highlightControl = fixture.debugElement
.queryAll(By.directive(MatCheckbox))
.find((element) => (element.nativeElement as HTMLElement).textContent.includes('Highlight DACs'))
expect(highlightControl).toBeDefined()
if (!highlightControl) throw new Error('Expected the highlight control')
const checkbox = highlightControl.injector.get(MatCheckbox)
const element = highlightControl.nativeElement as HTMLElement

expect(checkbox.disabled).toBeFalse()
expect(element.classList.contains('opacity-40')).toBeFalse()

component.toggleLayer('censusTractLayer', false)
fixture.detectChanges()

expect(checkbox.disabled).toBeTrue()
expect(checkbox.checked).toBeTrue()
expect(element.querySelector('input')?.disabled).toBeTrue()
expect(element.classList.contains('opacity-40')).toBeTrue()

component.toggleLayer('censusTractLayer', true)
fixture.detectChanges()

expect(checkbox.disabled).toBeFalse()
expect(checkbox.checked).toBeTrue()
expect(element.classList.contains('opacity-40')).toBeFalse()
})
})
Loading
Loading