Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
<app-list-sub-nav title="Total Variables" [count]="totalVariables"
[addAction]="addVariableAction" [isAdding]="isAdding">
<!-- Inline add form rendered on the L5 row when isAdding is true.
Replaces the +Add Variable button. Uses the same data-source
model bindings the legacy in-toolbar form used; save/cancel
call dataSource methods directly. -->
Replaces the +Add Variable button. Save/cancel route through
the component, which talks to AppVariableActionsService. -->
<div subNavForm class="flex items-center gap-2">
<!-- Validation runs on ✓ click, not reactively. Errors land in
an absolute-positioned label above the Name input (sitting
Expand All @@ -20,25 +19,26 @@
}
<input class="bg-white rounded-md border border-gray-300 px-3 py-2 text-base text-slate-800 placeholder:text-gray-400 placeholder:opacity-70 focus:outline-none focus:ring-1 focus:ring-primary"
id="envVarName" name="envVarName" placeholder="Name"
[(ngModel)]="envVarsDataSource.addItem.name"
(input)="clearNameError()">
[ngModel]="addItem().name"
(ngModelChange)="addItem.set({ name: $event, value: addItem().value }); clearNameError()">
</div>
<input class="bg-white rounded-md border border-gray-300 px-3 py-2 text-base text-slate-800 placeholder:text-gray-400 placeholder:opacity-70 focus:outline-none focus:ring-1 focus:ring-primary"
id="envVarValue" name="envVarValue" placeholder="Value"
[(ngModel)]="envVarsDataSource.addItem.value">
[ngModel]="addItem().value"
(ngModelChange)="addItem.set({ name: addItem().name, value: $event })">
<button id="addFormButtonAdd" class="btn btn-icon btn-success h-9"
type="button"
(click)="validateAndSave()">
<span class="material-icons">done</span>
</button>
<button id="addFormButtonCancel" class="btn btn-icon h-9"
type="button"
(click)="clearNameError(); envVarsDataSource.cancelAdd()">
(click)="cancelAdd()">
<span class="material-icons">clear</span>
</button>
</div>
</app-list-sub-nav>
<app-list [suppressAddButton]="true"></app-list>
<app-signal-list [config]="listConfig"></app-signal-list>
</div>

