diff --git a/admin-compliance/app/sdk/iace/[projectId]/fmea/_hooks/useFMEA.ts b/admin-compliance/app/sdk/iace/[projectId]/fmea/_hooks/useFMEA.ts index 5b2894c..6993570 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/fmea/_hooks/useFMEA.ts +++ b/admin-compliance/app/sdk/iace/[projectId]/fmea/_hooks/useFMEA.ts @@ -52,21 +52,7 @@ export function useFMEA(projectId: string) { }) ) - // Load failure modes for each component type (deduplicated) - const types = [...new Set(components.map((c) => c.component_type))] - const fmByType: Record = {} - - await Promise.all( - types.map(async (type) => { - const res = await fetch(`/api/sdk/v1/iace/failure-modes?component_type=${type}`) - if (res.ok) { - const json = await res.json() - fmByType[type] = json.failure_modes || [] - } - }) - ) - - // Also load general failure modes (no type filter) + // Load ALL failure modes, then match by component type + name keywords const allRes = await fetch('/api/sdk/v1/iace/failure-modes') let allFMs: FailureMode[] = [] if (allRes.ok) { @@ -74,12 +60,33 @@ export function useFMEA(projectId: string) { allFMs = json.failure_modes || [] } + // Derive the best FM component_type from component name keywords + const nameToFMTypes: Record = { + sensor: ['sensor'], scanner: ['sensor'], kamera: ['sensor'], + motor: ['actuator', 'electrical'], antrieb: ['actuator'], + steuerung: ['controller'], sps: ['controller'], plc: ['controller'], + software: ['software'], firmware: ['software'], + ventil: ['actuator', 'mechanical'], greifer: ['actuator', 'mechanical'], + roboter: ['actuator', 'mechanical'], hydraulik: ['actuator'], + netzwerk: ['network'], ethernet: ['network'], + } + + function getFMTypesForComp(comp: Component): string[] { + const types = [comp.component_type] + const nameLower = comp.name.toLowerCase() + for (const [kw, fmTypes] of Object.entries(nameToFMTypes)) { + if (nameLower.includes(kw)) types.push(...fmTypes) + } + return [...new Set(types)] + } + // Build FMEA rows: each component × its matching failure modes const fmeaRows: FMEARow[] = [] for (const comp of components) { - const compFMs = fmByType[comp.component_type] || [] - // Use type-specific FMs, or fallback to first 3 general FMs - const relevantFMs = compFMs.length > 0 ? compFMs : allFMs.slice(0, 3) + const compTypes = getFMTypesForComp(comp) + const compFMs = allFMs.filter((fm) => compTypes.includes(fm.component_type)) + // Use matched FMs, or fallback to mechanical FMs + const relevantFMs = compFMs.length > 0 ? compFMs : allFMs.filter((fm) => fm.component_type === 'mechanical').slice(0, 3) for (const fm of relevantFMs) { const s = fm.default_severity || 5 diff --git a/admin-compliance/app/sdk/iace/[projectId]/operational-states/_hooks/useOperationalStates.ts b/admin-compliance/app/sdk/iace/[projectId]/operational-states/_hooks/useOperationalStates.ts index 4f7b99b..ebd65ee 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/operational-states/_hooks/useOperationalStates.ts +++ b/admin-compliance/app/sdk/iace/[projectId]/operational-states/_hooks/useOperationalStates.ts @@ -108,15 +108,45 @@ export function useOperationalStates(projectId: string) { setDeltaLoading(true) setDeltaResult(null) try { - // Build a MatchInput from the project's current components + proposed states + // Build MatchInput from project's components — derive tags from names/types const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`) - let componentIds: string[] = [] + let componentTags: string[] = [] let energyIds: string[] = [] if (compRes.ok) { const cj = await compRes.json() - const comps = cj.components || cj || [] - componentIds = comps.map((c: { library_id?: string }) => c.library_id).filter(Boolean) - energyIds = comps.flatMap((c: { energy_source_ids?: string[] }) => c.energy_source_ids || []) + const comps = (cj.components || cj || []) as Array<{ library_id?: string; component_type?: string; name?: string; energy_source_ids?: string[] }> + // Use library_ids if available, otherwise derive tags from component names/types + const libIds = comps.map((c) => c.library_id).filter(Boolean) as string[] + if (libIds.length > 0) { + componentTags = libIds + } else { + // Derive tags from component names for pattern matching + const tagMap: Record = { + sensor: ['sensor', 'has_sensor'], software: ['software', 'has_software'], + firmware: ['firmware', 'has_firmware'], ai_model: ['has_ai', 'ai_model'], + hmi: ['hmi', 'display'], electrical: ['electric_motor', 'electric_drive'], + network: ['networked', 'ethernet'], actuator: ['actuator', 'hydraulic'], + mechanical: ['moving_mechanical_parts'], hydraulic: ['hydraulic'], + } + const nameKeywords: Record = { + roboter: ['cobot', 'robot_arm'], motor: ['electric_motor', 'electric_drive'], + scanner: ['sensor', 'safety_scanner'], sps: ['controller', 'plc'], + steuerung: ['controller', 'plc'], greifer: ['actuator', 'gripper'], + schutzzaun: ['safety_fence'], lichtgitter: ['light_curtain'], + kamera: ['camera', 'sensor'], ventil: ['valve', 'pneumatic'], + } + const tags = new Set() + for (const c of comps) { + const typeTags = tagMap[c.component_type || ''] || ['moving_mechanical_parts'] + typeTags.forEach((t) => tags.add(t)) + const nameLower = (c.name || '').toLowerCase() + for (const [kw, kwTags] of Object.entries(nameKeywords)) { + if (nameLower.includes(kw)) kwTags.forEach((t) => tags.add(t)) + } + } + componentTags = [...tags] + } + energyIds = comps.flatMap((c) => c.energy_source_ids || []) } const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/delta-analysis`, { @@ -124,13 +154,15 @@ export function useOperationalStates(projectId: string) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current: { - component_library_ids: componentIds, + component_library_ids: componentTags, energy_source_ids: energyIds, + custom_tags: componentTags, operational_states: metadataRef.current.operational_states || [], }, proposed: { - component_library_ids: componentIds, + component_library_ids: componentTags, energy_source_ids: energyIds, + custom_tags: componentTags, operational_states: states, }, }), diff --git a/admin-compliance/e2e/specs/iace-extensions.spec.ts b/admin-compliance/e2e/specs/iace-extensions.spec.ts new file mode 100644 index 0000000..049d135 --- /dev/null +++ b/admin-compliance/e2e/specs/iace-extensions.spec.ts @@ -0,0 +1,218 @@ +import { test, expect, Page } from '@playwright/test' + +/** + * IACE Erweiterungen 2-5 — E2E Tests + * FMEA Worksheet, Delta Analysis, Knowledge Graph + * + * Run with: + * npx playwright test e2e/specs/iace-extensions.spec.ts --config e2e/playwright-live.config.ts --reporter=list + */ + +const BASE = 'https://macmini:3007' + +const PROJECTS = [ + { id: 'bb7d5b88-469d-401f-a0e3-ae5b867e4a1c', name: 'Kniehebelpresse HP-500' }, + { id: 'a4c4031e-75a5-461e-a575-159f1eabd6b3', name: 'EIGENBAU-Zelle (Cobot)' }, + { id: 'c43af8df-14e0-43ff-b26f-ab425f803e53', name: 'Gleichstrom-/Asynchronmotor' }, + { id: '3e0808b2-2eed-4e82-b35d-6dd6857bc379', name: 'Schwingarm-Rundtaktanlage' }, +] as const + +async function dismissCookieBanner(page: Page) { + try { + const acceptBtn = page.locator('button', { hasText: 'Nur notwendige Cookies' }) + if (await acceptBtn.isVisible({ timeout: 2000 })) { + await acceptBtn.click({ force: true }) + await page.waitForTimeout(800) + } + } catch { /* not present */ } +} + +async function goTo(page: Page, path: string) { + await page.goto(`${BASE}${path}`, { waitUntil: 'domcontentloaded', timeout: 30000 }) + await dismissCookieBanner(page) + try { await page.locator('h1').first().waitFor({ state: 'visible', timeout: 15000 }) } catch { /* ignore */ } + await page.waitForTimeout(2000) + await dismissCookieBanner(page) +} + +async function assertNoAppError(page: Page) { + const body = await page.textContent('body') + expect(body).not.toContain('Application error') + expect(body).not.toContain('Unhandled Runtime Error') +} + +// --------------------------------------------------------------------------- +// 1. FMEA Worksheet (Erweiterung 2) +// --------------------------------------------------------------------------- + +for (const project of PROJECTS) { + test.describe(`FMEA: ${project.name}`, () => { + test.setTimeout(60_000) + + test('page loads', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/fmea`) + await assertNoAppError(page) + await expect(page.locator('h1')).toContainText('FMEA', { timeout: 15000 }) + }) + + test('stats cards visible', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/fmea`) + const body = await page.innerText('body') + expect(body).toContain('Gesamt') + expect(body).toContain('RPZ') + }) + + test('table columns present', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/fmea`) + await page.waitForTimeout(3000) + const body = await page.innerText('body') + expect(body).toContain('Komponente') + expect(body).toContain('Fehlerart') + }) + + test('RPZ threshold info visible', async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/fmea`) + const body = await page.innerText('body') + expect(body).toContain('Handlungsbedarf') + expect(body).toContain('Akzeptabel') + }) + }) +} + +// --------------------------------------------------------------------------- +// 2. Knowledge Graph (Erweiterung 5) +// --------------------------------------------------------------------------- + +test.describe('Knowledge Graph', () => { + test.setTimeout(60_000) + + test('page loads', async ({ page }) => { + await goTo(page, `/sdk/iace/${PROJECTS[0].id}/knowledge-graph`) + await assertNoAppError(page) + await expect(page.locator('h1')).toContainText('Knowledge Graph', { timeout: 15000 }) + }) + + test('legend shows 3 node types', async ({ page }) => { + await goTo(page, `/sdk/iace/${PROJECTS[0].id}/knowledge-graph`) + const body = await page.innerText('body') + expect(body).toContain('Komponente') + expect(body).toContain('Gefaehrdung') + expect(body).toContain('Massnahme') + }) + + for (const project of PROJECTS) { + test(`${project.name} — loads without error`, async ({ page }) => { + await goTo(page, `/sdk/iace/${project.id}/knowledge-graph`) + await assertNoAppError(page) + }) + } +}) + +// --------------------------------------------------------------------------- +// 3. Sidebar — FMEA + Knowledge Graph entries +// --------------------------------------------------------------------------- + +test.describe('Sidebar Extensions', () => { + test.setTimeout(60_000) + + test('FMEA nav entry visible', async ({ page }) => { + await goTo(page, `/sdk/iace/${PROJECTS[0].id}`) + await expect(page.locator('a', { hasText: 'FMEA' })).toBeVisible({ timeout: 10000 }) + }) + + test('Knowledge Graph nav entry visible', async ({ page }) => { + await goTo(page, `/sdk/iace/${PROJECTS[0].id}`) + await expect(page.locator('a', { hasText: 'Knowledge Graph' })).toBeVisible({ timeout: 10000 }) + }) +}) + +// --------------------------------------------------------------------------- +// 4. Delta Analysis API — returns valid structure +// --------------------------------------------------------------------------- + +test.describe('Delta Analysis API', () => { + test.setTimeout(60_000) + const API = 'https://macmini:8093/sdk/v1/iace' + const HEADERS = { 'Content-Type': 'application/json', 'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } + + test('returns valid structure with component tags', async ({ request }) => { + const tags = ['moving_mechanical_parts', 'electric_motor', 'controller', 'sensor'] + const res = await request.post(`${API}/projects/${PROJECTS[0].id}/delta-analysis`, { + headers: HEADERS, + data: { + current: { component_library_ids: tags, custom_tags: tags, operational_states: [] }, + proposed: { component_library_ids: tags, custom_tags: tags, operational_states: ['automatic_operation', 'maintenance'] }, + }, + }) + expect(res.ok()).toBeTruthy() + const data = await res.json() + expect(data).toHaveProperty('added_patterns') + expect(data).toHaveProperty('removed_patterns') + }) + + test('empty input returns empty result', async ({ request }) => { + const res = await request.post(`${API}/projects/${PROJECTS[0].id}/delta-analysis`, { + headers: HEADERS, + data: { + current: { component_library_ids: [], operational_states: [] }, + proposed: { component_library_ids: [], operational_states: ['maintenance'] }, + }, + }) + expect(res.ok()).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// 5. Failure Modes API — returns entries per component type +// --------------------------------------------------------------------------- + +test.describe('Failure Modes API', () => { + test.setTimeout(30_000) + const API = 'https://macmini:8093/sdk/v1/iace' + const HEADERS = { 'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } + + test('GET /failure-modes returns 150 FMs', async ({ request }) => { + const res = await request.get(`${API}/failure-modes`, { headers: HEADERS }) + expect(res.ok()).toBeTruthy() + const data = await res.json() + expect(data.total).toBeGreaterThanOrEqual(150) + }) + + test('filter by sensor type', async ({ request }) => { + const res = await request.get(`${API}/failure-modes?component_type=sensor`, { headers: HEADERS }) + expect(res.ok()).toBeTruthy() + const data = await res.json() + expect(data.total).toBeGreaterThan(0) + for (const fm of data.failure_modes) { + expect(fm.component_type).toBe('sensor') + } + }) +}) + +// --------------------------------------------------------------------------- +// 6. Operational States — delta produces non-zero results +// --------------------------------------------------------------------------- + +test.describe('Op States Delta — with tags', () => { + test.setTimeout(60_000) + test.describe.configure({ retries: 1 }) + + test('delta preview shows changes after selecting states', async ({ page }) => { + await goTo(page, `/sdk/iace/${PROJECTS[1].id}/operational-states`) + await expect(page.locator('text=Hochfahren').first()).toBeVisible({ timeout: 20000 }) + await page.waitForTimeout(2000) + // Select several states + await page.locator('button').filter({ hasText: 'Automatikbetrieb' }).click({ force: true }) + await page.waitForTimeout(300) + await page.locator('button').filter({ hasText: 'Wartung' }).click({ force: true }) + await page.waitForTimeout(300) + await page.locator('button').filter({ hasText: 'Not-Halt' }).click({ force: true }) + await page.waitForTimeout(1000) + // Click delta + await page.locator('button', { hasText: 'Delta berechnen' }).click({ force: true }) + await page.waitForTimeout(5000) + // Should show delta section with results + const body = await page.innerText('body') + expect(body).toContain('Delta-Vorschau') + }) +})