feat: dead-config sweep, test suite foundation, widget settingsSchema validation
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Sprint G: audited every BootstrapConfig field for a real runtime consumer
(docs/DEAD-CONFIG-AUDIT.md). Wired 3 previously-dead editable fields:
footer.logoUrl, company.address.street/contacts.phone, catalog.suggestionsEnabled.
Remaining dead fields needing a business/design decision tracked in
PRODUCT_BACKLOG.md/KNOWN-ISSUES.md, not silently left.

Sprint H: 6 new spec files (test count 57 -> 83), covering ProjectEditorFacade
(undo/redo, draft persistence, publish gating), AdminAnalyticsFacade
(never-fabricate-a-number contract), and regression coverage for this
session's carousel/hero/profile-toggle fixes.

Sprint I: widget settingsSchema (declared in widget-manifest.json, never
validated) now enforced via a new lightweight schema check in
ProjectValidator, surfaced through the existing issuesByField pipeline.
Same check reused in diagnostics so editor and diagnostics can't disagree.

Verification: tsc clean, ng build clean, 83/83 tests pass, barry-cache
validate clean (2 pre-existing unrelated warnings only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-05 20:47:13 +04:00
parent 6f9401fa8f
commit ce63931bc2
25 changed files with 814 additions and 25 deletions

View File

@@ -2,7 +2,7 @@
<div class="app-footer__container page-container">
<div class="app-footer__top">
<div class="app-footer__brand">
<app-logo />
<app-logo [srcOverride]="footerLogoUrl()" />
<p>{{ 'footer.description' | translate }}</p>
</div>
@@ -46,6 +46,12 @@
@if (contactEmail) {
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
}
@if (contactPhone) {
<a [href]="'tel:' + contactPhone">{{ contactPhone }}</a>
}
@if (companyAddress) {
<span class="app-footer__address">{{ companyAddress }}</span>
}
</div>
</div>
</footer>

View File

@@ -21,6 +21,7 @@ export class FooterComponent {
readonly footerGroups = signal<FooterResolvedGroup[]>([]);
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
readonly copyrightText = signal('');
readonly footerLogoUrl = signal<string | undefined>(undefined);
private readonly configService = inject(ConfigService);
@@ -35,6 +36,7 @@ export class FooterComponent {
this.footerGroups.set([]);
this.paymentIcons.set([]);
this.copyrightText.set('');
this.footerLogoUrl.set(undefined);
return;
}
@@ -42,6 +44,7 @@ export class FooterComponent {
this.footerGroups.set(model.groups);
this.paymentIcons.set(model.paymentIcons);
this.copyrightText.set(model.copyrightText);
this.footerLogoUrl.set(model.logoUrl);
});
this.configService.loadBootstrap().subscribe();
@@ -56,4 +59,12 @@ export class FooterComponent {
get contactEmail(): string {
return this.uiRuntime.contactEmail();
}
get contactPhone(): string {
return this.uiRuntime.contactPhone();
}
get companyAddress(): string {
return this.uiRuntime.companyAddress();
}
}

View File

@@ -0,0 +1,86 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { BootstrapConfig } from '../../shared/models/config';
import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
import { ConfigService } from '../../core/config/config.service';
import { AuthService } from '../../services/auth.service';
import { HeaderComponent } from './header.component';
function makeBootstrap(): BootstrapConfig {
return {
schemaVersion: '1', generatedAt: new Date().toISOString(),
tenant: { id: 't1', slug: 't1', code: 't1', host: 'dexar.market', name: 'Dexar', websiteBaseUrl: 'https://dexar.market', builderBaseUrl: 'https://dexar.market', backofficeBaseUrl: 'https://dexar.market', defaultLocale: 'en', supportedLocales: ['en'], defaultCurrency: 'USD', supportedCurrencies: ['USD'], timezone: 'UTC' },
branding: { brandName: 'Dexar', legalName: 'Dexar LLC', logoUrl: 'logo.png', faviconUrl: 'favicon.png' },
theme: { themeId: 'default', mode: 'light', palette: {} as any, typography: {} as any, spacing: {} as any, borderRadiusScale: {}, shadows: {}, iconSet: 'default' },
company: { companyName: 'Dexar LLC', address: { country: 'US', city: 'NY' }, contacts: { email: 'sales@dexar.market' } },
featureFlags: {} as any,
apiEndpoints: {} as any,
localization: { defaultLocale: 'en', supportedLocales: ['en'], currencyByLocale: {}, dictionaries: [] },
seo: { default: { title: 'Dexar', description: 'Dexar' }, byPageKey: {} },
permissions: { definitions: [], roles: [] },
header: { showLogo: true, showSearch: true, showCategories: true, showLanguages: true, showCart: true, showProfile: true, showWishlist: true, showCompare: true, showRegion: true, sticky: true, layout: 'default' },
navigation: { header: [], footer: [] },
pages: [],
} as unknown as BootstrapConfig;
}
describe('HeaderComponent profile control (login/logout gating regression)', () => {
function configure(isAuthenticated: boolean): void {
// Full fake - TelegramLoginComponent (rendered inside the profile control) reads
// several signals/methods off AuthService directly, not just isAuthenticated.
const fakeAuth = {
session: () => null,
status: () => (isAuthenticated ? 'authenticated' : 'unauthenticated'),
isAuthenticated: () => isAuthenticated,
showLoginDialog: () => false,
displayName: () => null,
requestLogin: jasmine.createSpy('requestLogin'),
logout: jasmine.createSpy('logout'),
hideLogin: jasmine.createSpy('hideLogin'),
createWebSession: () => of({ webSessionID: 'x', botLoginUrl: '' }),
checkSessionOnce: () => of(null),
getTelegramAppLoginUrl: () => '',
onTelegramLoginComplete: jasmine.createSpy('onTelegramLoginComplete'),
};
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
{ provide: AuthService, useValue: fakeAuth },
],
});
// Deterministically prime the bootstrap snapshot before component creation -
// resolveHeaderConfig() reads getBootstrapSnapshot() synchronously and falls
// back to DEFAULT_HEADER_CONFIG (showProfile: false) if it isn't populated yet.
TestBed.inject(ConfigService).loadBootstrap().subscribe();
}
it('shows a login button (not logout) when logged out', () => {
configure(false);
const fixture = TestBed.createComponent(HeaderComponent);
fixture.detectChanges();
// Aria-labels render translated text (Russian by default), so assert on the
// icon name attribute instead - stable regardless of active language.
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-icon[name="user"]')).toBeTruthy();
expect(compiled.querySelector('app-icon[name="logOut"]')).toBeFalsy();
});
it('shows a logout button (not login) when logged in - never both', () => {
configure(true);
const fixture = TestBed.createComponent(HeaderComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-icon[name="logOut"]')).toBeTruthy();
expect(compiled.querySelector('app-icon[name="user"]')).toBeFalsy();
});
});

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
@Component({
@@ -17,6 +17,9 @@ import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LogoComponent {
/** Overrides the default (branding.logoUrl) logo, e.g. footer.logoUrl. */
@Input() srcOverride?: string;
constructor(private readonly uiRuntime: UiRuntimeFacade) {}
get brandName(): string {
@@ -24,6 +27,6 @@ export class LogoComponent {
}
get logoPath(): string {
return this.uiRuntime.logoUrl();
return this.srcOverride || this.uiRuntime.logoUrl();
}
}