fix(iace): Delta + FMEA — derive component tags from names when library_id missing
Build + Deploy / build-admin-compliance (push) Successful in 2m7s
Build + Deploy / build-backend-compliance (push) Successful in 3m42s
Build + Deploy / build-ai-sdk (push) Successful in 48s
Build + Deploy / build-developer-portal (push) Successful in 1m8s
Build + Deploy / build-tts (push) Successful in 1m38s
Build + Deploy / build-document-crawler (push) Successful in 1m0s
Build + Deploy / build-dsms-gateway (push) Successful in 29s
Build + Deploy / build-dsms-node (push) Successful in 19s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 15s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m36s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 51s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 3m28s
Build + Deploy / build-admin-compliance (push) Successful in 2m7s
Build + Deploy / build-backend-compliance (push) Successful in 3m42s
Build + Deploy / build-ai-sdk (push) Successful in 48s
Build + Deploy / build-developer-portal (push) Successful in 1m8s
Build + Deploy / build-tts (push) Successful in 1m38s
Build + Deploy / build-document-crawler (push) Successful in 1m0s
Build + Deploy / build-dsms-gateway (push) Successful in 29s
Build + Deploy / build-dsms-node (push) Successful in 19s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 15s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m36s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 51s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 3m28s
Auto-created components have no library_id. Delta analysis and FMEA now derive pattern-engine-compatible tags from component names (e.g. "Roboter" → cobot/robot_arm, "SPS" → controller/plc, "Scanner" → sensor). Also: new E2E test file iace-extensions.spec.ts (FMEA, Knowledge Graph, Delta API, Failure Modes API). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, FailureMode[]> = {}
|
||||
|
||||
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<string, string[]> = {
|
||||
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
|
||||
|
||||
+39
-7
@@ -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<string, string[]> = {
|
||||
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<string, string[]> = {
|
||||
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<string>()
|
||||
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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user