feat: dead-config sweep, test suite foundation, widget settingsSchema validation
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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:
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, catchError, map, of, shareReplay, switchMap, take } from 'rxjs';
|
||||
import { Observable, catchError, map, of, shareReplay, switchMap, take, tap } from 'rxjs';
|
||||
import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ConfigService } from '../../core/config/config.service';
|
||||
export class WidgetManifestService {
|
||||
private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
|
||||
private readonly manifestByUrl = new Map<string, Observable<WidgetManifestFile>>();
|
||||
/** Last manifest resolved by getManifest(), for synchronous readers (e.g. ProjectValidator) that can't await an Observable. Mirrors ConfigService.getBootstrapSnapshot(). */
|
||||
private manifestSnapshot: WidgetManifestFile | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@@ -23,6 +25,7 @@ export class WidgetManifestService {
|
||||
}
|
||||
|
||||
const manifest$ = this.http.get<WidgetManifestFile>(manifestUrl).pipe(
|
||||
tap(manifest => { this.manifestSnapshot = manifest; }),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
catchError(() => of({ widgets: [] }))
|
||||
);
|
||||
@@ -41,6 +44,11 @@ export class WidgetManifestService {
|
||||
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
|
||||
}
|
||||
|
||||
/** Synchronous accessor for the last-resolved manifest, or null before it's loaded once. */
|
||||
getManifestSnapshot(): WidgetManifestFile | null {
|
||||
return this.manifestSnapshot;
|
||||
}
|
||||
|
||||
private resolveManifestUrl(): Observable<string> {
|
||||
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
|
||||
if (snapshotUrl) {
|
||||
|
||||
52
src/app/widgets/ui/hero-widget.component.spec.ts
Normal file
52
src/app/widgets/ui/hero-widget.component.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { HeroWidgetData } from '../contracts/widget-data.contract';
|
||||
import { HeroWidgetComponent } from './hero-widget.component';
|
||||
|
||||
function makeSection(columns?: number): SectionConfig {
|
||||
return { id: 's1', type: 'hero', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
|
||||
}
|
||||
|
||||
function makeData(): HeroWidgetData {
|
||||
return {
|
||||
title: 'First slide',
|
||||
subtitle: 'sub',
|
||||
ctaLabel: 'Shop now',
|
||||
autoplay: false,
|
||||
slides: [{ title: 'Second slide' }],
|
||||
} as unknown as HeroWidgetData;
|
||||
}
|
||||
|
||||
describe('HeroWidgetComponent panel count regression (layout.columns)', () => {
|
||||
it('shows 1 panel when layout.columns is 1 (or unset)', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(1);
|
||||
fixture.componentInstance.data = makeData();
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(1);
|
||||
expect(fixture.componentInstance.visibleSlides().length).toBe(1);
|
||||
});
|
||||
|
||||
it('shows 2 panels when layout.columns is 2 and more than one slide exists', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.componentInstance.data = makeData();
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(2);
|
||||
expect(fixture.componentInstance.visibleSlides().length).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to 1 panel when columns is 2 but there is only a single slide', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.componentInstance.data = { title: 'Only slide', autoplay: false } as unknown as HeroWidgetData;
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(1);
|
||||
});
|
||||
});
|
||||
35
src/app/widgets/ui/product-carousel-widget.component.spec.ts
Normal file
35
src/app/widgets/ui/product-carousel-widget.component.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
|
||||
import { ProductCarouselWidgetComponent } from './product-carousel-widget.component';
|
||||
|
||||
function makeSection(columns?: number): SectionConfig {
|
||||
return { id: 's1', type: 'product-carousel', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
|
||||
}
|
||||
|
||||
describe('ProductCarouselWidgetComponent sizing regression (layout.columns)', () => {
|
||||
it('defaults to 4 items per page when layout.columns is unset', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.itemsPerPage()).toBe(4);
|
||||
});
|
||||
|
||||
it('reflects layout.columns when set', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(3);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.itemsPerPage()).toBe(3);
|
||||
});
|
||||
|
||||
it('applies the resolved value as the --items-per-page CSS custom property', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const host = (fixture.nativeElement as HTMLElement).querySelector('.product-carousel-widget') as HTMLElement;
|
||||
expect(host.style.getPropertyValue('--items-per-page').trim()).toBe('2');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user