- Add .gitignore to exclude node_modules, dist, logs, and system files - Add comprehensive project documentation including README, deployment guide, and development setup - Add .kiro project specifications for amazon-product-bar-extension, appwrite-cloud-storage, appwrite-userid-repair, blacklist-feature, and enhanced-item-management - Add .kiro steering documents for product, structure, styling, and tech guidelines - Add VSCode settings configuration for consistent development environment - Add manifest.json and babel/vite configuration for extension build setup - Add complete source code implementation including AppWrite integration, storage managers, UI components, and services - Add comprehensive test suite with Jest configuration and 30+ test files covering all major modules - Add test HTML files for integration testing and validation - Add coverage reports and build validation scripts - Add AppWrite setup and repair documentation for database schema management - Add migration guides and responsive accessibility implementation documentation - Establish foundation for Amazon product bar extension with full feature set including blacklist management, enhanced item workflows, and real-time synchronization
734 lines
21 KiB
Markdown
734 lines
21 KiB
Markdown
# Design Document: Blacklist Feature
|
||
|
||
## Overview
|
||
|
||
Die Blacklist-Funktion erweitert die Amazon Product Bar Extension um die Möglichkeit, Markennamen zu verwalten und Produkte dieser Marken visuell in der Product_Bar zu kennzeichnen. Die Funktion nutzt einen neuen Menüpunkt "Blacklist" im StaggeredMenu, speichert Daten im Local Storage und zeigt Marken-Logos bei geblacklisteten Produkten an.
|
||
|
||
## Architecture
|
||
|
||
```mermaid
|
||
graph TD
|
||
A[StaggeredMenu] --> B[Blacklist Menu Item]
|
||
B --> C[Blacklist Panel Manager]
|
||
C --> D[Blacklist Storage Manager]
|
||
D --> E[Local Storage]
|
||
C --> F[Brand Input UI]
|
||
C --> G[Brand List UI]
|
||
|
||
H[Product Card Detector] --> I[Brand Extractor]
|
||
I --> J[Blacklist Matcher]
|
||
J --> D
|
||
J --> K[Brand Icon Manager]
|
||
K --> L[Product Bar]
|
||
|
||
M[Brand Logo Registry] --> K
|
||
```
|
||
|
||
Die Blacklist-Funktion besteht aus:
|
||
1. **Blacklist Panel Manager** - UI-Verwaltung für das Blacklist-Panel
|
||
2. **Blacklist Storage Manager** - CRUD-Operationen für geblacklistete Marken
|
||
3. **Brand Extractor** - Extraktion von Markennamen aus Produktkarten
|
||
4. **Blacklist Matcher** - Case-insensitive Vergleich von Marken
|
||
5. **Brand Icon Manager** - Verwaltung der Marken-Icons in Product_Bars
|
||
6. **Brand Logo Registry** - Vordefinierte Logos für bekannte Marken
|
||
|
||
## Components and Interfaces
|
||
|
||
### 1. Blacklist Storage Manager
|
||
|
||
```javascript
|
||
// BlacklistStorageManager.js
|
||
class BlacklistStorageManager {
|
||
constructor() {
|
||
this.STORAGE_KEY = 'amazon_ext_blacklist';
|
||
}
|
||
|
||
// Speichert eine Marke in der Blacklist
|
||
async addBrand(brandName) {
|
||
const brands = await this.getBrands();
|
||
const normalizedName = brandName.trim();
|
||
|
||
// Case-insensitive Duplikat-Check
|
||
const exists = brands.some(b =>
|
||
b.name.toLowerCase() === normalizedName.toLowerCase()
|
||
);
|
||
|
||
if (exists) {
|
||
throw new Error('Brand already exists');
|
||
}
|
||
|
||
brands.push({
|
||
id: this.generateId(),
|
||
name: normalizedName,
|
||
addedAt: new Date().toISOString()
|
||
});
|
||
|
||
await this.saveBrands(brands);
|
||
return brands;
|
||
}
|
||
|
||
// Holt alle geblacklisteten Marken
|
||
async getBrands() {
|
||
const data = localStorage.getItem(this.STORAGE_KEY);
|
||
return data ? JSON.parse(data) : [];
|
||
}
|
||
|
||
// Löscht eine Marke aus der Blacklist
|
||
async deleteBrand(brandId) {
|
||
const brands = await this.getBrands();
|
||
const filtered = brands.filter(b => b.id !== brandId);
|
||
await this.saveBrands(filtered);
|
||
return filtered;
|
||
}
|
||
|
||
// Prüft ob eine Marke geblacklistet ist (case-insensitive)
|
||
async isBrandBlacklisted(brandName) {
|
||
const brands = await this.getBrands();
|
||
return brands.some(b =>
|
||
b.name.toLowerCase() === brandName.toLowerCase()
|
||
);
|
||
}
|
||
|
||
// Speichert Marken im Local Storage
|
||
async saveBrands(brands) {
|
||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(brands));
|
||
|
||
// Event für UI-Updates emittieren
|
||
if (window.amazonExtEventBus) {
|
||
window.amazonExtEventBus.emit('blacklist:updated', brands);
|
||
}
|
||
}
|
||
|
||
generateId() {
|
||
return 'bl_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2. Brand Extractor
|
||
|
||
```javascript
|
||
// BrandExtractor.js
|
||
class BrandExtractor {
|
||
// Extrahiert Markennamen aus einer Produktkarte
|
||
extractBrand(productCard) {
|
||
// Methode 1: "by [Brand]" Text
|
||
const byBrandElement = productCard.querySelector('.a-row.a-size-base.a-color-secondary');
|
||
if (byBrandElement) {
|
||
const byMatch = byBrandElement.textContent.match(/by\s+([^,\n]+)/i);
|
||
if (byMatch) {
|
||
return byMatch[1].trim();
|
||
}
|
||
}
|
||
|
||
// Methode 2: Brand-Link
|
||
const brandLink = productCard.querySelector('a[href*="/stores/"], .a-link-normal[href*="brand="]');
|
||
if (brandLink) {
|
||
return brandLink.textContent.trim();
|
||
}
|
||
|
||
// Methode 3: Aus Produkttitel extrahieren (erstes Wort oft die Marke)
|
||
const titleElement = productCard.querySelector('h2 a span, .a-text-normal');
|
||
if (titleElement) {
|
||
const title = titleElement.textContent.trim();
|
||
const firstWord = title.split(/\s+/)[0];
|
||
// Nur wenn es wie ein Markenname aussieht (Großbuchstabe am Anfang)
|
||
if (firstWord && /^[A-Z]/.test(firstWord)) {
|
||
return firstWord;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3. Brand Logo Registry
|
||
|
||
```javascript
|
||
// BrandLogoRegistry.js
|
||
class BrandLogoRegistry {
|
||
constructor() {
|
||
// Vordefinierte SVG-Logos für bekannte Marken
|
||
this.logos = {
|
||
'nike': this.createNikeLogo(),
|
||
'adidas': this.createAdidasLogo(),
|
||
'puma': this.createPumaLogo(),
|
||
'apple': this.createAppleLogo(),
|
||
'samsung': this.createSamsungLogo()
|
||
};
|
||
|
||
this.defaultBlockedIcon = this.createBlockedIcon();
|
||
}
|
||
|
||
// Holt Logo für eine Marke (case-insensitive)
|
||
getLogo(brandName) {
|
||
const normalized = brandName.toLowerCase();
|
||
return this.logos[normalized] || this.defaultBlockedIcon;
|
||
}
|
||
|
||
// Prüft ob ein spezifisches Logo existiert
|
||
hasLogo(brandName) {
|
||
return brandName.toLowerCase() in this.logos;
|
||
}
|
||
|
||
createNikeLogo() {
|
||
return `<svg viewBox="0 0 16 16" width="16" height="16">
|
||
<path fill="currentColor" d="M1.5 9.5c-.3.1-.5.4-.5.7 0 .2.1.4.3.5l.2.1c.1 0 .2 0 .3-.1l12-5.5c.2-.1.3-.3.3-.5 0-.3-.2-.5-.5-.6L1.5 9.5z"/>
|
||
</svg>`;
|
||
}
|
||
|
||
createAdidasLogo() {
|
||
return `<svg viewBox="0 0 16 16" width="16" height="16">
|
||
<path fill="currentColor" d="M2 12h3V6L2 12zm4 0h3V4L6 12zm4 0h3V2l-3 10z"/>
|
||
</svg>`;
|
||
}
|
||
|
||
createPumaLogo() {
|
||
return `<svg viewBox="0 0 16 16" width="16" height="16">
|
||
<path fill="currentColor" d="M8 2C4.7 2 2 4.7 2 8s2.7 6 6 6 6-2.7 6-6-2.7-6-6-6zm0 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z"/>
|
||
</svg>`;
|
||
}
|
||
|
||
createAppleLogo() {
|
||
return `<svg viewBox="0 0 16 16" width="16" height="16">
|
||
<path fill="currentColor" d="M11.2 4.2c-.6-.7-1.4-1.1-2.3-1.2.1-.8.5-1.5 1.1-2-.6.1-1.2.4-1.6.9-.4-.5-1-.8-1.6-.9.6.5 1 1.2 1.1 2-.9.1-1.7.5-2.3 1.2C4.5 5.4 4 6.7 4 8c0 2.2 1.3 5 3 5 .5 0 .9-.2 1.3-.5.4.3.8.5 1.3.5 1.7 0 3-2.8 3-5 0-1.3-.5-2.6-1.4-3.8z"/>
|
||
</svg>`;
|
||
}
|
||
|
||
createSamsungLogo() {
|
||
return `<svg viewBox="0 0 16 16" width="16" height="16">
|
||
<rect fill="currentColor" x="2" y="6" width="12" height="4" rx="1"/>
|
||
</svg>`;
|
||
}
|
||
|
||
createBlockedIcon() {
|
||
return `<svg viewBox="0 0 16 16" width="16" height="16">
|
||
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||
<line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1.5"/>
|
||
</svg>`;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4. Brand Icon Manager
|
||
|
||
```javascript
|
||
// BrandIconManager.js
|
||
class BrandIconManager {
|
||
constructor(blacklistStorage, brandExtractor, logoRegistry) {
|
||
this.blacklistStorage = blacklistStorage;
|
||
this.brandExtractor = brandExtractor;
|
||
this.logoRegistry = logoRegistry;
|
||
}
|
||
|
||
// Aktualisiert alle Product_Bars auf der Seite
|
||
async updateAllBars() {
|
||
const productBars = document.querySelectorAll('.amazon-ext-product-bar');
|
||
const brands = await this.blacklistStorage.getBrands();
|
||
const blacklistedNames = brands.map(b => b.name.toLowerCase());
|
||
|
||
productBars.forEach(bar => {
|
||
const productCard = bar.closest('[data-asin]');
|
||
if (!productCard) return;
|
||
|
||
const brand = this.brandExtractor.extractBrand(productCard);
|
||
if (brand && blacklistedNames.includes(brand.toLowerCase())) {
|
||
this.addBrandIcon(bar, brand);
|
||
} else {
|
||
this.removeBrandIcon(bar);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Fügt Brand-Icon zu einer Product_Bar hinzu
|
||
addBrandIcon(productBar, brandName) {
|
||
let iconContainer = productBar.querySelector('.brand-icon');
|
||
|
||
if (!iconContainer) {
|
||
iconContainer = document.createElement('div');
|
||
iconContainer.className = 'brand-icon';
|
||
productBar.insertBefore(iconContainer, productBar.firstChild);
|
||
}
|
||
|
||
const logo = this.logoRegistry.getLogo(brandName);
|
||
iconContainer.innerHTML = logo;
|
||
iconContainer.title = `Blacklisted: ${brandName}`;
|
||
iconContainer.style.display = 'flex';
|
||
}
|
||
|
||
// Entfernt Brand-Icon von einer Product_Bar
|
||
removeBrandIcon(productBar) {
|
||
const iconContainer = productBar.querySelector('.brand-icon');
|
||
if (iconContainer) {
|
||
iconContainer.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Fügt Icon zu allen Produkten einer bestimmten Marke hinzu
|
||
async addIconForBrand(brandName) {
|
||
const productBars = document.querySelectorAll('.amazon-ext-product-bar');
|
||
|
||
productBars.forEach(bar => {
|
||
const productCard = bar.closest('[data-asin]');
|
||
if (!productCard) return;
|
||
|
||
const brand = this.brandExtractor.extractBrand(productCard);
|
||
if (brand && brand.toLowerCase() === brandName.toLowerCase()) {
|
||
this.addBrandIcon(bar, brand);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Entfernt Icon von allen Produkten einer bestimmten Marke
|
||
async removeIconForBrand(brandName) {
|
||
const productBars = document.querySelectorAll('.amazon-ext-product-bar');
|
||
|
||
productBars.forEach(bar => {
|
||
const productCard = bar.closest('[data-asin]');
|
||
if (!productCard) return;
|
||
|
||
const brand = this.brandExtractor.extractBrand(productCard);
|
||
if (brand && brand.toLowerCase() === brandName.toLowerCase()) {
|
||
this.removeBrandIcon(bar);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5. Blacklist Panel Manager
|
||
|
||
```javascript
|
||
// BlacklistPanelManager.js
|
||
class BlacklistPanelManager {
|
||
constructor(blacklistStorage, logoRegistry) {
|
||
this.blacklistStorage = blacklistStorage;
|
||
this.logoRegistry = logoRegistry;
|
||
this.container = null;
|
||
}
|
||
|
||
createBlacklistContent() {
|
||
const container = document.createElement('div');
|
||
container.className = 'amazon-ext-blacklist-content';
|
||
|
||
container.innerHTML = `
|
||
<div class="blacklist-header">
|
||
<h2>Blacklist</h2>
|
||
<p class="blacklist-description">Markennamen hinzufügen, um Produkte zu markieren</p>
|
||
</div>
|
||
|
||
<div class="add-brand-form">
|
||
<input
|
||
type="text"
|
||
class="brand-input"
|
||
placeholder="Markenname eingeben (z.B. Nike, Adidas)..."
|
||
/>
|
||
<button class="add-brand-btn">Hinzufügen</button>
|
||
</div>
|
||
|
||
<div class="brand-list-container">
|
||
<div class="brand-list"></div>
|
||
</div>
|
||
|
||
<div class="blacklist-message" style="display: none;"></div>
|
||
`;
|
||
|
||
this.container = container;
|
||
this.setupEventListeners();
|
||
this.loadBrands();
|
||
|
||
return container;
|
||
}
|
||
|
||
setupEventListeners() {
|
||
const input = this.container.querySelector('.brand-input');
|
||
const addBtn = this.container.querySelector('.add-brand-btn');
|
||
|
||
addBtn.addEventListener('click', () => this.handleAddBrand());
|
||
input.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') this.handleAddBrand();
|
||
});
|
||
}
|
||
|
||
async handleAddBrand() {
|
||
const input = this.container.querySelector('.brand-input');
|
||
const brandName = input.value.trim();
|
||
|
||
if (!brandName) {
|
||
this.showMessage('Bitte einen Markennamen eingeben', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.blacklistStorage.addBrand(brandName);
|
||
input.value = '';
|
||
this.showMessage(`"${brandName}" zur Blacklist hinzugefügt`, 'success');
|
||
this.loadBrands();
|
||
} catch (error) {
|
||
if (error.message === 'Brand already exists') {
|
||
this.showMessage('Diese Marke ist bereits in der Blacklist', 'error');
|
||
} else {
|
||
this.showMessage('Fehler beim Speichern', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async loadBrands() {
|
||
const brands = await this.blacklistStorage.getBrands();
|
||
const listContainer = this.container.querySelector('.brand-list');
|
||
|
||
if (brands.length === 0) {
|
||
listContainer.innerHTML = '<p class="empty-message">Keine Marken in der Blacklist</p>';
|
||
return;
|
||
}
|
||
|
||
listContainer.innerHTML = brands.map(brand => `
|
||
<div class="brand-item" data-id="${brand.id}">
|
||
<div class="brand-logo">
|
||
${this.logoRegistry.getLogo(brand.name)}
|
||
</div>
|
||
<span class="brand-name">${brand.name}</span>
|
||
<button class="delete-brand-btn" data-id="${brand.id}">×</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Delete-Button Event Listeners
|
||
listContainer.querySelectorAll('.delete-brand-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const brandId = e.target.dataset.id;
|
||
this.handleDeleteBrand(brandId);
|
||
});
|
||
});
|
||
}
|
||
|
||
async handleDeleteBrand(brandId) {
|
||
const brands = await this.blacklistStorage.getBrands();
|
||
const brand = brands.find(b => b.id === brandId);
|
||
|
||
await this.blacklistStorage.deleteBrand(brandId);
|
||
this.showMessage(`"${brand?.name}" entfernt`, 'success');
|
||
this.loadBrands();
|
||
}
|
||
|
||
showMessage(text, type) {
|
||
const messageEl = this.container.querySelector('.blacklist-message');
|
||
messageEl.textContent = text;
|
||
messageEl.className = `blacklist-message ${type}`;
|
||
messageEl.style.display = 'block';
|
||
|
||
setTimeout(() => {
|
||
messageEl.style.display = 'none';
|
||
}, 3000);
|
||
}
|
||
|
||
showBlacklistPanel() {
|
||
this.loadBrands();
|
||
}
|
||
|
||
hideBlacklistPanel() {
|
||
// Cleanup wenn nötig
|
||
}
|
||
}
|
||
```
|
||
|
||
## Data Models
|
||
|
||
### BlacklistedBrand
|
||
|
||
```typescript
|
||
interface BlacklistedBrand {
|
||
id: string; // Eindeutige ID (bl_timestamp_random)
|
||
name: string; // Markenname (originale Schreibweise)
|
||
addedAt: string; // ISO-Timestamp der Hinzufügung
|
||
}
|
||
```
|
||
|
||
### Local Storage Structure
|
||
|
||
```json
|
||
{
|
||
"amazon_ext_blacklist": [
|
||
{
|
||
"id": "bl_1699123456789_abc123def",
|
||
"name": "Nike",
|
||
"addedAt": "2024-01-15T10:30:00.000Z"
|
||
},
|
||
{
|
||
"id": "bl_1699123456790_xyz789ghi",
|
||
"name": "Adidas",
|
||
"addedAt": "2024-01-15T10:31:00.000Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### CSS Styling
|
||
|
||
```css
|
||
/* Blacklist Panel Styles */
|
||
.amazon-ext-blacklist-content {
|
||
color: white;
|
||
padding: 2rem;
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.blacklist-header h2 {
|
||
margin: 0 0 0.5rem 0;
|
||
font-size: 2rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.blacklist-description {
|
||
color: #888;
|
||
margin: 0 0 1.5rem 0;
|
||
}
|
||
|
||
.add-brand-form {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.add-brand-form .brand-input {
|
||
flex: 1;
|
||
padding: 0.75rem;
|
||
border: 1px solid #333;
|
||
background: #222;
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.add-brand-form .add-brand-btn {
|
||
padding: 0.75rem 1.5rem;
|
||
background: #ff9900;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.add-brand-form .add-brand-btn:hover {
|
||
background: #e68a00;
|
||
}
|
||
|
||
.brand-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.brand-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
padding: 0.75rem 1rem;
|
||
background: #222;
|
||
border-radius: 4px;
|
||
border: 1px solid #333;
|
||
}
|
||
|
||
.brand-logo {
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #ff9900;
|
||
}
|
||
|
||
.brand-name {
|
||
flex: 1;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.delete-brand-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #888;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
padding: 0 0.5rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
.delete-brand-btn:hover {
|
||
color: #ff4444;
|
||
}
|
||
|
||
.empty-message {
|
||
color: #666;
|
||
text-align: center;
|
||
padding: 2rem;
|
||
}
|
||
|
||
.blacklist-message {
|
||
padding: 0.75rem 1rem;
|
||
border-radius: 4px;
|
||
margin-top: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.blacklist-message.success {
|
||
background: #1a4d1a;
|
||
color: #4ade4a;
|
||
}
|
||
|
||
.blacklist-message.error {
|
||
background: #4d1a1a;
|
||
color: #ff6b6b;
|
||
}
|
||
|
||
/* Brand Icon in Product Bar */
|
||
.amazon-ext-product-bar .brand-icon {
|
||
position: absolute;
|
||
left: 5px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 16px;
|
||
height: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #ff4444;
|
||
}
|
||
```
|
||
|
||
|
||
|
||
## Correctness Properties
|
||
|
||
*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||
|
||
### Property 1: Brand Saving Round-Trip
|
||
|
||
*For any* valid brand name, saving it to the blacklist and then retrieving all brands should include that brand with the same name.
|
||
|
||
**Validates: Requirements 2.2, 2.3**
|
||
|
||
### Property 2: Case-Insensitive Comparison
|
||
|
||
*For any* two brand name strings that differ only in letter case (e.g., "Nike" vs "nike" vs "NIKE"), the `isBrandBlacklisted` function should return the same result for both.
|
||
|
||
**Validates: Requirements 4.1, 4.2**
|
||
|
||
### Property 3: Duplicate Prevention
|
||
|
||
*For any* brand name already in the blacklist, attempting to add a case-variant of that name should throw an error and not increase the blacklist size.
|
||
|
||
**Validates: Requirements 2.5, 4.3**
|
||
|
||
### Property 4: Whitespace Trimming
|
||
|
||
*For any* brand name with leading or trailing whitespace, the saved brand name should have no leading or trailing whitespace.
|
||
|
||
**Validates: Requirements 2.6**
|
||
|
||
### Property 5: Original Case Preservation
|
||
|
||
*For any* brand name saved to the blacklist, the retrieved brand name should preserve the exact original case as entered.
|
||
|
||
**Validates: Requirements 4.4**
|
||
|
||
### Property 6: Brand List Rendering Completeness
|
||
|
||
*For any* set of N saved brands, the rendered brand list should contain exactly N brand items.
|
||
|
||
**Validates: Requirements 3.1**
|
||
|
||
### Property 7: Logo Selection Consistency
|
||
|
||
*For any* brand name, if the brand has a predefined logo in the registry, `getLogo` should return that specific logo; otherwise, it should return the default blocked icon.
|
||
|
||
**Validates: Requirements 3.2, 6.3, 7.3, 7.4**
|
||
|
||
### Property 8: Delete Button Presence
|
||
|
||
*For any* rendered brand item in the blacklist panel, it should contain exactly one delete button element.
|
||
|
||
**Validates: Requirements 3.3**
|
||
|
||
### Property 9: Deletion Completeness
|
||
|
||
*For any* brand deleted from the blacklist, it should no longer appear in storage or in the UI after deletion.
|
||
|
||
**Validates: Requirements 3.4**
|
||
|
||
### Property 10: Brand Extraction Determinism
|
||
|
||
*For any* product card DOM element with brand information, the `extractBrand` function should return a non-null string representing the brand.
|
||
|
||
**Validates: Requirements 5.1, 5.2, 5.3**
|
||
|
||
### Property 11: No Marking Without Brand
|
||
|
||
*For any* product card where brand extraction returns null, no blacklist icon should be added to the product bar.
|
||
|
||
**Validates: Requirements 5.4**
|
||
|
||
### Property 12: Blacklist Icon Display
|
||
|
||
*For any* product whose extracted brand matches a blacklisted brand (case-insensitive), the product bar should display a brand icon.
|
||
|
||
**Validates: Requirements 6.1**
|
||
|
||
### Property 13: Real-Time Icon Updates
|
||
|
||
*For any* brand added to or removed from the blacklist, all visible product bars with matching brands should immediately reflect the change (icon added or removed).
|
||
|
||
**Validates: Requirements 6.4, 6.5**
|
||
|
||
## Error Handling
|
||
|
||
| Scenario | Handling |
|
||
|----------|----------|
|
||
| Empty brand name entered | Display error message, prevent saving |
|
||
| Brand already exists | Display "already exists" message, prevent duplicate |
|
||
| Local storage quota exceeded | Display warning, suggest cleanup |
|
||
| Brand extraction fails | Skip blacklist marking for that product |
|
||
| Invalid DOM structure | Graceful degradation, log warning |
|
||
| Logo not found for brand | Use default blocked icon |
|
||
|
||
## Testing Strategy
|
||
|
||
### Unit Tests
|
||
- BlacklistStorageManager CRUD operations
|
||
- Case-insensitive comparison logic
|
||
- Whitespace trimming
|
||
- BrandExtractor with various DOM structures
|
||
- BrandLogoRegistry logo retrieval
|
||
- BlacklistPanelManager UI rendering
|
||
|
||
### Property-Based Tests
|
||
- **Property 1**: Generate random brand names, save and retrieve
|
||
- **Property 2**: Generate brand name pairs differing only in case
|
||
- **Property 3**: Generate brands, add twice with case variants
|
||
- **Property 4**: Generate brand names with various whitespace patterns
|
||
- **Property 5**: Generate brand names with mixed case, verify preservation
|
||
- **Property 6**: Generate sets of brands, verify list count
|
||
- **Property 7**: Test known brands and unknown brands for logo selection
|
||
- **Property 8**: Render brand items, verify delete button presence
|
||
- **Property 9**: Add and delete brands, verify complete removal
|
||
- **Property 10**: Generate product card DOMs with brand info
|
||
- **Property 11**: Generate product cards without brand info
|
||
- **Property 12**: Generate products with blacklisted brands
|
||
- **Property 13**: Add/remove brands, verify icon updates
|
||
|
||
### Integration Tests
|
||
- End-to-end: Add brand → see icon on matching products → delete brand → icon removed
|
||
- Menu navigation: Open menu → click Blacklist → verify panel content
|
||
- Persistence: Add brands → reload page → verify brands persist
|
||
|
||
### Testing Framework
|
||
- Jest für Unit Tests
|
||
- fast-check für Property-Based Tests
|
||
- JSDOM für DOM-Simulation
|
||
|
||
### Test Configuration
|
||
- Minimum 100 Iterationen pro Property Test
|
||
- Tag-Format: **Feature: blacklist-feature, Property {number}: {property_text}**
|
||
- Jede Correctness Property wird durch einen einzelnen Property-Based Test implementiert
|