[#66563] Migrate specs to Vitest

Apply Angular's Vitest migration schematic to update frontend and plugin
specs from Jasmine globals to Vitest APIs.

    ng g @schematics/angular:refactor-jasmine-vitest

Fix migrated edge cases where async assertions or shallow tests changed.

https://community.openproject.org/wp/66563
This commit is contained in:
Alexander Brandon Coles
2026-05-04 20:12:39 +01:00
parent 5bd700c4f8
commit 6e8510ca1d
32 changed files with 753 additions and 736 deletions
@@ -26,9 +26,7 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import {
TestBed,
} from '@angular/core/testing';
import { TestBed, } from '@angular/core/testing';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { States } from 'core-app/core/states/states.service';
@@ -58,7 +56,7 @@ describe('APIv3Service', () => {
expect(service.projects.id(projectIdentifier).path).toEqual('/api/v3/projects/majora');
});
it('should provide a path to work package query on subject or ID ', () => {
it('should provide a path to work package query on subject or ID', () => {
let params = {
filters: '[{"typeahead":{"operator":"**","values":["bogus"]}}]',
sortBy: '[["updatedAt","desc"]]',
@@ -66,9 +64,7 @@ describe('APIv3Service', () => {
pageSize: '10',
};
expect(
service.work_packages.filterByTypeaheadOrId('bogus').path,
).toEqual(`/api/v3/work_packages?${encodeParams(params)}`);
expect(service.work_packages.filterByTypeaheadOrId('bogus').path).toEqual(`/api/v3/work_packages?${encodeParams(params)}`);
params = {
filters: '[{"id":{"operator":"=","values":["1234"]}}]',
@@ -77,9 +73,7 @@ describe('APIv3Service', () => {
pageSize: '10',
};
expect(
service.work_packages.filterByTypeaheadOrId('1234', true).path,
).toEqual(`/api/v3/work_packages?${encodeParams(params)}`);
expect(service.work_packages.filterByTypeaheadOrId('1234', true).path).toEqual(`/api/v3/work_packages?${encodeParams(params)}`);
});
});
});
@@ -35,7 +35,8 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { States } from 'core-app/core/states/states.service';
import { take, takeWhile } from 'rxjs/operators';
import { firstValueFrom } from 'rxjs';
import { take, toArray } from 'rxjs/operators';
import { WorkPackagesActivityService } from 'core-app/features/work-packages/components/wp-single-view-tabs/activity-panel/wp-activity.service';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service';
@@ -55,8 +56,8 @@ describe('WorkPackageCache', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [OpenprojectHalModule],
providers: [
imports: [OpenprojectHalModule],
providers: [
States,
HalResourceService,
TimezoneService,
@@ -64,15 +65,15 @@ describe('WorkPackageCache', () => {
SchemaCacheService,
PathHelperService,
{ provide: ConfigurationService, useValue: {} },
{ provide: I18nService, useValue: { t: (...args:any[]) => 'translation' } },
{ provide: I18nService, useValue: { t: (..._args:any[]) => 'translation' } },
{ provide: WorkPackageResource, useValue: {} },
{ provide: ToastService, useValue: {} },
{ provide: HalResourceNotificationService, useValue: { handleRawError: () => false } },
{ provide: WorkPackageNotificationService, useValue: {} },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
]
});
]
});
injector = TestBed.inject(Injector);
states = TestBed.inject(States);
@@ -80,55 +81,40 @@ describe('WorkPackageCache', () => {
workPackageCache = new WorkPackageCache(injector, states.workPackages);
// sinon.stub(schemaCacheService, 'ensureLoaded').returns(Promise.resolve(true));
spyOn(schemaCacheService, 'ensureLoaded').and.resolveTo(true as any);
vi.spyOn(schemaCacheService, 'ensureLoaded').mockResolvedValue(true as any);
const workPackage1 = new WorkPackageResource(
injector,
{
id: '1',
_links: {
self: '',
},
const workPackage1 = new WorkPackageResource(injector, {
id: '1',
_links: {
self: '',
},
true,
(wp:WorkPackageResource) => undefined,
'WorkPackage',
);
}, true, (_wp:WorkPackageResource) => undefined, 'WorkPackage');
dummyWorkPackages = [workPackage1 as any];
});
it('returns a work package after the list has been initialized', (done:any) => {
workPackageCache.state('1').values$()
.pipe(
take(1),
)
.subscribe((wp:WorkPackageResource) => {
expect(wp.id!).toEqual('1');
done();
});
it('returns a work package after the list has been initialized', async () => {
const emittedWorkPackage = firstValueFrom(
workPackageCache.state('1').values$().pipe(take(1)),
);
workPackageCache.updateWorkPackageList(dummyWorkPackages);
await expect(emittedWorkPackage).resolves.toMatchObject({ id: '1' });
});
it('should return/stream a work package every time it gets updated', (done:any) => {
let count = 0;
workPackageCache.state('1').values$()
.pipe(
takeWhile((wp) => count < 2),
)
.subscribe((wp:WorkPackageResource) => {
expect(wp.id!).toEqual('1');
count += 1;
if (count === 2) {
done();
}
});
it('should return/stream a work package every time it gets updated', async () => {
const emittedWorkPackages = firstValueFrom(
workPackageCache.state('1').values$().pipe(take(3), toArray()),
);
workPackageCache.updateWorkPackageList([dummyWorkPackages[0]], false);
workPackageCache.updateWorkPackageList([dummyWorkPackages[0]], false);
workPackageCache.updateWorkPackageList([dummyWorkPackages[0]], false);
const values = await emittedWorkPackages;
expect(values).toHaveLength(3);
expect(values.map((wp) => wp.id)).toEqual(['1', '1', '1']);
});
});
@@ -37,7 +37,8 @@ import { OpenprojectHalModule } from 'core-app/features/hal/openproject-hal.modu
import { HalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import Spy = jasmine.Spy;
import type { Mock } from 'vitest';
type Spy = Mock;
describe('HalResource', () => {
let halResourceService:HalResourceService;
@@ -45,21 +46,21 @@ describe('HalResource', () => {
let source:any;
let resource:HalResource;
class OtherResource extends HalResource {
}
let OtherResource:typeof HalResource;
beforeEach(async () => {
OtherResource = class extends HalResource {};
await TestBed.configureTestingModule({
imports: [OpenprojectHalModule],
providers: [
imports: [OpenprojectHalModule],
providers: [
HalResourceService,
States,
I18nService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
]
}).compileComponents();
]
}).compileComponents();
halResourceService = TestBed.inject(HalResourceService);
injector = TestBed.inject(Injector);
});
@@ -71,7 +72,7 @@ describe('HalResource', () => {
});
describe('when updating a loaded resource using `$update()`', () => {
let getStub:jasmine.Spy;
let getStub:Mock;
beforeEach(() => {
source = {
@@ -82,7 +83,7 @@ describe('HalResource', () => {
},
};
getStub = spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => {
getStub = vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => {
if (verb === 'get' && path === '/api/hello') {
return of(halResourceService.createHalResource(source)) as any;
}
@@ -106,7 +107,7 @@ describe('HalResource', () => {
});
it('should be an instance of HalResource', () => {
expect(resource).toEqual(jasmine.any(HalResource));
expect(resource).toEqual(expect.any(HalResource));
});
});
@@ -121,19 +122,16 @@ describe('HalResource', () => {
},
};
halResourceService.registerResource(
'Other',
{ cls: OtherResource, attrTypes: { someResource: 'Other' } },
);
halResourceService.registerResource('Other', { cls: OtherResource, attrTypes: { someResource: 'Other' } });
resource = halResourceService.createHalResource(source, false);
});
it('should be an instance of that type', () => {
expect(resource).toEqual(jasmine.any(OtherResource));
expect(resource).toEqual(expect.any(OtherResource));
});
it('should have an attribute that is of the configured instance', () => {
expect(resource.someResource).toEqual(jasmine.any(OtherResource));
expect(resource.someResource).toEqual(expect.any(OtherResource));
});
it('should not be loaded', () => {
@@ -147,8 +145,8 @@ describe('HalResource', () => {
let embeddedFn:Spy;
beforeEach(() => {
linkFn = jasmine.createSpy();
embeddedFn = jasmine.createSpy();
linkFn = vi.fn();
embeddedFn = vi.fn();
resource = halResourceService.createHalResource({
_links: {
@@ -167,25 +165,25 @@ describe('HalResource', () => {
});
it('should not have touched the source links initially', () => {
expect(linkFn.calls.count()).toEqual(0);
expect(vi.mocked(linkFn).mock.calls.length).toEqual(0);
});
it('should not have touched the embedded elements of the source initially', () => {
expect(embeddedFn.calls.count()).toEqual(0);
expect(vi.mocked(embeddedFn).mock.calls.length).toEqual(0);
});
it('should use the source link only once when called', () => {
resource.link;
resource.link;
expect(linkFn.calls.count()).toEqual(1);
expect(vi.mocked(linkFn).mock.calls.length).toEqual(1);
});
it('should use the source embedded only once when called', () => {
resource.resource;
resource.resource;
expect(embeddedFn.calls.count()).toEqual(1);
expect(vi.mocked(embeddedFn).mock.calls.length).toEqual(1);
});
});
@@ -377,7 +375,7 @@ describe('HalResource', () => {
});
it('should have a callable self link', () => {
spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => {
vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => {
if (verb === 'get' && path === 'unicorn/69') {
return of(halResourceService.createHalResource({})) as any;
}
@@ -388,7 +386,7 @@ describe('HalResource', () => {
});
it('should have a callable beaver', () => {
spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => {
vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => {
if (verb === 'get' && path === 'justin/420') {
return of(halResourceService.createHalResource({})) as any;
}
@@ -632,7 +630,8 @@ describe('HalResource', () => {
it('should not be possible to override a link', () => {
try {
resource.$links.action = 'foo';
} catch (ignore) {
}
catch (ignore) {
/**/
}
@@ -642,7 +641,8 @@ describe('HalResource', () => {
it('should not be possible to override an embedded resource', () => {
try {
resource.$embedded.embedded = 'foo';
} catch (ignore) {
}
catch (ignore) {
/**/
}
@@ -713,18 +713,18 @@ describe('HalResource', () => {
});
describe('when loading it', () => {
let getStub:jasmine.Spy;
let getStub:Mock;
let newResult:any;
let promise:Promise<any>;
beforeEach((done) => {
beforeEach(async () => {
const result = halResourceService.createHalResource({
_links: {},
name: 'name',
foo: 'bar',
});
getStub = spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => {
getStub = vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => {
if (verb === 'get' && path === '/api/property') {
return of(result) as any;
}
@@ -737,14 +737,13 @@ describe('HalResource', () => {
});
expect(getStub).toHaveBeenCalled();
done();
;
});
it('should be loaded', (done) => {
promise.then(() => {
expect(resource.$loaded).toBeTruthy();
done();
}).catch(done.fail);
it('should be loaded', async () => {
await promise;
expect(resource.$loaded).toBeTruthy();
});
it('should be updated', () => {
@@ -754,10 +753,10 @@ describe('HalResource', () => {
it('should have properties that have a getter and setter', () => {
const descriptor = Object.getOwnPropertyDescriptor(newResult, 'foo');
expect(descriptor).toBeDefined('Descriptor should be defined');
expect(descriptor).toBeDefined();
expect(descriptor!.get).toBeDefined('Descriptor getter should be defined');
expect(descriptor!.set).toBeDefined('Descriptor setter should be defined');
expect(descriptor!.get).toBeDefined();
expect(descriptor!.set).toBeDefined();
});
it('should return itself in a promise if already loaded', () => {
@@ -70,8 +70,8 @@ describe('WorkPackage', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [OpenprojectHalModule],
providers: [
imports: [OpenprojectHalModule],
providers: [
HalResourceService,
States,
TimezoneService,
@@ -89,8 +89,8 @@ describe('WorkPackage', () => {
{ provide: SchemaCacheService, useValue: {} },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
]
}).compileComponents();
]
}).compileComponents();
halResourceService = TestBed.inject(HalResourceService);
injector = TestBed.inject(Injector);
halResourceNotification = injector.get(HalResourceNotificationService);
@@ -109,7 +109,7 @@ describe('WorkPackage', () => {
beforeEach(createWorkPackage);
it('should have an attachments property of type `AttachmentCollectionResource`', () => {
expect(workPackage.attachments).toEqual(jasmine.any(AttachmentCollectionResource));
expect(workPackage.attachments).toEqual(expect.any(AttachmentCollectionResource));
});
it('should return true for `isNewResource`', () => {
@@ -190,11 +190,12 @@ describe('WorkPackage', () => {
it('surfaces the semantic displayId on each ancestor resource', () => {
const ancestor = (workPackage as any).ancestors[0] as WorkPackageResource;
expect(ancestor.displayId).toEqual('ACSMT-15');
});
});
});
});
describe('formattedId', () => {
afterEach(() => {
@@ -215,7 +216,7 @@ describe('WorkPackage', () => {
expect(workPackage.formattedId).toEqual('#42');
});
});
});
describe('subjectWithId', () => {
afterEach(() => {
@@ -31,8 +31,8 @@ import { HookService } from 'core-app/features/plugins/hook-service';
describe('HookService', () => {
let service:HookService = new HookService();
let callback:any; let
invalidCallback:any;
let callback:any;
let invalidCallback:any;
const validId = 'myValidCallbacks';
beforeEach(() => {
@@ -80,7 +80,7 @@ describe('HookService', () => {
describe('valid function callback registered', () => {
beforeEach(() => {
callback = jasmine.createSpy('hook');
callback = vi.fn();
service.register('myValidCallbacks', callback);
});
@@ -93,7 +93,7 @@ describe('HookService', () => {
describe('call', () => {
describe('function that returns undefined', () => {
beforeEach(() => {
callback = jasmine.createSpy('hook');
callback = vi.fn();
service.register('myValidCallbacks', callback);
});
@@ -104,7 +104,7 @@ describe('HookService', () => {
describe('function that returns something that is not undefined', () => {
beforeEach(() => {
callback = jasmine.createSpy('hook').and.returnValue({});
callback = vi.fn().mockReturnValue({});
service.register('myValidCallbacks', callback);
});
@@ -116,7 +116,7 @@ describe('HookService', () => {
describe('function that returns something that is not undefined', () => {
beforeEach(() => {
callback = jasmine.createSpy('hook').and.returnValue({});
callback = vi.fn().mockReturnValue({});
service.register('myValidCallbacks', callback);
});
@@ -128,8 +128,8 @@ describe('HookService', () => {
describe('function that returns something that is not undefined', () => {
beforeEach(() => {
callback = jasmine.createSpy('hook');
invalidCallback = jasmine.createSpy('invalidHook');
callback = vi.fn();
invalidCallback = vi.fn();
service.register('myValidCallbacks', callback);
@@ -142,12 +142,12 @@ describe('HookService', () => {
});
describe('function that returns something that is not undefined', () => {
let callback1; let
callback2;
let callback1;
let callback2;
beforeEach(() => {
callback1 = jasmine.createSpy('hook1').and.returnValue({});
callback2 = jasmine.createSpy('hook1').and.returnValue({});
callback1 = vi.fn().mockReturnValue({});
callback2 = vi.fn().mockReturnValue({});
service.register('myValidCallbacks', callback1);
service.register('myValidCallbacks', callback2);
@@ -73,8 +73,8 @@ describe('WorkPackageFilterValues', () => {
function setupTestBed() {
// noinspection JSIgnoredPromiseFromCall
void TestBed.configureTestingModule({
imports: [UIRouterModule.forRoot({})],
providers: [
imports: [UIRouterModule.forRoot({})],
providers: [
I18nService,
{ provide: WeekdayService, useValue: WeekdayServiceStub },
States,
@@ -95,8 +95,8 @@ describe('WorkPackageFilterValues', () => {
HalResourceEditingService,
WorkPackagesActivityService,
provideHttpClient(withInterceptorsFromDi()),
]
}).compileComponents();
]
}).compileComponents();
injector = TestBed.inject(Injector);
halResourceService = injector.get(HalResourceService);
@@ -104,14 +104,8 @@ describe('WorkPackageFilterValues', () => {
resource = halResourceService.createHalResourceOfClass(WorkPackageResource, source, true);
changeset = new WorkPackageChangeset(resource);
const type1 = halResourceService.createHalResourceOfClass(
TypeResource,
{ _type: 'Type', id: '1', _links: { self: { href: '/api/v3/types/1', name: 'Task' } } },
);
const type2 = halResourceService.createHalResourceOfClass(
TypeResource,
{ _type: 'Type', id: '2', _links: { self: { href: '/api/v3/types/2', name: 'Bug' } } },
);
const type1 = halResourceService.createHalResourceOfClass(TypeResource, { _type: 'Type', id: '1', _links: { self: { href: '/api/v3/types/1', name: 'Task' } } });
const type2 = halResourceService.createHalResourceOfClass(TypeResource, { _type: 'Type', id: '2', _links: { self: { href: '/api/v3/types/2', name: 'Bug' } } });
filters = [
{
@@ -79,7 +79,7 @@ describe('keepTab service', () => {
let currentPathPrefix = 'work-packages.show.*';
beforeEach(() => {
spyOn($state, 'includes').and.callFake((path:string) => path === currentPathPrefix);
vi.spyOn($state, 'includes').mockImplementation((path:string) => path === currentPathPrefix);
$state.current.name = 'work-packages.show.tabs';
uiRouterGlobals.params.tabIdentifier = 'relations';
@@ -95,7 +95,7 @@ describe('keepTab service', () => {
});
it('should propagate the previous change', () => {
const cb = jasmine.createSpy();
const cb = vi.fn();
const expected = {
active: 'relations',
@@ -121,7 +121,7 @@ describe('keepTab service', () => {
describe('when opening show#activity', () => {
beforeEach(() => {
spyOn($state, 'includes').and.callFake((path:string) => path === 'work-packages.show.*');
vi.spyOn($state, 'includes').mockImplementation((path:string) => path === 'work-packages.show.*');
uiRouterGlobals.params.tabIdentifier = 'activity';
$state.current.name = 'work-packages.show.tabs';
@@ -135,7 +135,7 @@ describe('keepTab service', () => {
describe('when opening a details route', () => {
beforeEach(() => {
spyOn($state, 'includes').and.callFake((path:string) => path === '**.details.*');
vi.spyOn($state, 'includes').mockImplementation((path:string) => path === '**.details.*');
uiRouterGlobals.params.tabIdentifier = 'activity';
$state.current.name = 'work-packages.partitioned.list.details.tabs';
@@ -151,7 +151,7 @@ describe('keepTab service', () => {
});
it('should propagate the previous and next change', () => {
const cb = jasmine.createSpy();
const cb = vi.fn();
const expected = {
active: 'activity',
@@ -165,7 +165,7 @@ describe('keepTab service', () => {
keepTab.updateTabs();
expect(cb.calls.count()).toEqual(2);
expect(vi.mocked(cb).mock.calls.length).toEqual(2);
});
});
});
@@ -52,14 +52,11 @@ function setupMocks(paginationService:PaginationService) {
optionsTruncationSize: 6,
};
// eslint-disable-next-line jasmine/no-unsafe-spy
spyOn(paginationService, 'getMaxVisiblePageOptions').and.callFake(() => options.maxVisiblePageOptions);
vi.spyOn(paginationService, 'getMaxVisiblePageOptions').mockImplementation(() => options.maxVisiblePageOptions);
// eslint-disable-next-line jasmine/no-unsafe-spy
spyOn(paginationService, 'getOptionsTruncationSize').and.callFake(() => options.optionsTruncationSize);
vi.spyOn(paginationService, 'getOptionsTruncationSize').mockImplementation(() => options.optionsTruncationSize);
// eslint-disable-next-line jasmine/no-unsafe-spy
spyOn(paginationService, 'getPaginationOptions').and.callFake(() => options);
vi.spyOn(paginationService, 'getPaginationOptions').mockImplementation(() => options);
}
function pageString(element:HTMLElement) {
@@ -75,12 +72,12 @@ describe('wpTablePagination Directive', () => {
};
await TestBed.configureTestingModule({
declarations: [
declarations: [
WorkPackageTablePaginationComponent,
OpIconComponent,
],
imports: [],
providers: [
],
imports: [],
providers: [
States,
PaginationService,
WorkPackageViewSortByService,
@@ -92,80 +89,77 @@ describe('wpTablePagination Directive', () => {
IsolatedQuerySpace,
I18nService,
provideHttpClient(withInterceptorsFromDi()),
]
}).compileComponents();
]
}).compileComponents();
});
describe('page ranges and links', () => {
it('should display the correct page range',
inject([PaginationService], (paginationService:PaginationService) => {
setupMocks(paginationService);
const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent);
const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance;
const element = fixture.elementRef.nativeElement;
it('should display the correct page range', inject([PaginationService], (paginationService:PaginationService) => {
setupMocks(paginationService);
const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent);
const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance;
const element = fixture.elementRef.nativeElement;
app.pagination = new PaginationInstance(1, 0, 10);
app.update();
fixture.detectChanges();
app.pagination = new PaginationInstance(1, 0, 10);
app.update();
fixture.detectChanges();
expect(pageString(element)).toEqual('');
expect(pageString(element)).toEqual('');
app.pagination = new PaginationInstance(1, 11, 10);
app.update();
fixture.detectChanges();
app.pagination = new PaginationInstance(1, 11, 10);
app.update();
fixture.detectChanges();
expect(pageString(element)).toEqual('(1 - 10/11)');
}));
expect(pageString(element)).toEqual('(1 - 10/11)');
}));
describe('"next" link', () => {
it('hidden on the last page',
inject([PaginationService], (paginationService:PaginationService) => {
setupMocks(paginationService);
const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent);
const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance;
const element = fixture.elementRef.nativeElement;
app.pagination = new PaginationInstance(2, 11, 10);
app.update();
fixture.detectChanges();
const liWithNextLink = element.querySelector('.op-pagination--item-link_next')?.parentElement;
expect(liWithNextLink?.matches('li')).toBeTrue();
const attrHidden = liWithNextLink.getAttribute('hidden');
expect(attrHidden).toBeDefined();
}));
});
it('should display correct number of page number links',
inject([PaginationService], (paginationService:PaginationService) => {
it('hidden on the last page', inject([PaginationService], (paginationService:PaginationService) => {
setupMocks(paginationService);
const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent);
const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance;
const element = fixture.elementRef.nativeElement;
function numberOfPageNumberLinks() {
return element.querySelectorAll('button[data-rel="next"]').length;
}
app.pagination = new PaginationInstance(1, 1, 10);
app.pagination = new PaginationInstance(2, 11, 10);
app.update();
fixture.detectChanges();
expect(numberOfPageNumberLinks()).toEqual(1);
const liWithNextLink = element.querySelector('.op-pagination--item-link_next')?.parentElement;
app.pagination = new PaginationInstance(1, 11, 10);
app.update();
fixture.detectChanges();
expect(liWithNextLink?.matches('li')).toBe(true);
const attrHidden = liWithNextLink.getAttribute('hidden');
expect(numberOfPageNumberLinks()).toEqual(2);
app.pagination = new PaginationInstance(1, 59, 10);
app.update();
fixture.detectChanges();
expect(numberOfPageNumberLinks()).toEqual(6);
expect(attrHidden).toBeDefined();
}));
});
it('should display correct number of page number links', inject([PaginationService], (paginationService:PaginationService) => {
setupMocks(paginationService);
const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent);
const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance;
const element = fixture.elementRef.nativeElement;
function numberOfPageNumberLinks() {
return element.querySelectorAll('button[data-rel="next"]').length;
}
app.pagination = new PaginationInstance(1, 1, 10);
app.update();
fixture.detectChanges();
expect(numberOfPageNumberLinks()).toEqual(1);
app.pagination = new PaginationInstance(1, 11, 10);
app.update();
fixture.detectChanges();
expect(numberOfPageNumberLinks()).toEqual(2);
app.pagination = new PaginationInstance(1, 59, 10);
app.update();
fixture.detectChanges();
expect(numberOfPageNumberLinks()).toEqual(6);
}));
});
});
@@ -1,4 +1,4 @@
import { Input, NO_ERRORS_SCHEMA } from '@angular/core';
import { Component, Input, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
@@ -12,8 +12,13 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
describe('WpTabsComponent', () => {
@Component({
template: '',
standalone: false,
})
class TestComponent implements TabComponent {
@Input() public workPackage:WorkPackageResource;
@Input()
public workPackage:WorkPackageResource;
}
const displayableTab = {
@@ -64,6 +69,6 @@ describe('WpTabsComponent', () => {
it('displays the visible tab', () => {
const tabLink:HTMLElement = fixture.debugElement.query(By.css('[data-qa-tab-id="displayable-test-tab"]')).nativeElement;
expect(tabLink.innerText).toContain('Displayable TestTab');
expect(tabLink.textContent).toContain('Displayable TestTab');
});
});
@@ -1,20 +1,23 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { Input } from '@angular/core';
import { StateService } from '@uirouter/angular';
import { Component, Input } from '@angular/core';
import { StateService } from '@uirouter/core';
import { TestBed } from '@angular/core/testing';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import {
WorkPackageTabsService,
} from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service';
import { WorkPackageTabsService, } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service';
import { TabComponent } from '../../components/wp-tab-wrapper/tab';
describe('WpTabsService', () => {
let service:WorkPackageTabsService;
const workPackage:any = { id: 1234 };
@Component({
template: '',
standalone: false,
})
class TestComponent implements TabComponent {
@Input() public workPackage:WorkPackageResource;
@Input()
public workPackage:WorkPackageResource;
}
const displayableTab = {
@@ -34,12 +37,12 @@ describe('WpTabsService', () => {
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [],
providers: [
imports: [],
providers: [
{ provide: StateService, useValue: { includes: () => false } },
provideHttpClient(withInterceptorsFromDi()),
]
});
]
});
service = TestBed.inject(WorkPackageTabsService);
(service as any).registeredTabs = [];
service.register({ ...displayableTab }, { ...notDisplayableTab });
@@ -66,7 +69,7 @@ describe('WpTabsService', () => {
const displayableTabs = service.getDisplayableTabs(workPackage);
expect(displayableTabs).toHaveSize(1);
expect(displayableTabs).toHaveLength(1);
expect(displayableTabs[0].id).toEqual(notDisplayableTab.id);
});
});
@@ -35,7 +35,8 @@ import { WorkPackageViewHierarchyIdentationService } from 'core-app/features/wor
import { WorkPackageViewDisplayRepresentationService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-display-representation.service';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { of } from 'rxjs';
import SpyObj = jasmine.SpyObj;
import type { MockedObject } from 'vitest';
type SpyObj<T> = MockedObject<T>;
describe('WorkPackageViewIndentation service', () => {
let service:WorkPackageViewHierarchyIdentationService;
@@ -59,12 +60,11 @@ describe('WorkPackageViewIndentation service', () => {
}
beforeEach(async () => {
parentServiceSpy = jasmine.createSpyObj(
'WorkPackageRelationHierarchyService',
['changeParent'],
);
parentServiceSpy = {
changeParent: vi.fn().mockName('WorkPackageRelationHierarchyService.changeParent')
};
parentServiceSpy.changeParent.and.resolveTo();
parentServiceSpy.changeParent.mockResolvedValue();
await TestBed.configureTestingModule({
providers: [
@@ -118,8 +118,7 @@ describe('WorkPackageViewIndentation service', () => {
{ workPackageId: '1234', hidden: false, classIdentifier: 'foo' },
]);
spyOnProperty(hierarchyServiceStub, 'isEnabled', 'get')
.and.returnValue(false);
vi.spyOn(hierarchyServiceStub, 'isEnabled', 'get').mockReturnValue(false);
const workPackage:any = { id: '1234', changeParent: () => 'foo', ancestorIds: [] };
@@ -160,8 +159,7 @@ describe('WorkPackageViewIndentation service', () => {
it('Cannot outdent with changeParent link but disabled', () => {
const workPackage:any = { id: '1234', changeParent: () => 'foo', parent: { id: '2345' } };
spyOnProperty(hierarchyServiceStub, 'isEnabled', 'get')
.and.returnValue(false);
vi.spyOn(hierarchyServiceStub, 'isEnabled', 'get').mockReturnValue(false);
expect(service.canOutdent(workPackage)).toBeFalsy();
});
@@ -174,7 +172,7 @@ describe('WorkPackageViewIndentation service', () => {
});
describe('indent', () => {
it('Can indent with a predecessor that is NOT an ancestor already', (done) => {
it('Can indent with a predecessor that is NOT an ancestor already', async () => {
querySpace.tableRendered.putValue([
{ workPackageId: '5555', hidden: false, classIdentifier: 'foo' },
{ workPackageId: '2345', hidden: false, classIdentifier: 'foo' },
@@ -186,13 +184,12 @@ describe('WorkPackageViewIndentation service', () => {
states.workPackages.get('2345').putValue(predecessor);
service.indent(workPackage).then(() => {
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345');
done();
}).catch(done.fail);
await service.indent(workPackage);
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345');
});
it('Can indent with a predecessor that shares an ancestor chain', (done) => {
it('Can indent with a predecessor that shares an ancestor chain', async () => {
querySpace.tableRendered.putValue([
{ workPackageId: '5555', hidden: false, classIdentifier: 'foo' },
{ workPackageId: '2345', hidden: false, classIdentifier: 'foo' },
@@ -204,13 +201,12 @@ describe('WorkPackageViewIndentation service', () => {
states.workPackages.get('2345').putValue(predecessor);
service.indent(workPackage).then(() => {
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '5555');
done();
}).catch(done.fail);
await service.indent(workPackage);
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '5555');
});
it('Can indent with a predecessor that shares an ancestor chain', (done) => {
it('Can indent with a predecessor that shares an ancestor chain', async () => {
querySpace.tableRendered.putValue([
{ workPackageId: '5555', hidden: false, classIdentifier: 'foo' },
{ workPackageId: '2345', hidden: false, classIdentifier: 'foo' },
@@ -222,15 +218,14 @@ describe('WorkPackageViewIndentation service', () => {
states.workPackages.get('2345').putValue(predecessor);
service.indent(workPackage).then(() => {
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345');
done();
}).catch(done.fail);
await service.indent(workPackage);
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345');
});
});
describe('outdent', () => {
it('will outdent to the previous last ancestorId', (done) => {
it('will outdent to the previous last ancestorId', async () => {
querySpace.tableRendered.putValue([
{ workPackageId: '1234', hidden: false, classIdentifier: 'foo' },
]);
@@ -239,13 +234,12 @@ describe('WorkPackageViewIndentation service', () => {
id: '1234', changeParent: () => 'foo', parent: '5555', ancestorIds: ['2345', '5555'],
};
service.outdent(workPackage).then(() => {
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345');
done();
}).catch(done.fail);
await service.outdent(workPackage);
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345');
});
it('will outdent to null in case of ancestorIds.length < 2', (done) => {
it('will outdent to null in case of ancestorIds.length < 2', async () => {
querySpace.tableRendered.putValue([
{ workPackageId: '1234', hidden: false, classIdentifier: 'foo' },
]);
@@ -254,10 +248,9 @@ describe('WorkPackageViewIndentation service', () => {
id: '1234', changeParent: () => 'foo', parent: '2345', ancestorIds: ['2345'],
};
service.outdent(workPackage).then(() => {
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, null);
done();
}).catch(done.fail);
await service.outdent(workPackage);
expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, null);
});
});
});
@@ -1,3 +1,4 @@
import type { Mock } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { AttributeHelpTextsService } from './attribute-help-text.service';
import { AttributeHelpTextModalService } from './attribute-help-text-modal.service';
@@ -6,12 +7,12 @@ import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service
import { ToastService } from '../toaster/toast.service';
describe('AttributeHelpTextModalService', () => {
let fetchSpy:jasmine.Spy<Window['fetch']>;
let fetchSpy:Mock<Window['fetch']>;
let modalService:AttributeHelpTextModalService;
let dialog:HTMLDialogElement|null;
beforeEach(() => {
fetchSpy = spyOn(window, 'fetch');
fetchSpy = vi.spyOn(window, 'fetch') as unknown as Mock<Window['fetch']>;
});
beforeEach(async () => {
@@ -34,12 +35,12 @@ describe('AttributeHelpTextModalService', () => {
const makeSuccessResponse = (dialogId:string, dialogContent:string) => {
const body = `<turbo-stream action="dialog">
<template>
<dialog-helper>
<dialog id="${dialogId}">${dialogContent}</dialog>
</dialog-helper>
</template>
</turbo-stream>`;
<template>
<dialog-helper>
<dialog id="${dialogId}">${dialogContent}</dialog>
</dialog-helper>
</template>
</turbo-stream>`;
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/vnd.turbo-stream.html' } });
};
@@ -47,84 +48,84 @@ describe('AttributeHelpTextModalService', () => {
afterEach(() => {
dialog?.remove();
dialog = null;
vi.restoreAllMocks();
});
describe('with a successful request', () => {
beforeEach(() => {
fetchSpy
.withArgs(jasmine.stringMatching('/1/show_dialog'), jasmine.any(Object))
.and.resolveTo(makeSuccessResponse('test1', 'Hello Dialog'));
fetchSpy.mockImplementation((url:RequestInfo | URL) => {
const requestUrl = url instanceof Request ? url.url : url.toString();
if (requestUrl.includes('/1/show_dialog')) {
return Promise.resolve(makeSuccessResponse('test1', 'Hello Dialog'));
}
return Promise.reject(new Error(`Unexpected url: ${requestUrl}`));
});
});
it('should handle Turbo Stream dialog response and open the dialog', async () => {
expect(document.querySelector('dialog#test1')).toBeFalsy();
await expectAsync(modalService.show('1')).toBeResolved();
await modalService.show('1');
expect(fetchSpy).toHaveBeenCalledTimes(1);
dialog = await waitForNativeElement<HTMLDialogElement>('dialog#test1');
expect(dialog.textContent).toEqual('Hello Dialog');
expect(dialog.open).toBeTrue();
expect(dialog.open).toBe(true);
dialog.close();
expect(dialog.open).toBeFalse();
expect(dialog.open).toBe(false);
});
});
describe('with an aborted request followed by a successful request', () => {
beforeEach(() => {
fetchSpy
.withArgs(jasmine.stringMatching('/2/show_dialog'), jasmine.any(Object))
.and.returnValues(
Promise.reject(new DOMException('message', 'AbortError')),
Promise.resolve(makeSuccessResponse('test2', 'Noch mal ein Dialog')),
);
.mockReturnValueOnce(Promise.reject(new DOMException('message', 'AbortError')))
.mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test2', 'Noch mal ein Dialog')));
});
it('should handle Turbo Stream dialog response and still open the dialog', async () => {
expect(document.querySelector('dialog#test2')).toBeFalsy();
await expectAsync(modalService.show('2')).toBeRejected();
await expectAsync(modalService.show('2')).toBeResolved();
await expect(modalService.show('2')).rejects.toThrow();
await modalService.show('2');
expect(fetchSpy).toHaveBeenCalledTimes(2);
dialog = await waitForNativeElement<HTMLDialogElement>('dialog#test2');
expect(dialog.textContent).toEqual('Noch mal ein Dialog');
expect(dialog.open).toBeTrue();
expect(dialog.open).toBe(true);
dialog.close();
expect(dialog.open).toBeFalse();
expect(dialog.open).toBe(false);
});
});
describe('with 3 successful requests with the same dialog id', () => {
beforeEach(() => {
fetchSpy
.withArgs(jasmine.stringMatching('/3/show_dialog'), jasmine.any(Object))
.and.returnValues(
Promise.resolve(makeSuccessResponse('test3', '<p>initial content</p>')),
Promise.resolve(makeSuccessResponse('test3', '<p>updated content</p>')),
Promise.resolve(makeSuccessResponse('test3', '<h3>new headline</h3>')),
);
.mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test3', '<p>initial content</p>')))
.mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test3', '<p>updated content</p>')))
.mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test3', '<h3>new headline</h3>')));
});
it('should handle Turbo Stream dialog response and update dialog', async () => {
expect(document.querySelector('dialog#test3')).toBeFalsy();
await expectAsync(modalService.show('3')).toBeResolved();
await modalService.show('3');
expect(fetchSpy).toHaveBeenCalledTimes(1);
dialog = await waitForNativeElement<HTMLDialogElement>('dialog#test3');
expect(dialog.textContent).toEqual('initial content');
expect(dialog.open).toBeTrue();
expect(dialog.open).toBe(true);
await expectAsync(modalService.show('3')).toBeResolved();
await modalService.show('3');
expect(fetchSpy).toHaveBeenCalledTimes(2);
@@ -132,9 +133,9 @@ describe('AttributeHelpTextModalService', () => {
expect(mutation.type).toEqual('characterData');
expect(dialog.textContent).toEqual('updated content');
expect(dialog.open).toBeTrue();
expect(dialog.open).toBe(true);
await expectAsync(modalService.show('3')).toBeResolved();
await modalService.show('3');
expect(fetchSpy).toHaveBeenCalledTimes(3);
@@ -143,11 +144,11 @@ describe('AttributeHelpTextModalService', () => {
expect(mutation.type).toEqual('childList');
expect(dialog.textContent).toEqual('new headline');
expect(dialog.querySelector('h3')).toBeTruthy();
expect(dialog.open).toBeTrue();
expect(dialog.open).toBe(true);
dialog.close();
expect(dialog.open).toBeFalse();
expect(dialog.open).toBe(false);
});
});
});
@@ -170,10 +171,7 @@ function waitForNativeElement<T extends Element>(selector:string):Promise<T> {
});
}
function waitForElementMutation<T extends Element>(
element:T,
predicate:(mutation:MutationRecord) => boolean = () => true,
):Promise<MutationRecord> {
function waitForElementMutation<T extends Element>(element:T, predicate:(mutation:MutationRecord) => boolean = () => true):Promise<MutationRecord> {
return new Promise((resolve) => {
const observer = new MutationObserver((mutationList) => {
const record = mutationList.find(predicate);
@@ -14,12 +14,14 @@ describe('AttributeHelpTextComponent', () => {
let element:DebugElement;
const serviceStub = {};
let modalServiceStub:jasmine.SpyObj<AttributeHelpTextModalService>;
const i18nStub = { t: (_scope:string|string[], _options?:Record<string, any>) => 'Show help text' };
let modalServiceStub:{ show:ReturnType<typeof vi.fn> };
const i18nStub = { t: (_scope:string | string[], _options?:Record<string, any>) => 'Show help text' };
beforeEach(async () => {
modalServiceStub = jasmine.createSpyObj('AttributeHelpTextModalService', ['show']);
modalServiceStub.show.and.resolveTo();
modalServiceStub = {
show: vi.fn().mockName('AttributeHelpTextModalService.show')
};
modalServiceStub.show.mockResolvedValue(undefined);
await TestBed
.configureTestingModule({
@@ -59,7 +61,7 @@ describe('AttributeHelpTextComponent', () => {
const button = element.query(By.css("[role='button']"));
expect(button).toBeTruthy();
expect(button.nativeElement).toHaveClass('spot-link');
expect(button.nativeElement.classList.contains('spot-link')).toBe(true);
});
it('renders a tooltip', () => {
@@ -68,7 +70,7 @@ describe('AttributeHelpTextComponent', () => {
expect(tooltip).toBeTruthy();
expect(tooltip.nativeElement.textContent).toEqual('Show help text');
expect(tooltip.nativeElement.getAttribute('for')).toMatch(/attribute-help-text-component-\d+/);
expect(tooltip.nativeElement.popover).toEqual('manual');
expect(tooltip.nativeElement.getAttribute('popover')).toEqual('manual');
expect(tooltip.nativeElement.dataset.direction).toEqual('sw');
expect(tooltip.nativeElement.dataset.type).toEqual('label');
});
@@ -82,7 +84,7 @@ describe('AttributeHelpTextComponent', () => {
it('applies .help-text--entry class', () => {
const button = element.query(By.css("[role='button']"));
expect(button.nativeElement).toHaveClass('help-text--entry');
expect(button.nativeElement.classList.contains('help-text--entry')).toBe(true);
});
it('applies an ID', () => {
@@ -106,11 +108,13 @@ describe('AttributeHelpTextComponent', () => {
expect(button.nativeElement.ariaDisabled).toEqual('true');
await Promise.resolve();
await modalServiceStub.show.calls.mostRecent().returnValue;
await modalServiceStub.show.mock.results.at(-1)!.value;
await new Promise(resolve => setTimeout(resolve, 0));
fixture.detectChanges();
expect(modalServiceStub.show).toHaveBeenCalledOnceWith('1');
expect(modalServiceStub.show).toHaveBeenCalledTimes(1);
expect(modalServiceStub.show).toHaveBeenCalledWith('1');
expect(button.nativeElement.ariaDisabled).toEqual('false');
});
@@ -128,11 +132,13 @@ describe('AttributeHelpTextComponent', () => {
fixture.detectChanges();
await Promise.resolve();
await modalServiceStub.show.calls.mostRecent().returnValue;
await modalServiceStub.show.mock.results.at(-1)!.value;
await new Promise(resolve => setTimeout(resolve, 0));
fixture.detectChanges();
expect(modalServiceStub.show).toHaveBeenCalledOnceWith('1');
expect(modalServiceStub.show).toHaveBeenCalledTimes(1);
expect(modalServiceStub.show).toHaveBeenCalledWith('1');
expect(button.nativeElement.ariaDisabled).toEqual('false');
});
});
@@ -1,3 +1,4 @@
import type { Mock } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { States } from 'core-app/core/states/states.service';
import { provideHttpClientTesting } from '@angular/common/http/testing';
@@ -12,7 +13,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
describe('autocompleter', () => {
let fixture:ComponentFixture<OpAutocompleterComponent>;
let getOptionsFnSpy:jasmine.Spy;
let getOptionsFnSpy:Mock;
const workPackagesStub = [
{
id: 1,
@@ -60,7 +61,11 @@ describe('autocompleter', () => {
},
];
type WindowWithOpenProject = Omit<Window, 'OpenProject'> & { OpenProject?:{ environment:string } };
type WindowWithOpenProject = Omit<Window, 'OpenProject'> & {
OpenProject?:{
environment:string;
};
};
beforeEach(() => {
(window as WindowWithOpenProject).OpenProject = { environment: 'test' };
@@ -79,10 +84,8 @@ describe('autocompleter', () => {
}).compileComponents();
fixture = TestBed.createComponent(OpAutocompleterComponent);
getOptionsFnSpy = jasmine.createSpy('getOptionsFn').and.callFake((searchTerm:string) => {
return of(workPackagesStub).pipe(
map((wps) => wps.filter((wp) => searchTerm !== '' && wp.subject.includes(searchTerm)))
);
getOptionsFnSpy = vi.fn().mockImplementation((searchTerm:string) => {
return of(workPackagesStub).pipe(map((wps) => wps.filter((wp) => searchTerm !== '' && wp.subject.includes(searchTerm))));
});
fixture.componentInstance.resource = 'work_packages' as TOpAutocompleterResource;
@@ -98,35 +101,36 @@ describe('autocompleter', () => {
});
it('should load the ng-select correctly', () => {
jasmine.clock().install();
vi.useFakeTimers();
try {
fixture.detectChanges();
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
const autocompleter = document.querySelector('.ng-select-container');
expect(document.contains(autocompleter)).toBeTruthy();
} finally {
jasmine.clock().uninstall();
}
finally {
vi.useRealTimers();
}
});
describe('without debounce', () => {
it('should load items', () => {
jasmine.clock().install();
vi.useFakeTimers();
try {
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
fixture.detectChanges();
fixture.componentInstance.ngAfterViewInit();
jasmine.clock().tick(1000);
vi.advanceTimersByTime(1000);
fixture.detectChanges();
const select = fixture.componentInstance.ngSelectInstance;
expect(select.isOpen()).toBeFalse();
expect(select.isOpen()).toBe(false);
select.open();
select.focus();
expect(select.isOpen()).toBeTrue();
expect(select.isOpen()).toBe(true);
expect(select.itemsList.items.length).toEqual(0);
@@ -134,14 +138,14 @@ describe('autocompleter', () => {
const inputElement = inputDebugElement.nativeElement as HTMLInputElement;
fixture.detectChanges();
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
expect(getOptionsFnSpy).toHaveBeenCalledWith('');
inputElement.value = 'Wor';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
expect(getOptionsFnSpy).toHaveBeenCalledWith('Wor');
@@ -152,27 +156,28 @@ describe('autocompleter', () => {
inputElement.value = 'package 2';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
expect(getOptionsFnSpy).toHaveBeenCalledWith('package 2');
fixture.detectChanges();
expect(select.itemsList.items.length).toEqual(1);
} finally {
jasmine.clock().uninstall();
}
finally {
vi.useRealTimers();
}
});
});
describe('work package option rendering', () => {
it('should display formattedId in dropdown options', () => {
jasmine.clock().install();
vi.useFakeTimers();
try {
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
fixture.detectChanges();
fixture.componentInstance.ngAfterViewInit();
jasmine.clock().tick(1000);
vi.advanceTimersByTime(1000);
fixture.detectChanges();
const select = fixture.componentInstance.ngSelectInstance;
@@ -185,7 +190,7 @@ describe('autocompleter', () => {
inputElement.value = 'Wor';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
fixture.detectChanges();
const wpIdElements = document.querySelectorAll('.op-autocompleter--wp-id');
@@ -195,18 +200,19 @@ describe('autocompleter', () => {
const renderedIds = Array.from(wpIdElements).map(el => el.textContent?.trim());
expect(renderedIds).toContain('#1');
} finally {
jasmine.clock().uninstall();
}
finally {
vi.useRealTimers();
}
});
it('should display classic formattedId in selected value label', () => {
jasmine.clock().install();
vi.useFakeTimers();
try {
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
fixture.detectChanges();
fixture.componentInstance.ngAfterViewInit();
jasmine.clock().tick(1000);
vi.advanceTimersByTime(1000);
fixture.detectChanges();
const select = fixture.componentInstance.ngSelectInstance;
@@ -219,7 +225,7 @@ describe('autocompleter', () => {
inputElement.value = 'Wor';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
fixture.detectChanges();
// Select the first item (classic mode: #1)
@@ -232,18 +238,19 @@ describe('autocompleter', () => {
expect(labelElement).toBeTruthy();
expect(labelElement!.textContent).toContain('#1');
expect(labelElement!.textContent).toContain('Workpackage 1');
} finally {
jasmine.clock().uninstall();
}
finally {
vi.useRealTimers();
}
});
it('should display semantic formattedId in selected value label', () => {
jasmine.clock().install();
vi.useFakeTimers();
try {
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
fixture.detectChanges();
fixture.componentInstance.ngAfterViewInit();
jasmine.clock().tick(1000);
vi.advanceTimersByTime(1000);
fixture.detectChanges();
const select = fixture.componentInstance.ngSelectInstance;
@@ -256,7 +263,7 @@ describe('autocompleter', () => {
inputElement.value = 'package 2';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
jasmine.clock().tick(0);
vi.advanceTimersByTime(0);
fixture.detectChanges();
// Select the semantic mode item (PROJ-2)
@@ -270,8 +277,9 @@ describe('autocompleter', () => {
expect(labelElement!.textContent).toContain('PROJ-2');
expect(labelElement!.textContent).not.toContain('#PROJ-2');
expect(labelElement!.textContent).toContain('Workpackage 2');
} finally {
jasmine.clock().uninstall();
}
finally {
vi.useRealTimers();
}
});
});
@@ -290,11 +298,11 @@ describe('autocompleter', () => {
fixture.detectChanges();
const select = fixture.componentInstance.ngSelectInstance;
expect(select.isOpen()).toBeFalse();
expect(select.isOpen()).toBe(false);
select.open();
select.focus();
expect(select.isOpen()).toBeTrue();
expect(select.isOpen()).toBe(true);
expect(select.itemsList.items.length).toEqual(0);
@@ -307,7 +315,7 @@ describe('autocompleter', () => {
await new Promise(resolve => setTimeout(resolve, 100));
expect(getOptionsFnSpy).toHaveBeenCalledWith('');
getOptionsFnSpy.calls.reset();
getOptionsFnSpy.mockClear();
inputElement.value = 'Wor';
inputElement.dispatchEvent(new Event('input'));
@@ -33,12 +33,7 @@ import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing
import { IFieldSchema } from 'core-app/shared/components/fields/field.base';
class TestEditForm extends EditForm<HalResource> {
constructor(
injector:Injector,
private readonly requireVisibleSpy:(fieldName:string) => Promise<void>,
private readonly activateFieldSpy:() => Promise<EditFieldHandler>,
private readonly resetSpy:(fieldName:string, focus?:boolean) => void,
) {
constructor(injector:Injector, private readonly requireVisibleSpy:(fieldName:string) => Promise<void>, private readonly activateFieldSpy:() => Promise<EditFieldHandler>, private readonly resetSpy:(fieldName:string, focus?:boolean) => void) {
super(injector);
}
@@ -61,12 +56,12 @@ class TestEditForm extends EditForm<HalResource> {
describe('EditForm', () => {
it('does not require visibility twice for newly erroneous inactive fields', async () => {
const tick = jasmine.createSpy('tick');
const requireVisible = jasmine.createSpy('requireVisible').and.resolveTo();
const activateField = jasmine.createSpy('activateField').and.resolveTo({} as EditFieldHandler);
const reset = jasmine.createSpy('reset');
const tick = vi.fn();
const requireVisible = vi.fn().mockResolvedValue(undefined);
const activateField = vi.fn().mockResolvedValue({} as EditFieldHandler);
const reset = vi.fn();
const injector = {
get: jasmine.createSpy('get').and.callFake((token:unknown) => {
get: vi.fn().mockImplementation((token:unknown) => {
if (token === ApplicationRef) {
return { tick };
}
@@ -79,25 +74,27 @@ describe('EditForm', () => {
const change = {
inFlight: false,
schema: {
ofProperty: jasmine.createSpy('ofProperty').and.returnValue({
ofProperty: vi.fn().mockReturnValue({
writable: true,
name: 'Foo',
} as IFieldSchema),
},
getForm: jasmine.createSpy('getForm').and.resolveTo(),
getForm: vi.fn().mockResolvedValue(undefined),
};
form.resource = { id: 1 } as unknown as HalResource;
form.halEditing = {
changeFor: jasmine.createSpy('changeFor').and.returnValue(change),
changeFor: vi.fn().mockReturnValue(change),
} as never;
form.halNotification = {
handleRawError: jasmine.createSpy('handleRawError'),
showEditingBlockedError: jasmine.createSpy('showEditingBlockedError'),
handleRawError: vi.fn(),
showEditingBlockedError: vi.fn(),
} as never;
form.errorsPerAttribute = { foo: ['Required'] };
(form as unknown as { setErrorsForFields:(fields:string[]) => void }).setErrorsForFields(['foo']);
(form as unknown as {
setErrorsForFields:(fields:string[]) => void;
}).setErrorsForFields(['foo']);
await Promise.resolve();
await Promise.resolve();
@@ -20,7 +20,7 @@ describe('GlobalEditFormChangesTrackerService', () => {
});
it('should report no changes when empty', () => {
expect(service.thereAreFormsWithUnsavedChanges).toBeFalse();
expect(service.thereAreFormsWithUnsavedChanges).toBe(false);
});
it('should report no changes when one form has no changes', () => {
@@ -28,7 +28,7 @@ describe('GlobalEditFormChangesTrackerService', () => {
service.addToActiveForms(form);
expect(service.thereAreFormsWithUnsavedChanges).toBeFalse();
expect(service.thereAreFormsWithUnsavedChanges).toBe(false);
});
it('should report no changes when multiple forms have no changes', () => {
@@ -40,7 +40,7 @@ describe('GlobalEditFormChangesTrackerService', () => {
service.addToActiveForms(form2);
service.addToActiveForms(form3);
expect(service.thereAreFormsWithUnsavedChanges).toBeFalse();
expect(service.thereAreFormsWithUnsavedChanges).toBe(false);
});
it('should report no changes when the only form with changes is removed', () => {
@@ -49,7 +49,7 @@ describe('GlobalEditFormChangesTrackerService', () => {
service.addToActiveForms(form);
service.removeFromActiveForms(form);
expect(service.thereAreFormsWithUnsavedChanges).toBeFalse();
expect(service.thereAreFormsWithUnsavedChanges).toBe(false);
});
it('should report changes when one form has changes', () => {
@@ -57,7 +57,7 @@ describe('GlobalEditFormChangesTrackerService', () => {
service.addToActiveForms(form);
expect(service.thereAreFormsWithUnsavedChanges).toBeTrue();
expect(service.thereAreFormsWithUnsavedChanges).toBe(true);
});
it('should report forms with changes when multiple form have changes', () => {
@@ -69,13 +69,13 @@ describe('GlobalEditFormChangesTrackerService', () => {
service.addToActiveForms(form2);
service.addToActiveForms(form3);
expect(service.thereAreFormsWithUnsavedChanges).toBeTrue();
expect(service.thereAreFormsWithUnsavedChanges).toBe(true);
});
it('should call thereAreFormsWithUnsavedChangesSpy on beforeunload', () => {
const thereAreFormsWithUnsavedChangesSpy = spyOnProperty(service, 'thereAreFormsWithUnsavedChanges', 'get');
const thereAreFormsWithUnsavedChangesSpy = vi.spyOn(service, 'thereAreFormsWithUnsavedChanges', 'get');
window.onbeforeunload = jasmine.createSpy();
window.onbeforeunload = vi.fn();
window.dispatchEvent(new Event('beforeunload'));
@@ -57,6 +57,10 @@ describe('DynamicIconDirective', () => {
svgElement = fixture.nativeElement.querySelector('svg');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create', () => {
component.iconName = 'star';
fixture.detectChanges();
@@ -151,8 +155,8 @@ describe('DynamicIconDirective', () => {
});
it('should warn when rendering unknown icon', () => {
spyOn(console, 'warn');
vi.spyOn(console, 'warn');
component.iconName = 'unknown-icon';
fixture.detectChanges();
@@ -160,15 +164,15 @@ describe('DynamicIconDirective', () => {
});
it('should not render anything for unknown icon', () => {
spyOn(console, 'warn');
vi.spyOn(console, 'warn');
component.iconName = 'unknown-icon';
fixture.detectChanges();
// Should not have set viewBox or other attributes
expect(svgElement.getAttribute('viewBox')).toBeNull();
expect(svgElement.getAttribute('fill')).toBeNull();
// Should not have any paths
const paths = svgElement.querySelectorAll('path');
@@ -176,28 +180,28 @@ describe('DynamicIconDirective', () => {
});
it('should handle empty icon name', () => {
spyOn(console, 'warn');
vi.spyOn(console, 'warn');
component.iconName = '';
fixture.detectChanges();
// Should not warn or render anything
expect(console.warn).not.toHaveBeenCalled();
expect(svgElement.getAttribute('viewBox')).toBeNull();
const paths = svgElement.querySelectorAll('path');
expect(paths.length).toBe(0);
});
it('should only render once when loaded', () => {
spyOn(console, 'warn');
vi.spyOn(console, 'warn');
const directive = fixture.debugElement.children[0].injector.get(DynamicIconDirective);
spyOn(directive as any, 'renderIcon').and.callThrough();
vi.spyOn(directive as any, 'renderIcon');
component.iconName = 'star';
fixture.detectChanges();
// Change icon name after initial load - should not re-render
component.iconName = 'x';
fixture.detectChanges();
@@ -32,19 +32,12 @@ import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types';
import { SortFilesPipe } from 'core-app/shared/components/storages/pipes/sort-files.pipe';
import { StorageFilesResourceService } from 'core-app/core/state/storage-files/storage-files.service';
import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model';
import {
FilePickerBaseModalComponent,
} from 'core-app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component';
import { FilePickerBaseModalComponent, } from 'core-app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component';
import { StorageFileListItem } from 'core-app/shared/components/storages/storage-file-list-item/storage-file-list-item';
import type { Mock } from 'vitest';
class TestFilePickerBaseModalComponent extends FilePickerBaseModalComponent {
constructor(
locals:OpModalLocalsMap,
elementRef:ElementRef,
cdRef:ChangeDetectorRef,
sortFilesPipe:SortFilesPipe,
storageFilesResourceService:StorageFilesResourceService,
) {
constructor(locals:OpModalLocalsMap, elementRef:ElementRef, cdRef:ChangeDetectorRef, sortFilesPipe:SortFilesPipe, storageFilesResourceService:StorageFilesResourceService) {
super(locals, elementRef, cdRef, sortFilesPipe, storageFilesResourceService);
}
@@ -59,10 +52,10 @@ class TestFilePickerBaseModalComponent extends FilePickerBaseModalComponent {
describe('FilePickerBaseModalComponent', () => {
interface Spies {
detectChanges:jasmine.Spy;
close:jasmine.Spy;
files:jasmine.Spy;
reset:jasmine.Spy;
detectChanges:Mock;
close:Mock;
files:Mock;
reset:Mock;
}
function buildComponent(spies:Spies) {
@@ -84,13 +77,7 @@ describe('FilePickerBaseModalComponent', () => {
files: spies.files,
reset: spies.reset,
} as unknown as StorageFilesResourceService;
const component = new TestFilePickerBaseModalComponent(
locals,
elementRef,
cdRef,
sortFilesPipe,
storageFilesResourceService,
);
const component = new TestFilePickerBaseModalComponent(locals, elementRef, cdRef, sortFilesPipe, storageFilesResourceService);
component.ngOnInit();
@@ -98,15 +85,15 @@ describe('FilePickerBaseModalComponent', () => {
}
it('cancels pending directory loading on destroy', () => {
const teardown = jasmine.createSpy('teardown');
const teardown = vi.fn();
const files$ = new Observable(() => teardown);
const directory = { location: '/folder', mimeType: 'application/x-op-directory' } as IStorageFile;
const files = jasmine.createSpy('files').and.returnValue(files$);
const files = vi.fn().mockReturnValue(files$);
const { component } = buildComponent({
detectChanges: jasmine.createSpy('detectChanges'),
close: jasmine.createSpy('close'),
detectChanges: vi.fn(),
close: vi.fn(),
files,
reset: jasmine.createSpy('reset'),
reset: vi.fn(),
});
component.loadDirectory(directory);
@@ -121,15 +108,15 @@ describe('FilePickerBaseModalComponent', () => {
it('does not report directory loading errors as unhandled async exceptions', async () => {
const previousUnhandledError = config.onUnhandledError;
const onUnhandledError = jasmine.createSpy('onUnhandledError');
const onUnhandledError = vi.fn();
const files$ = throwError(() => new Error('boom'));
const detectChanges = jasmine.createSpy('detectChanges');
const detectChanges = vi.fn();
const directory = { location: '/folder', mimeType: 'application/x-op-directory' } as IStorageFile;
const { component } = buildComponent({
detectChanges,
close: jasmine.createSpy('close'),
files: jasmine.createSpy('files').and.returnValue(files$),
reset: jasmine.createSpy('reset'),
close: vi.fn(),
files: vi.fn().mockReturnValue(files$),
reset: vi.fn(),
});
config.onUnhandledError = onUnhandledError;
@@ -26,12 +26,7 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import {
toggleElement,
toggleElementByClass,
toggleElementByVisibility,
attributeTokenList,
} from './dom-helpers';
import { toggleElement, toggleElementByClass, toggleElementByVisibility, attributeTokenList, } from './dom-helpers';
describe('dom-helpers', () => {
describe('toggleElement', () => {
@@ -197,13 +192,13 @@ describe('dom-helpers', () => {
it('mimics DOMTokenList over an attribute', () => {
const list = attributeTokenList(el, attr);
expect(list.contains('a')).toBeFalse();
expect(list.contains('a')).toBe(false);
expect(el.getAttribute(attr)).toBeNull();
list.add('a', 'b');
expect(list.contains('a')).toBeTrue();
expect(list.contains('b')).toBeTrue();
expect(list.contains('a')).toBe(true);
expect(list.contains('b')).toBe(true);
expect(el.getAttribute(attr)).toBe('a b');
// adding duplicates is idempotent
@@ -214,25 +209,25 @@ describe('dom-helpers', () => {
// remove works
list.remove('a');
expect(list.contains('a')).toBeFalse();
expect(list.contains('a')).toBe(false);
expect(el.getAttribute(attr)).toBe('b');
// toggle without force flips presence and returns the new state
expect(list.toggle('b')).toBeFalse(); // removed
expect(list.toggle('b')).toBe(false); // removed
expect(el.getAttribute(attr)).toBe('');
expect(list.toggle('c')).toBeTrue(); // added
expect(list.toggle('c')).toBe(true); // added
expect(el.getAttribute(attr)).toBe('c');
// forced toggle honors force
expect(list.toggle('x', true)).toBeTrue();
expect(list.contains('x')).toBeTrue();
expect(list.toggle('x', false)).toBeFalse();
expect(list.contains('x')).toBeFalse();
expect(list.toggle('x', true)).toBe(true);
expect(list.contains('x')).toBe(true);
expect(list.toggle('x', false)).toBe(false);
expect(list.contains('x')).toBe(false);
// replace swaps tokens and returns true when old exists
expect(list.replace('c', 'd')).toBeTrue();
expect(list.contains('c')).toBeFalse();
expect(list.contains('d')).toBeTrue();
expect(list.replace('c', 'd')).toBe(true);
expect(list.contains('c')).toBe(false);
expect(list.contains('d')).toBe(true);
// iterator yields tokens
expect([...list]).toEqual(['d']);
@@ -241,15 +236,15 @@ describe('dom-helpers', () => {
list.value = 'e f';
expect(el.getAttribute(attr)).toBe('e f');
expect(list.contains('e')).toBeTrue();
expect(list.contains('f')).toBeTrue();
expect(list.contains('e')).toBe(true);
expect(list.contains('f')).toBe(true);
});
it('replace on non-existent token returns false and does not change tokens', () => {
const list = attributeTokenList(el, attr);
list.add('a', 'b');
expect(list.replace('x', 'y')).toBeFalse();
expect(list.replace('x', 'y')).toBe(false);
expect([...list]).toEqual(['a', 'b']);
expect(el.getAttribute(attr)).toBe('a b');
});
@@ -326,7 +321,8 @@ describe('dom-helpers', () => {
const tokens:string[] = [];
for (let i = 0; i < list.length; i++) {
const token = list.item(i);
if (token) tokens.push(token);
if (token)
tokens.push(token);
}
expect(tokens).toEqual(['alpha', 'beta', 'gamma']);
@@ -54,19 +54,19 @@ describe('CheckAllController', () => {
});
const checkAllTemplate = `
<div data-controller="check-all" data-check-all-checkable-outlet="#checkables">
<button id="check-all" data-action="check-all#checkAll">Check all</button>
<button id="uncheck-all" data-action="check-all#uncheckAll">Uncheck all</button>
</div>
`;
<div data-controller="check-all" data-check-all-checkable-outlet="#checkables">
<button id="check-all" data-action="check-all#checkAll">Check all</button>
<button id="uncheck-all" data-action="check-all#uncheckAll">Uncheck all</button>
</div>
`;
const checkableTemplate = `
<div id="checkables" data-controller="checkable">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
</div>
`;
<div id="checkables" data-controller="checkable">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
</div>
`;
function appendTemplate(html:string) {
const template = document.createElement('template');
@@ -98,18 +98,18 @@ describe('CheckAllController', () => {
it('toggles checkboxes', async () => {
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'));
expect(inputs).toHaveSize(3);
expect(inputs.every((i) => !i.checked)).toBeTrue();
expect(inputs).toHaveLength(3);
expect(inputs.every((i) => !i.checked)).toBe(true);
document.getElementById('check-all')!.click();
await nextFrame();
expect(inputs.every((i) => i.checked)).toBeTrue();
expect(inputs.every((i) => i.checked)).toBe(true);
document.getElementById('uncheck-all')!.click();
await nextFrame();
expect(inputs.every((i) => !i.checked)).toBeTrue();
expect(inputs.every((i) => !i.checked)).toBe(true);
});
it('applies aria-controls for connected outlet', () => {
@@ -53,7 +53,7 @@ describe('CheckableController', () => {
it('checks all when none are checked', () => {
controller.toggleAll(new Event('click'));
expect(inputs.every((i) => i.checked)).toBeTrue();
expect(inputs.every((i) => i.checked)).toBe(true);
});
it('checks all when some are checked (mixed state)', () => {
@@ -61,7 +61,7 @@ describe('CheckableController', () => {
controller.toggleAll(new Event('click'));
expect(inputs.every((i) => i.checked)).toBeTrue();
expect(inputs.every((i) => i.checked)).toBe(true);
});
it('unchecks all when all are checked', () => {
@@ -69,17 +69,17 @@ describe('CheckableController', () => {
controller.toggleAll(new Event('click'));
expect(inputs.every((i) => !i.checked)).toBeTrue();
expect(inputs.every((i) => !i.checked)).toBe(true);
});
it('dispatches input event', () => {
const dispatchSpy = spyOn(inputs[0], 'dispatchEvent').and.callThrough();
const dispatchSpy = vi.spyOn(inputs[0], 'dispatchEvent');
controller.toggleAll(new Event('click'));
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const eventArg = dispatchSpy.calls.mostRecent().args[0];
const eventArg = vi.mocked(dispatchSpy).mock.lastCall![0];
expect(eventArg.type).toBe('input');
expect(eventArg.bubbles).toBe(false);
@@ -87,7 +87,7 @@ describe('CheckableController', () => {
});
it('checkAll calls toggleChecked(true)', () => {
spyOn(controller, 'toggleChecked').and.callFake(() => {});
vi.spyOn(controller, 'toggleChecked').mockImplementation(() => { });
controller.checkAll(new Event('click'));
@@ -95,7 +95,7 @@ describe('CheckableController', () => {
});
it('uncheckAll calls toggleChecked(false)', () => {
spyOn(controller, 'toggleChecked').and.callFake(() => {});
vi.spyOn(controller, 'toggleChecked').mockImplementation(() => { });
controller.uncheckAll(new Event('click'));
@@ -139,9 +139,9 @@ describe('CheckableController', () => {
controller.toggleSelection(event);
// Only admin checkboxes should be checked
expect(inputs[0].checked).toBeTrue();
expect(inputs[1].checked).toBeFalse();
expect(inputs[2].checked).toBeTrue();
expect(inputs[0].checked).toBe(true);
expect(inputs[1].checked).toBe(false);
expect(inputs[2].checked).toBe(true);
});
it('unchecks all matching checkboxes when all are checked', () => {
@@ -157,9 +157,9 @@ describe('CheckableController', () => {
controller.toggleSelection(event);
// Only admin checkboxes should be unchecked
expect(inputs[0].checked).toBeFalse();
expect(inputs[1].checked).toBeTrue(); // member stays checked
expect(inputs[2].checked).toBeFalse();
expect(inputs[0].checked).toBe(false);
expect(inputs[1].checked).toBe(true); // member stays checked
expect(inputs[2].checked).toBe(false);
});
it('works with numeric value params (converted to string)', () => {
@@ -172,9 +172,9 @@ describe('CheckableController', () => {
controller.toggleSelection(event);
expect(inputs[0].checked).toBeTrue();
expect(inputs[1].checked).toBeFalse();
expect(inputs[2].checked).toBeTrue();
expect(inputs[0].checked).toBe(true);
expect(inputs[1].checked).toBe(false);
expect(inputs[2].checked).toBe(true);
});
it('works with boolean value params (converted to string)', () => {
@@ -187,9 +187,9 @@ describe('CheckableController', () => {
controller.toggleSelection(event);
expect(inputs[0].checked).toBeTrue();
expect(inputs[1].checked).toBeFalse();
expect(inputs[2].checked).toBeTrue();
expect(inputs[0].checked).toBe(true);
expect(inputs[1].checked).toBe(false);
expect(inputs[2].checked).toBe(true);
});
});
});
@@ -37,7 +37,7 @@ describe('GenericDragAndDropController', () => {
controller = Object.create(GenericDragAndDropController.prototype) as GenericDragAndDropController;
});
function setValue(name:'handleValue'|'handleSelectorValue', value:boolean|string) {
function setValue(name:'handleValue' | 'handleSelectorValue', value:boolean | string) {
Object.defineProperty(controller, name, { value, configurable: true });
}
@@ -51,29 +51,20 @@ describe('GenericDragAndDropController', () => {
return row;
}
function callCanStartDrag(el:Element|null|undefined, handle:Element|null|undefined):boolean {
const canStartDrag = Reflect.get(controller, 'canStartDrag') as (
this:GenericDragAndDropController,
el:Element|null|undefined,
handle:Element|null|undefined
) => boolean;
function callCanStartDrag(el:Element | null | undefined, handle:Element | null | undefined):boolean {
const canStartDrag = Reflect.get(controller, 'canStartDrag') as (this:GenericDragAndDropController, el:Element | null | undefined, handle:Element | null | undefined) => boolean;
return canStartDrag.call(controller, el, handle);
}
function callAriaPressedTarget(el:Element):Element|null {
const ariaPressedTarget = Reflect.get(controller, 'ariaPressedTarget') as (
this:GenericDragAndDropController,
el:Element
) => Element|null;
function callAriaPressedTarget(el:Element):Element | null {
const ariaPressedTarget = Reflect.get(controller, 'ariaPressedTarget') as (this:GenericDragAndDropController, el:Element) => Element | null;
return ariaPressedTarget.call(controller, el);
}
function callResolveMirrorContainer():Element {
const resolveMirrorContainer = Reflect.get(controller, 'resolveMirrorContainer') as (
this:GenericDragAndDropController
) => Element;
const resolveMirrorContainer = Reflect.get(controller, 'resolveMirrorContainer') as (this:GenericDragAndDropController) => Element;
return resolveMirrorContainer.call(controller);
}
@@ -85,7 +76,7 @@ describe('GenericDragAndDropController', () => {
setValue('handleValue', false);
setValue('handleSelectorValue', '.DragHandle');
expect(callCanStartDrag(row, row)).toBeTrue();
expect(callCanStartDrag(row, row)).toBe(true);
});
it('rejects rows that are not draggable in handle-less mode', () => {
@@ -96,7 +87,7 @@ describe('GenericDragAndDropController', () => {
setValue('handleValue', false);
setValue('handleSelectorValue', '.DragHandle');
expect(callCanStartDrag(row, row)).toBeFalse();
expect(callCanStartDrag(row, row)).toBe(false);
});
it('rejects empty placeholder rows in handle-less mode', () => {
@@ -106,7 +97,7 @@ describe('GenericDragAndDropController', () => {
setValue('handleValue', false);
setValue('handleSelectorValue', '.DragHandle');
expect(callCanStartDrag(row, row)).toBeFalse();
expect(callCanStartDrag(row, row)).toBe(false);
});
it('rejects interactive descendants in handle-less mode', () => {
@@ -117,7 +108,7 @@ describe('GenericDragAndDropController', () => {
setValue('handleValue', false);
setValue('handleSelectorValue', '.DragHandle');
expect(callCanStartDrag(row, button)).toBeFalse();
expect(callCanStartDrag(row, button)).toBe(false);
});
it('allows drag handles in handle mode', () => {
@@ -129,7 +120,7 @@ describe('GenericDragAndDropController', () => {
setValue('handleValue', true);
setValue('handleSelectorValue', '.DragHandle');
expect(callCanStartDrag(row, handle)).toBeTrue();
expect(callCanStartDrag(row, handle)).toBe(true);
});
});
@@ -68,17 +68,17 @@ describe('TruncationController', () => {
});
const truncationTemplate = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
This is a very long text that should be truncated when it exceeds the container width
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
This is a very long text that should be truncated when it exceeds the container width
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
function appendTemplate(html:string) {
const template = document.createElement('template');
@@ -93,10 +93,7 @@ describe('TruncationController', () => {
});
it('connects successfully', () => {
const controller = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
expect(controller).toBeDefined();
});
@@ -111,17 +108,14 @@ describe('TruncationController', () => {
it('adds Truncate--expanded class when expanded value is true', async () => {
const truncateEl = document.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
const controller:any = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
controller.expandedValue = true;
await nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true);
});
});
@@ -135,20 +129,20 @@ describe('TruncationController', () => {
const button = document.querySelector<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
const truncateEl = document.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
expect(button.getAttribute('aria-expanded')).toBe('false');
button.click();
await nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true);
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(button.getAttribute('aria-label')).toBe('Collapse text');
button.click();
await nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('aria-label')).toBe('Expand text');
});
@@ -162,10 +156,7 @@ describe('TruncationController', () => {
it('updates aria-label when expanded', async () => {
const button = document.querySelector<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
expect(button.getAttribute('aria-label')).toBe('Expand text');
@@ -177,10 +168,7 @@ describe('TruncationController', () => {
it('updates aria-expanded attribute', async () => {
const button = document.querySelector<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
expect(button.getAttribute('aria-expanded')).toBe('false');
@@ -192,22 +180,19 @@ describe('TruncationController', () => {
it('toggles Truncate--expanded class', async () => {
const truncateEl = document.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
controller.expandedValue = true;
await nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true);
controller.expandedValue = false;
await nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
});
});
@@ -221,17 +206,17 @@ describe('TruncationController', () => {
it('hides expander when content is not truncated', async () => {
const shortTextTemplate = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 500px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Short text
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 500px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Short text
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(shortTextTemplate);
await waitForResize();
@@ -239,58 +224,60 @@ describe('TruncationController', () => {
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
// When content is not truncated, expander should be hidden
expect(expander.hidden).toBeTrue();
expect(expander.hidden).toBe(true);
});
it('shows expander when content is truncated', async () => {
const longTextTemplate = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 50px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap; width: 300px;">
This is a very long text that should definitely be truncated
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 50px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap; width: 300px;">
This is a very long text that should definitely be truncated
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(longTextTemplate);
const truncateText = document.querySelector<HTMLElement>('.Truncate-text')!;
Object.defineProperty(truncateText, 'scrollWidth', { value: 300, configurable: true });
Object.defineProperty(truncateText, 'clientWidth', { value: 50, configurable: true });
await waitForResize();
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
// When content is truncated, expander should be visible
expect(expander.hidden).toBeFalse();
expect(expander.hidden).toBe(false);
});
});
describe('resize() method', () => {
it('calls update() when resize is triggered', async () => {
const template = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Test text
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Test text
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(template);
await nextFrame();
const controller:any = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
// Spy on the private update method to verify resize() calls it
const updateSpy = spyOn<any>(controller, 'update').and.callThrough();
const updateSpy = vi.spyOn(controller, 'update');
controller.resize();
@@ -299,25 +286,22 @@ describe('TruncationController', () => {
it('updates expander visibility when content dimensions change', async () => {
const template = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Test
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Test
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(template);
await nextFrame();
const controller:any = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
const truncateText = document.querySelector<HTMLElement>('.Truncate-text')!;
@@ -330,21 +314,21 @@ describe('TruncationController', () => {
Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 });
controller.resize();
expect(expander.hidden).toBeTrue();
expect(expander.hidden).toBe(true);
// Simulate truncated: scrollWidth > clientWidth
Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 200 });
Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 });
controller.resize();
expect(expander.hidden).toBeFalse();
expect(expander.hidden).toBe(false);
// Simulate not truncated again
Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 50 });
Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 50 });
controller.resize();
expect(expander.hidden).toBeTrue();
expect(expander.hidden).toBe(true);
// Restore original descriptors
if (originalScrollWidth) {
@@ -357,38 +341,35 @@ describe('TruncationController', () => {
it('keeps expander visible when expanded even if not truncated', async () => {
const template = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Short
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Short
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(template);
await nextFrame();
const controller:any = Stimulus.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="truncation"]')!,
'truncation',
);
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
// Initially short text, expander should be hidden
controller.resize();
expect(expander.hidden).toBeTrue();
expect(expander.hidden).toBe(true);
// Expand the text
controller.expandedValue = true;
await nextFrame();
// When expanded, expander should remain visible even if not truncated
expect(expander.hidden).toBeFalse();
expect(expander.hidden).toBe(false);
});
});
+40
View File
@@ -1,4 +1,5 @@
import { I18n } from 'i18n-js';
import lodash from 'lodash';
import { registerDialogStreamAction } from 'core-turbo/dialog-stream-action';
registerDialogStreamAction();
@@ -7,3 +8,42 @@ registerDialogStreamAction();
(window as any).global = window;
window.I18n = new I18n();
// Production code expects `_` to be available globally (set in init-vendors.ts).
// Mirror that here so production modules pulled in by spec compilation can run.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any)._ = lodash;
// jsdom does not implement CSS.escape; production helpers (e.g. getMetaElement)
// call it unconditionally.
if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).CSS = (globalThis as any).CSS || {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).CSS.escape = (value:string) => String(value).replace(/[^a-zA-Z0-9_\-]/g, (ch) => `\\${ch}`);
}
// jsdom does not implement ResizeObserver.
if (typeof (globalThis as any).ResizeObserver === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
// jsdom does not implement HTMLDialogElement.showModal/close.
if (typeof HTMLDialogElement !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proto = HTMLDialogElement.prototype as any;
if (typeof proto.showModal !== 'function') {
proto.showModal = function showModal() { this.open = true; };
}
if (typeof proto.show !== 'function') {
proto.show = function show() { this.open = true; };
}
if (typeof proto.close !== 'function') {
proto.close = function close() { this.open = false; };
}
}
+4 -1
View File
@@ -8,10 +8,13 @@
},
"files": [
"src/test-setup.ts",
"src/test-providers.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
"src/**/*.d.ts",
"src/main.ts",
"src/app/**/*.module.ts"
]
}
@@ -1,23 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { GitActionsMenuComponent } from "./git-actions-menu.component";
import { GitActionsService } from "../git-actions/git-actions.service";
import { By } from "@angular/platform-browser";
import { OpIconComponent } from "core-app/shared/components/icon/icon.component";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { OpContextMenuLocalsToken } from "core-app/shared/components/op-context-menu/op-context-menu.types";
import { GitActionsMenuComponent } from './git-actions-menu.component';
import { GitActionsService } from '../git-actions/git-actions.service';
import { By } from '@angular/platform-browser';
import { OpIconComponent } from 'core-app/shared/components/icon/icon.component';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { OpContextMenuLocalsToken } from 'core-app/shared/components/op-context-menu/op-context-menu.types';
describe('GitActionsMenuComponent', () => {
let component:GitActionsMenuComponent;
let fixture:ComponentFixture<GitActionsMenuComponent>;
let element:DebugElement;
let gitActionsService:jasmine.SpyObj<GitActionsService>;
let gitActionsService:{ gitCommand:ReturnType<typeof vi.fn>; commitMessage:ReturnType<typeof vi.fn>; commitMessageDisplayText:ReturnType<typeof vi.fn>; branchName:ReturnType<typeof vi.fn> };
const I18nServiceStub = {
t: function(key:string) {
t: function (key:string) {
return 'test translation';
}
}
};
const localsStub = {
workPackage: 1,
items: [
@@ -28,23 +28,28 @@ describe('GitActionsMenuComponent', () => {
linkText: 'linkText',
}
]
}
};
beforeEach(async () => {
const gitActionsServiceSpy = jasmine.createSpyObj('GitActionsService', ['gitCommand', 'commitMessage', 'commitMessageDisplayText', 'branchName']);
const gitActionsServiceSpy = {
gitCommand: vi.fn().mockName('GitActionsService.gitCommand'),
commitMessage: vi.fn().mockName('GitActionsService.commitMessage'),
commitMessageDisplayText: vi.fn().mockName('GitActionsService.commitMessageDisplayText'),
branchName: vi.fn().mockName('GitActionsService.branchName')
};
await TestBed
.configureTestingModule({
declarations: [
GitActionsMenuComponent,
OpIconComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: OpContextMenuLocalsToken, useValue: localsStub },
{ provide: GitActionsService, useValue: gitActionsServiceSpy },
],
})
declarations: [
GitActionsMenuComponent,
OpIconComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: OpContextMenuLocalsToken, useValue: localsStub },
{ provide: GitActionsService, useValue: gitActionsServiceSpy },
],
})
.compileComponents();
});
@@ -52,7 +57,7 @@ describe('GitActionsMenuComponent', () => {
fixture = TestBed.createComponent(GitActionsMenuComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
gitActionsService = fixture.debugElement.injector.get(GitActionsService) as jasmine.SpyObj<GitActionsService>;
gitActionsService = fixture.debugElement.injector.get(GitActionsService) as unknown as typeof gitActionsService;
fixture.detectChanges();
});
@@ -64,7 +69,7 @@ describe('GitActionsMenuComponent', () => {
it('should generate the branch name on copy button click', () => {
const copyButton = fixture.debugElement.query(By.css('.copy-button')).nativeElement;
gitActionsService.branchName.and.returnValue('test branch');
gitActionsService.branchName.mockReturnValue('test branch');
copyButton.click();
fixture.detectChanges();
@@ -27,10 +27,10 @@
//++
import { GitActionsService } from './git-actions.service';
import { WorkPackageResource } from "core-app/features/hal/resources/work-package-resource";
import { PathHelperService } from "core-app/core/path-helper/path-helper.service";
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
describe('GitActionsService', function() {
describe('GitActionsService', function () {
let service:GitActionsService;
const createWorkPackage = (overrides = {}) => {
@@ -44,7 +44,7 @@ describe('GitActionsService', function() {
pathHelper: new PathHelperService()
};
const workPackage = { ...defaults, ...overrides };
return(workPackage as WorkPackageResource);
return (workPackage as WorkPackageResource);
};
beforeEach(() => {
@@ -54,21 +54,20 @@ describe('GitActionsService', function() {
it('produces a branch name, commit message, and a git command', () => {
const wp = createWorkPackage();
expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t');
expect(service.commitMessage(wp)).toEqual(
`[#42] Find the question, or don't
const origin = window.location.origin;
http://localhost:9876/wp/42`
);
expect(service.gitCommand(wp)).toEqual(
`git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m '[#42] Find the question, or don'\\''t' -m 'http://localhost:9876/wp/42'`
);
expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t');
expect(service.commitMessage(wp)).toEqual(`[#42] Find the question, or don't
${origin}/wp/42`);
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m '[#42] Find the question, or don'\\''t' -m '${origin}/wp/42'`);
});
it('shell-escapes output for the git-command', () => {
const wp = createWorkPackage({ subject: "' && rm -rf / #" });
expect(service.gitCommand(wp)).toEqual(
`git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m '[#42] '\\'' && rm -rf / #' -m 'http://localhost:9876/wp/42'`
);
const origin = window.location.origin;
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m '[#42] '\\'' && rm -rf / #' -m '${origin}/wp/42'`);
});
});
@@ -1,37 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { GitHubTabComponent } from "core-app/features/plugins/linked/openproject-github_integration/github-tab/github-tab.component";
import { TabPrsComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component";
import { TabHeaderComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-header/tab-header.component";
import { By } from "@angular/platform-browser";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { PathHelperService } from "core-app/core/path-helper/path-helper.service";
import { Component, DebugElement, Input } from '@angular/core';
import { GitHubTabComponent } from 'core-app/features/plugins/linked/openproject-github_integration/github-tab/github-tab.component';
import { By } from '@angular/platform-browser';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
@Component({
selector: 'tab-header',
template: '',
standalone: false,
})
class TabHeaderStubComponent {
@Input() workPackage:WorkPackageResource;
}
@Component({
selector: 'op-tab-prs',
template: '',
standalone: false,
})
class TabPrsStubComponent {
@Input() workPackage:WorkPackageResource;
}
describe('GitHubTabComponent.', () => {
let component:GitHubTabComponent;
let fixture:ComponentFixture<GitHubTabComponent>;
let element:DebugElement;
const workPackage = { id: 'testId' } as WorkPackageResource;
const apiV3Base = 'http://www.openproject.com/api/v3/';
const IPathHelperServiceStub = { api:{ v3: { apiV3Base }}};
const IPathHelperServiceStub = { api: { v3: { apiV3Base } } };
const I18nServiceStub = {
t: function(key:string) {
t: function (key:string) {
return 'test translation';
}
}
};
beforeEach(async () => {
await TestBed
.configureTestingModule({
declarations: [
TabPrsComponent,
TabHeaderComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: PathHelperService, useValue: IPathHelperServiceStub },
],
})
declarations: [
GitHubTabComponent,
TabHeaderStubComponent,
TabPrsStubComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: PathHelperService, useValue: IPathHelperServiceStub },
],
})
.compileComponents();
});
@@ -39,6 +57,7 @@ describe('GitHubTabComponent.', () => {
fixture = TestBed.createComponent(GitHubTabComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
component.workPackage = workPackage;
fixture.detectChanges();
});
@@ -54,4 +73,12 @@ describe('GitHubTabComponent.', () => {
expect(tabHeader).toBeTruthy();
expect(tabPrs).toBeTruthy();
});
it('should pass the work package to the child components', () => {
const tabHeader = fixture.debugElement.query(By.directive(TabHeaderStubComponent));
const tabPrs = fixture.debugElement.query(By.directive(TabPrsStubComponent));
expect(tabHeader.componentInstance.workPackage).toBe(workPackage);
expect(tabPrs.componentInstance.workPackage).toBe(workPackage);
});
});
@@ -1,18 +1,19 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement, Input } from '@angular/core';
import { By } from "@angular/platform-browser";
import { PullRequestComponent } from "./pull-request.component";
import { By } from '@angular/platform-browser';
import { PullRequestComponent } from './pull-request.component';
import { OpIconComponent } from 'core-app/shared/components/icon/icon.component';
import { IGithubCheckRunResource, IGithubPullRequest, IGithubUserResource } from '../state/github-pull-request.model';
import { PullRequestStateComponent } from './pull-request-state.component';
@Component({
selector: 'op-date-time',
template: ``,
template: '',
standalone: false,
})
class OpDateTimeComponent {
@Input('dateTimeValue') dateTimeValue:any;
@Input()
dateTimeValue:any;
}
describe('PullRequestComponent', () => {
@@ -39,10 +40,10 @@ describe('PullRequestComponent', () => {
const pullRequestStub:IGithubPullRequest = {
id: 3,
additionsCount: 3,
body:{
body: {
format: '',
raw: 'test raw',
html:'<p>test</p>',
html: '<p>test</p>',
},
changedFilesCount: 3,
commentsCount: 3,
@@ -74,20 +75,20 @@ describe('PullRequestComponent', () => {
_embedded: {
githubUser,
mergedBy: githubUser,
checkRuns:[checkRun],
checkRuns: [checkRun],
}
}
};
beforeEach(async () => {
await TestBed
.configureTestingModule({
declarations: [
PullRequestComponent,
OpDateTimeComponent,
OpIconComponent,
PullRequestStateComponent,
],
})
declarations: [
PullRequestComponent,
OpDateTimeComponent,
OpIconComponent,
PullRequestStateComponent,
],
})
.compileComponents();
});
@@ -1,11 +1,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { TabHeaderComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-header/tab-header.component";
import { By } from "@angular/platform-browser";
import { OpIconComponent } from "core-app/shared/components/icon/icon.component";
import { GitActionsMenuDirective } from "core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive";
import { OPContextMenuService } from "core-app/shared/components/op-context-menu/op-context-menu.service";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { TabHeaderComponent } from 'core-app/features/plugins/linked/openproject-github_integration/tab-header/tab-header.component';
import { By } from '@angular/platform-browser';
import { OpIconComponent } from 'core-app/shared/components/icon/icon.component';
import { GitActionsMenuDirective } from 'core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive';
import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
describe('TabHeaderComponent', () => {
@@ -13,29 +13,31 @@ describe('TabHeaderComponent', () => {
let fixture:ComponentFixture<TabHeaderComponent>;
let element:DebugElement;
const I18nServiceStub = {
t: function(key:string) {
t: function (key:string) {
return 'test translation';
}
}
let oPContextMenuService:jasmine.SpyObj<OPContextMenuService>;
};
let oPContextMenuService:{ show:ReturnType<typeof vi.fn> };
// @ts-ignore
window.Mousetrap = () => () => {};
window.Mousetrap = () => () => { };
beforeEach(async () => {
const oPContextMenuServiceSpy = jasmine.createSpyObj('OPContextMenuService', ['show']);
const oPContextMenuServiceSpy = {
show: vi.fn().mockName('OPContextMenuService.show')
};
await TestBed
.configureTestingModule({
declarations: [
TabHeaderComponent,
OpIconComponent,
GitActionsMenuDirective,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: OPContextMenuService, useValue: oPContextMenuServiceSpy },
],
})
declarations: [
TabHeaderComponent,
OpIconComponent,
GitActionsMenuDirective,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: OPContextMenuService, useValue: oPContextMenuServiceSpy },
],
})
.compileComponents();
});
@@ -43,7 +45,7 @@ describe('TabHeaderComponent', () => {
fixture = TestBed.createComponent(TabHeaderComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
oPContextMenuService = fixture.debugElement.injector.get(OPContextMenuService) as jasmine.SpyObj<OPContextMenuService>;
oPContextMenuService = fixture.debugElement.injector.get(OPContextMenuService) as unknown as typeof oPContextMenuService;
fixture.detectChanges();
});
@@ -1,14 +1,14 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectorRef, Component, DebugElement, Input } from '@angular/core';
import { OpIconComponent } from "core-app/shared/components/icon/icon.component";
import { GitActionsMenuDirective } from "core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive";
import { TabPrsComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component";
import { OpIconComponent } from 'core-app/shared/components/icon/icon.component';
import { GitActionsMenuDirective } from 'core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive';
import { TabPrsComponent } from 'core-app/features/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component';
import { GithubPullRequestResourceService } from '../state/github-pull-request.service';
import { ApiV3Service } from "core-app/core/apiv3/api-v3.service";
import { of } from "rxjs";
import { PullRequestComponent } from "core-app/features/plugins/linked/openproject-github_integration/pull-request/pull-request.component";
import { By } from "@angular/platform-browser";
import { I18nService } from "core-app/core/i18n/i18n.service";
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { of } from 'rxjs';
import { PullRequestComponent } from 'core-app/features/plugins/linked/openproject-github_integration/pull-request/pull-request.component';
import { By } from '@angular/platform-browser';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { IGithubPullRequest } from '../state/github-pull-request.model';
import { PullRequestStateComponent } from '../pull-request/pull-request-state.component';
@@ -26,18 +26,18 @@ describe('TabPrsComponent', () => {
let component:TabPrsComponent;
let fixture:ComponentFixture<TabPrsComponent>;
let element:DebugElement;
let githubPullRequestResourceServiceSpy:jasmine.SpyObj<GithubPullRequestResourceService>;
let changeDetectorRef: jasmine.SpyObj<ChangeDetectorRef>;
let githubPullRequestResourceServiceSpy:{ ofWorkPackage:ReturnType<typeof vi.fn> };
let changeDetectorRef:ChangeDetectorRef & { detectChanges:ReturnType<typeof vi.fn> };
const I18nServiceStub = {
t: function(key:string) {
t: function (key:string) {
return 'test translation';
}
}
};
const ApiV3Stub = {
work_packages: {
id: () => ({github_pull_requests: 'prpath'})
id: () => ({ github_pull_requests: 'prpath' })
}
}
};
const pullRequests:IGithubPullRequest[] = [
{
@@ -123,28 +123,32 @@ describe('TabPrsComponent', () => {
];
beforeEach(async () => {
const changeDetectorSpy = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']);
githubPullRequestResourceServiceSpy = jasmine.createSpyObj('GithubPullRequestResourceService', ['ofWorkPackage']);
const changeDetectorSpy = {
detectChanges: vi.fn().mockName('ChangeDetectorRef.detectChanges')
};
githubPullRequestResourceServiceSpy = {
ofWorkPackage: vi.fn().mockName('GithubPullRequestResourceService.ofWorkPackage')
};
// @ts-ignore
githubPullRequestResourceServiceSpy.ofWorkPackage.and.returnValue(of(pullRequests));
githubPullRequestResourceServiceSpy.ofWorkPackage.mockReturnValue(of(pullRequests));
await TestBed
.configureTestingModule({
declarations: [
TabPrsComponent,
OpIconComponent,
GitActionsMenuDirective,
PullRequestComponent,
PullRequestStateComponent,
OpDateTimeComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: ApiV3Service, useValue: ApiV3Stub },
{ provide: ChangeDetectorRef, useValue: changeDetectorSpy },
{ provide: GithubPullRequestResourceService, useValue: githubPullRequestResourceServiceSpy },
],
})
declarations: [
TabPrsComponent,
OpIconComponent,
GitActionsMenuDirective,
PullRequestComponent,
PullRequestStateComponent,
OpDateTimeComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: ApiV3Service, useValue: ApiV3Stub },
{ provide: ChangeDetectorRef, useValue: changeDetectorSpy },
{ provide: GithubPullRequestResourceService, useValue: githubPullRequestResourceServiceSpy },
],
})
.compileComponents();
});
@@ -152,7 +156,7 @@ describe('TabPrsComponent', () => {
fixture = TestBed.createComponent(TabPrsComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef) as jasmine.SpyObj<ChangeDetectorRef>;
changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef) as ChangeDetectorRef & { detectChanges:ReturnType<typeof vi.fn> };
// @ts-ignore
component.workPackage = { id: 'testId' };
@@ -27,10 +27,10 @@
//++
import { GitActionsService } from './git-actions.service';
import { WorkPackageResource } from "core-app/features/hal/resources/work-package-resource";
import { PathHelperService } from "core-app/core/path-helper/path-helper.service";
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
describe('GitActionsService', function() {
describe('GitActionsService', function () {
let service:GitActionsService;
const createWorkPackage = (overrides = {}) => {
@@ -44,7 +44,7 @@ describe('GitActionsService', function() {
pathHelper: new PathHelperService()
};
const workPackage = { ...defaults, ...overrides };
return(workPackage as WorkPackageResource);
return (workPackage as WorkPackageResource);
};
beforeEach(() => {
@@ -54,21 +54,20 @@ describe('GitActionsService', function() {
it('produces a branch name, commit message, and a git command', () => {
const wp = createWorkPackage();
expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t');
expect(service.commitMessage(wp)).toEqual(
`OP#42 Find the question, or don't
const origin = window.location.origin;
http://localhost:9876/work_packages/42`
);
expect(service.gitCommand(wp)).toEqual(
`git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m 'OP#42 Find the question, or don'\\''t' -m 'http://localhost:9876/work_packages/42'`
);
expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t');
expect(service.commitMessage(wp)).toEqual(`OP#42 Find the question, or don't
${origin}/work_packages/42`);
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m 'OP#42 Find the question, or don'\\''t' -m '${origin}/work_packages/42'`);
});
it('shell-escapes output for the git-command', () => {
const wp = createWorkPackage({ subject: "' && rm -rf / #" });
expect(service.gitCommand(wp)).toEqual(
`git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m 'OP#42 '\\'' && rm -rf / #' -m 'http://localhost:9876/work_packages/42'`
);
const origin = window.location.origin;
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m 'OP#42 '\\'' && rm -rf / #' -m '${origin}/work_packages/42'`);
});
});