<div class="pt-5">
Expand All @@ -48,7 +48,7 @@
</div>
<div class="card-body pt-5">
<app-code-block>
@for (envVar of (allEnvVars$ | async); track $index) {
@for (envVar of allEnvVars(); track $index) {
<div [class]="envVar.section ? '[&:not(:first-of-type)]:pt-[15px]' : ''">
@if (envVar.section) {
<pre class="m-0 whitespace-pre-wrap">{{envVar.name}}</pre>
Expand All @@ -66,4 +66,4 @@
</div>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,100 +1,258 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection, NO_ERRORS_SCHEMA, signal, computed } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { describe, it, expect, beforeEach } from 'vitest';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import {
CUSTOM_ELEMENTS_SCHEMA,
WritableSignal,
computed,
provideZonelessChangeDetection,
signal,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { PaginationMonitorFactory } from '@stratosui/store';
import { ApplicationService, CloudFoundryService } from '@stratosui/cloud-foundry';
import { ApplicationServiceMock } from '@test-framework/cf';
import {
ConfirmationDialogConfig,
ConfirmationDialogService,
TailwindSnackBarService,
} from '@stratosui/core';

import { generateTestApplicationServiceProvider, ApplicationStateService, ApplicationEnvVarsHelper, generateCfStoreModules } from '@test-framework/cf';
import { ConfirmationDialogService } from '@stratosui/core';
import { AppDetailDataService } from '../../../../app-detail-data.service';
import { AppVariableActionsService } from '../../../../../../shared/services/app-variable-actions.service';
import {
CfAppVariablesSignalConfigService,
} from '../../../../../../shared/components/list/list-types/app-variables/cf-app-variables-signal-config.service';
import { VariablesTabComponent } from './variables-tab.component';

/** Minimal AppDetailDataService stub — only the signals used by VariablesTabComponent. */
const makeDataStub = () => ({
app: signal<any>(undefined).asReadonly(),
});

describe('VariablesTabComponent', () => {
let component: VariablesTabComponent;
let fixture: ComponentFixture<VariablesTabComponent>;

beforeEach(() => {
const cfGuid = 'mockCfGuid';
const appGuid = 'mockAppGuid';
const mockStore = {
dispatch: vi.fn(),
select: vi.fn(() => of({})),
pipe: vi.fn(() => of({})),
};

const mockPmf = {
create: vi.fn(() => ({
currentPage$: of([]),
pagination$: of({}),
fetchingCurrentPage$: of(false),
isLoadingPage$: of(false),
})),
};

// Spy holders, refreshed per test.
let refreshScope: ReturnType<typeof vi.fn>;
let addVariable: ReturnType<typeof vi.fn>;
let deleteVariable: ReturnType<typeof vi.fn>;
let confirmOpen: ReturnType<typeof vi.fn>;
let configRefresh: ReturnType<typeof vi.fn>;

/** Minimal AppDetailDataService stub. */
const makeDataStub = () => {
refreshScope = vi.fn(async () => undefined);
return {
envVars: signal<any>(undefined).asReadonly(),
loading: signal({ envVars: false } as any).asReadonly(),
refresh: refreshScope,
};
};

const makeVariableActionsStub = () => {
addVariable = vi.fn(async () => undefined);
deleteVariable = vi.fn(async () => undefined);
return {
transitioningName: signal<string | null>(null).asReadonly(),
inFlight: signal(false).asReadonly(),
addVariable,
deleteVariable,
updateVariable: vi.fn(async () => undefined),
};
};

TestBed.configureTestingModule({
// Stub for the tab's signal-list config service. Mirrors the public
// surface the tab consumes (view pipeline, page/sort signals, columns,
// refresh/clear). The `actions` column carries an unwrapped invoke
// that the tab replaces with a confirm-wrapped factory.
const makeVariablesConfigStub = () => {
const variables: WritableSignal<any[]> = signal([]);
const filtered = computed(() => variables());
const view = {
pagedItems: filtered,
totalFilteredResults: computed(() => filtered().length),
totalPages: computed(() => 1),
};
const pageIndex: WritableSignal<number> = signal(0);
const pageSize: WritableSignal<number> = signal(25);
const nameFilter: WritableSignal<string> = signal('');
const sort: WritableSignal<any> = signal({ field: 'name', direction: 'asc' });
const viewMode: WritableSignal<'table' | 'card'> = signal('table');
configRefresh = vi.fn(async () => undefined);
return {
view,
pageIndex,
pageSize,
nameFilter,
sort,
viewMode,
variables,
buildColumns: () => [
{ header: 'Name', key: 'name', render: (r: any) => `${r.name}` },
{ header: 'Value', key: 'value', render: (r: any) => `${r.value}` },
{
header: '', key: 'actions', kind: 'actions',
render: () => '',
actions: () => [
{ label: 'Delete', invoke: () => Promise.resolve() },
],
} as any,
],
buildRowActions: () => [],
refresh: configRefresh,
clearFilters: vi.fn(),
};
};

const makeConfirmStub = () => {
confirmOpen = vi.fn();
return { open: confirmOpen };
};

const makeSnackStub = () => ({
open: vi.fn(),
error: vi.fn(),
});

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VariablesTabComponent],
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations(),
generateTestApplicationServiceProvider(appGuid, cfGuid),
ApplicationStateService,
ApplicationEnvVarsHelper,
ConfirmationDialogService,
provideZonelessChangeDetection(),
provideRouter([]),
{ provide: Store, useValue: mockStore },
{ provide: PaginationMonitorFactory, useValue: mockPmf },
{ provide: ApplicationService, useClass: ApplicationServiceMock },
{ provide: CloudFoundryService, useValue: { cFEndpoints$: of([]), connectedCFEndpoints$: of([]) } },
{ provide: AppDetailDataService, useFactory: makeDataStub },
{ provide: ConfirmationDialogService, useFactory: makeConfirmStub },
{ provide: TailwindSnackBarService, useFactory: makeSnackStub },
],
imports: [
VariablesTabComponent,
...generateCfStoreModules(),
],
schemas: [NO_ERRORS_SCHEMA]
});
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(VariablesTabComponent, {
// Replace the heavy tab-scoped providers with stubs so we can
// observe lifecycle calls without booting the real services
// (which would pull in HttpClient, ListStateStore, etc.).
remove: {
providers: [AppVariableActionsService, CfAppVariablesSignalConfigService],
},
add: {
providers: [
{ provide: AppVariableActionsService, useFactory: makeVariableActionsStub },
{ provide: CfAppVariablesSignalConfigService, useFactory: makeVariablesConfigStub },
],
},
})
.compileComponents();

fixture = TestBed.createComponent(VariablesTabComponent);
component = fixture.componentInstance;
});

afterEach(() => {
const httpMock = TestBed.inject(HttpTestingController);
httpMock.match(() => true);
if (fixture) {
try {
fixture.destroy();
} catch (_e) {
// Ignore cleanup errors.
}
}
});

it('should be created', () => {
// Component is created successfully without triggering full initialization
expect(component).toBeTruthy();
});

it('envVarNames returns empty array when app signal is undefined', () => {
it('envVarNames returns empty array when envVars signal is undefined', () => {
expect(component.envVarNames()).toEqual([]);
});

describe('validateAndSave()', () => {
beforeEach(() => {
// The data source's addItem is the model the form binds to. Stub it
// so validateAndSave can read .name without dragging in the full
// legacy paginator pipeline.
(component.envVarsDataSource as any).addItem = { name: '', value: '' };
});
it('triggers an initial envVars fetch on init', () => {
fixture.detectChanges();
expect(refreshScope).toHaveBeenCalledWith('envVars');
});

it('builds a signal-list config from the wave-2 service', () => {
fixture.detectChanges();
expect(component.listConfig).toBeTruthy();
expect(component.listConfig.pagedItems).toBeTruthy();
// Actions column carries the tab's confirm-wrapped factory, not the
// service's no-confirm one.
const actionsCol = component.listConfig.columns.find(c => c.key === 'actions');
expect(actionsCol).toBeTruthy();
expect(actionsCol!.actions).toBeTruthy();
});

it('opens a confirm dialog before deleting, refreshes on confirm', async () => {
fixture.detectChanges();
const actionsCol = component.listConfig.columns.find(c => c.key === 'actions');
const rowActions = actionsCol!.actions!({ name: 'FOO', value: 'bar' } as any);
const del = rowActions.find(a => a.label === 'Delete');
expect(del).toBeTruthy();

del!.invoke({ name: 'FOO', value: 'bar' } as any);

expect(confirmOpen).toHaveBeenCalledTimes(1);
const [config, onConfirm] = confirmOpen.mock.calls[0];
expect(config).toBeInstanceOf(ConfirmationDialogConfig);
expect((config as ConfirmationDialogConfig).message).toContain('FOO');

expect(deleteVariable).not.toHaveBeenCalled();

await onConfirm();
expect(deleteVariable).toHaveBeenCalledWith('FOO');
expect(configRefresh).toHaveBeenCalled();
});

describe('validateAndSave()', () => {
it('flags Name is required when the name is empty', () => {
(component.envVarsDataSource as any).addItem.name = '';
component.addItem.set({ name: '', value: '' });
component.validateAndSave();
expect(component.nameError()).toBe('Name is required');
});

it('flags Name is required when the name is whitespace-only', () => {
(component.envVarsDataSource as any).addItem.name = ' ';
component.addItem.set({ name: ' ', value: '' });
component.validateAndSave();
expect(component.nameError()).toBe('Name is required');
});

it('flags an invalid pattern when the name contains spaces', () => {
(component.envVarsDataSource as any).addItem.name = 'bad name';
component.addItem.set({ name: 'bad name', value: '' });
component.validateAndSave();
expect(component.nameError()).toMatch(/letters, digits, and underscores/i);
});

it('flags an invalid pattern when the name starts with a digit', () => {
(component.envVarsDataSource as any).addItem.name = '1FOO';
component.addItem.set({ name: '1FOO', value: '' });
component.validateAndSave();
expect(component.nameError()).toMatch(/letters, digits, and underscores/i);
});

it('accepts a valid name and clears any prior error', () => {
component.nameError.set('Name is required');
(component.envVarsDataSource as any).addItem.name = 'MY_VAR';
// The legacy data source's saveAdd dispatches ngrx actions; stub it
// so the test stays scoped to validation behavior.
(component.envVarsDataSource as any).saveAdd = () => undefined;
component.addItem.set({ name: 'MY_VAR', value: 'val' });
component.validateAndSave();
expect(component.nameError()).toBe('');
});
Expand Down
Loading
Loading