All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
Phase 1: Fix completeness gates G23 (require verified/rejected mitigations) and G09 (audit trail check) Phase 2: LLM-based tech-file section generation with 19 German prompts and RAG enrichment Phase 3: Multi-format document export (PDF/Excel/DOCX/Markdown/JSON) Phase 4: Company profile → IACE data flow with auto component/classification creation Phase 5: TipTap WYSIWYG editor replacing textarea for tech-file sections Phase 6: User journey tests, developer portal API reference, updated documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
488 lines
19 KiB
Go
488 lines
19 KiB
Go
package iace
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// buildFullValidContext returns a CompletenessContext where all required gates pass
|
|
// and the project is ready for CE export.
|
|
func buildFullValidContext() *CompletenessContext {
|
|
projectID := uuid.New()
|
|
componentID1 := uuid.New()
|
|
componentID2 := uuid.New()
|
|
hazardID1 := uuid.New()
|
|
hazardID2 := uuid.New()
|
|
hazardID3 := uuid.New()
|
|
mitigationID1 := uuid.New()
|
|
mitigationID2 := uuid.New()
|
|
mitigationID3 := uuid.New()
|
|
now := time.Now()
|
|
|
|
metadata, _ := json.Marshal(map[string]interface{}{
|
|
"operating_limits": "Temperature: -10 to 50C, Humidity: 10-90% RH",
|
|
"foreseeable_misuse": "Use without protective equipment, exceeding load capacity",
|
|
})
|
|
|
|
return &CompletenessContext{
|
|
Project: &Project{
|
|
ID: projectID,
|
|
TenantID: uuid.New(),
|
|
MachineName: "CNC-Fraese ProLine 5000",
|
|
MachineType: "cnc_milling_machine",
|
|
Manufacturer: "BreakPilot Maschinenbau GmbH",
|
|
Description: "5-Achsen CNC-Fraesmaschine fuer Praezisionsfertigung im Metallbau",
|
|
Status: ProjectStatusTechFile,
|
|
CEMarkingTarget: "2023/1230",
|
|
Metadata: metadata,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
Components: []Component{
|
|
{
|
|
ID: componentID1,
|
|
ProjectID: projectID,
|
|
Name: "Spindelantrieb",
|
|
ComponentType: ComponentTypeMechanical,
|
|
IsSafetyRelevant: true,
|
|
Description: "Hauptspindelantrieb mit Drehzahlregelung",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
{
|
|
ID: componentID2,
|
|
ProjectID: projectID,
|
|
Name: "SPS Steuerung",
|
|
ComponentType: ComponentTypeController,
|
|
IsSafetyRelevant: false,
|
|
IsNetworked: true,
|
|
Description: "Programmierbare Steuerung fuer Achsenbewegung",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
Classifications: []RegulatoryClassification{
|
|
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationAIAct, ClassificationResult: "Not applicable", RiskLevel: RiskLevelNegligible, Confidence: 0.95, CreatedAt: now, UpdatedAt: now},
|
|
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationMachineryRegulation, ClassificationResult: "Annex I", RiskLevel: RiskLevelHigh, Confidence: 0.9, CreatedAt: now, UpdatedAt: now},
|
|
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationNIS2, ClassificationResult: "Not in scope", RiskLevel: RiskLevelLow, Confidence: 0.85, CreatedAt: now, UpdatedAt: now},
|
|
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationCRA, ClassificationResult: "Default category", RiskLevel: RiskLevelMedium, Confidence: 0.88, CreatedAt: now, UpdatedAt: now},
|
|
},
|
|
Hazards: []Hazard{
|
|
{ID: hazardID1, ProjectID: projectID, ComponentID: componentID1, Name: "Quetschgefahr Spindel", Category: "mechanical", Description: "Quetschgefahr beim Werkzeugwechsel", Status: HazardStatusMitigated, CreatedAt: now, UpdatedAt: now},
|
|
{ID: hazardID2, ProjectID: projectID, ComponentID: componentID1, Name: "Schnittverletzung", Category: "mechanical", Description: "Schnittverletzung durch rotierende Fraeser", Status: HazardStatusMitigated, CreatedAt: now, UpdatedAt: now},
|
|
{ID: hazardID3, ProjectID: projectID, ComponentID: componentID2, Name: "Elektrischer Schlag", Category: "electrical", Description: "Kontakt mit spannungsfuehrenden Teilen", Status: HazardStatusAccepted, CreatedAt: now, UpdatedAt: now},
|
|
},
|
|
Assessments: []RiskAssessment{
|
|
{ID: uuid.New(), HazardID: hazardID1, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 3, Exposure: 2, Probability: 2, InherentRisk: 12, ControlMaturity: 4, ControlCoverage: 0.85, TestEvidenceStrength: 0.8, CEff: 0.75, ResidualRisk: 3.0, RiskLevel: RiskLevelLow, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
|
|
{ID: uuid.New(), HazardID: hazardID2, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 2, Exposure: 2, Probability: 1, InherentRisk: 4, ControlMaturity: 3, ControlCoverage: 0.9, TestEvidenceStrength: 0.7, CEff: 0.8, ResidualRisk: 0.8, RiskLevel: RiskLevelNegligible, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
|
|
{ID: uuid.New(), HazardID: hazardID3, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 2, Exposure: 1, Probability: 1, InherentRisk: 2, ControlMaturity: 4, ControlCoverage: 0.95, TestEvidenceStrength: 0.9, CEff: 0.9, ResidualRisk: 0.2, RiskLevel: RiskLevelNegligible, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
|
|
},
|
|
Mitigations: []Mitigation{
|
|
{ID: mitigationID1, HazardID: hazardID1, ReductionType: ReductionTypeDesign, Name: "Schutzhaube mit Verriegelung", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodTest, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
|
|
{ID: mitigationID2, HazardID: hazardID2, ReductionType: ReductionTypeProtective, Name: "Lichtschranke Arbeitsbereich", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodInspection, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
|
|
{ID: mitigationID3, HazardID: hazardID3, ReductionType: ReductionTypeInformation, Name: "Warnhinweis Hochspannung", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodReview, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
|
|
},
|
|
Evidence: []Evidence{
|
|
{ID: uuid.New(), ProjectID: projectID, MitigationID: &mitigationID1, FileName: "pruefbericht_schutzhaube.pdf", FilePath: "/evidence/pruefbericht_schutzhaube.pdf", FileHash: "sha256:abc123", FileSize: 524288, MimeType: "application/pdf", Description: "Pruefbericht Schutzhaubenverriegelung", UploadedBy: uuid.New(), CreatedAt: now},
|
|
{ID: uuid.New(), ProjectID: projectID, MitigationID: &mitigationID2, FileName: "lichtschranke_abnahme.pdf", FilePath: "/evidence/lichtschranke_abnahme.pdf", FileHash: "sha256:def456", FileSize: 1048576, MimeType: "application/pdf", Description: "Abnahmeprotokoll Lichtschranke", UploadedBy: uuid.New(), CreatedAt: now},
|
|
},
|
|
TechFileSections: []TechFileSection{
|
|
{ID: uuid.New(), ProjectID: projectID, SectionType: "risk_assessment_report", Title: "Risikobeurteilung nach ISO 12100", Content: "Vollstaendige Risikobeurteilung der CNC-Fraese...", Version: 1, Status: TechFileSectionStatusApproved, CreatedAt: now, UpdatedAt: now},
|
|
{ID: uuid.New(), ProjectID: projectID, SectionType: "hazard_log_combined", Title: "Gefaehrdungsprotokoll", Content: "Protokoll aller identifizierten Gefaehrdungen...", Version: 1, Status: TechFileSectionStatusApproved, CreatedAt: now, UpdatedAt: now},
|
|
},
|
|
HasAI: false,
|
|
PatternMatchingPerformed: true,
|
|
}
|
|
}
|
|
|
|
// findGate searches through a CompletenessResult for a gate with the given ID.
|
|
// Returns the gate and true if found, zero-value gate and false otherwise.
|
|
func findGate(result CompletenessResult, gateID string) (CompletenessGate, bool) {
|
|
for _, g := range result.Gates {
|
|
if g.ID == gateID {
|
|
return g, true
|
|
}
|
|
}
|
|
return CompletenessGate{}, false
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 1: Full User Journey
|
|
// ============================================================================
|
|
|
|
func TestCEWorkflow_FullUserJourney(t *testing.T) {
|
|
ctx := buildFullValidContext()
|
|
checker := NewCompletenessChecker()
|
|
|
|
// Step 1: Verify completeness check passes all required gates
|
|
result := checker.Check(ctx)
|
|
|
|
if !result.CanExport {
|
|
t.Error("CanExport should be true for fully valid project")
|
|
for _, g := range result.Gates {
|
|
if g.Required && !g.Passed {
|
|
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
|
|
}
|
|
}
|
|
}
|
|
|
|
if result.PassedRequired != result.TotalRequired {
|
|
t.Errorf("PassedRequired = %d, TotalRequired = %d; want all required gates to pass",
|
|
result.PassedRequired, result.TotalRequired)
|
|
}
|
|
|
|
// All required gates should individually pass
|
|
for _, g := range result.Gates {
|
|
if g.Required && !g.Passed {
|
|
t.Errorf("Required gate %s (%s) did not pass: %s", g.ID, g.Label, g.Details)
|
|
}
|
|
}
|
|
|
|
// Step 2: Export PDF and verify output
|
|
exporter := NewDocumentExporter()
|
|
|
|
pdfData, err := exporter.ExportPDF(
|
|
ctx.Project,
|
|
ctx.TechFileSections,
|
|
ctx.Hazards,
|
|
ctx.Assessments,
|
|
ctx.Mitigations,
|
|
ctx.Classifications,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ExportPDF returned error: %v", err)
|
|
}
|
|
if len(pdfData) == 0 {
|
|
t.Fatal("ExportPDF returned empty bytes")
|
|
}
|
|
if !bytes.HasPrefix(pdfData, []byte("%PDF-")) {
|
|
t.Errorf("PDF output does not start with %%PDF-, got first 10 bytes: %q", pdfData[:min(10, len(pdfData))])
|
|
}
|
|
|
|
// Step 3: Export Excel and verify output
|
|
xlsxData, err := exporter.ExportExcel(
|
|
ctx.Project,
|
|
ctx.TechFileSections,
|
|
ctx.Hazards,
|
|
ctx.Assessments,
|
|
ctx.Mitigations,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ExportExcel returned error: %v", err)
|
|
}
|
|
if len(xlsxData) == 0 {
|
|
t.Fatal("ExportExcel returned empty bytes")
|
|
}
|
|
if !bytes.HasPrefix(xlsxData, []byte("PK")) {
|
|
t.Errorf("Excel output does not start with PK (zip signature), got first 4 bytes: %x", xlsxData[:min(4, len(xlsxData))])
|
|
}
|
|
|
|
// Step 4: Export Markdown and verify output contains section titles
|
|
mdData, err := exporter.ExportMarkdown(ctx.Project, ctx.TechFileSections)
|
|
if err != nil {
|
|
t.Fatalf("ExportMarkdown returned error: %v", err)
|
|
}
|
|
if len(mdData) == 0 {
|
|
t.Fatal("ExportMarkdown returned empty bytes")
|
|
}
|
|
mdContent := string(mdData)
|
|
for _, section := range ctx.TechFileSections {
|
|
if !strings.Contains(mdContent, section.Title) {
|
|
t.Errorf("Markdown output missing section title %q", section.Title)
|
|
}
|
|
}
|
|
if !strings.Contains(mdContent, ctx.Project.MachineName) {
|
|
t.Errorf("Markdown output missing project name %q", ctx.Project.MachineName)
|
|
}
|
|
|
|
// Step 5: Export DOCX and verify output
|
|
docxData, err := exporter.ExportDOCX(ctx.Project, ctx.TechFileSections)
|
|
if err != nil {
|
|
t.Fatalf("ExportDOCX returned error: %v", err)
|
|
}
|
|
if len(docxData) == 0 {
|
|
t.Fatal("ExportDOCX returned empty bytes")
|
|
}
|
|
if !bytes.HasPrefix(docxData, []byte("PK")) {
|
|
t.Errorf("DOCX output does not start with PK (zip signature), got first 4 bytes: %x", docxData[:min(4, len(docxData))])
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 2: High Risk Not Acceptable blocks export
|
|
// ============================================================================
|
|
|
|
func TestCEWorkflow_HighRiskNotAcceptable(t *testing.T) {
|
|
ctx := buildFullValidContext()
|
|
|
|
// Override: make one hazard high-risk and not acceptable
|
|
hazardID := ctx.Hazards[0].ID
|
|
ctx.Assessments[0] = RiskAssessment{
|
|
ID: uuid.New(),
|
|
HazardID: hazardID,
|
|
Version: 2,
|
|
AssessmentType: AssessmentTypePostMitigation,
|
|
Severity: 5,
|
|
Exposure: 4,
|
|
Probability: 4,
|
|
InherentRisk: 80,
|
|
ControlMaturity: 2,
|
|
ControlCoverage: 0.3,
|
|
CEff: 0.2,
|
|
ResidualRisk: 64,
|
|
RiskLevel: RiskLevelHigh,
|
|
IsAcceptable: false,
|
|
AssessedBy: uuid.New(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
checker := NewCompletenessChecker()
|
|
result := checker.Check(ctx)
|
|
|
|
if result.CanExport {
|
|
t.Error("CanExport should be false when a high-risk hazard is not acceptable")
|
|
}
|
|
|
|
// Verify G24 (residual risk accepted) specifically fails
|
|
g24, found := findGate(result, "G24")
|
|
if !found {
|
|
t.Fatal("G24 gate not found in results")
|
|
}
|
|
if g24.Passed {
|
|
t.Error("G24 should fail when a hazard has RiskLevelHigh and IsAcceptable=false")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 3: Incomplete mitigations block export
|
|
// ============================================================================
|
|
|
|
func TestCEWorkflow_IncompleteMitigationsBlockExport(t *testing.T) {
|
|
ctx := buildFullValidContext()
|
|
|
|
// Override: set mitigations to planned status (not yet verified)
|
|
for i := range ctx.Mitigations {
|
|
ctx.Mitigations[i].Status = MitigationStatusPlanned
|
|
}
|
|
|
|
checker := NewCompletenessChecker()
|
|
result := checker.Check(ctx)
|
|
|
|
if result.CanExport {
|
|
t.Error("CanExport should be false when mitigations are in planned status")
|
|
}
|
|
|
|
// Verify G23 (mitigations verified) specifically fails
|
|
g23, found := findGate(result, "G23")
|
|
if !found {
|
|
t.Fatal("G23 gate not found in results")
|
|
}
|
|
if g23.Passed {
|
|
t.Error("G23 should fail when mitigations are still in planned status")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 4: Mitigation hierarchy warning (information-only still allows export)
|
|
// ============================================================================
|
|
|
|
func TestCEWorkflow_MitigationHierarchyWarning(t *testing.T) {
|
|
ctx := buildFullValidContext()
|
|
|
|
// Override: set all mitigations to information type only (no design or protective)
|
|
for i := range ctx.Mitigations {
|
|
ctx.Mitigations[i].ReductionType = ReductionTypeInformation
|
|
ctx.Mitigations[i].Status = MitigationStatusVerified
|
|
}
|
|
|
|
checker := NewCompletenessChecker()
|
|
result := checker.Check(ctx)
|
|
|
|
// Information-only mitigations are advisory; no gate blocks this scenario.
|
|
// The project should still be exportable.
|
|
if !result.CanExport {
|
|
t.Error("CanExport should be true even with information-only mitigations (advisory, not gated)")
|
|
for _, g := range result.Gates {
|
|
if g.Required && !g.Passed {
|
|
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify all required gates still pass
|
|
if result.PassedRequired != result.TotalRequired {
|
|
t.Errorf("PassedRequired = %d, TotalRequired = %d; want all required gates to pass with information-only mitigations",
|
|
result.PassedRequired, result.TotalRequired)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 5: AI components require extra tech file sections
|
|
// ============================================================================
|
|
|
|
func TestCEWorkflow_AIComponentsExtraSections(t *testing.T) {
|
|
checker := NewCompletenessChecker()
|
|
|
|
t.Run("AI without AI tech file sections fails G42", func(t *testing.T) {
|
|
ctx := buildFullValidContext()
|
|
ctx.HasAI = true
|
|
// Add AI Act classification (needed for G06 to pass with HasAI=true)
|
|
for i := range ctx.Classifications {
|
|
if ctx.Classifications[i].Regulation == RegulationAIAct {
|
|
ctx.Classifications[i].ClassificationResult = "High Risk"
|
|
ctx.Classifications[i].RiskLevel = RiskLevelHigh
|
|
}
|
|
}
|
|
// TechFileSections has risk_assessment_report and hazard_log_combined but no AI sections
|
|
|
|
result := checker.Check(ctx)
|
|
|
|
g42, found := findGate(result, "G42")
|
|
if !found {
|
|
t.Fatal("G42 gate not found in results")
|
|
}
|
|
if g42.Passed {
|
|
t.Error("G42 should fail when HasAI=true but AI tech file sections are missing")
|
|
}
|
|
if result.CanExport {
|
|
t.Error("CanExport should be false when G42 fails")
|
|
}
|
|
})
|
|
|
|
t.Run("AI with AI tech file sections passes G42", func(t *testing.T) {
|
|
ctx := buildFullValidContext()
|
|
ctx.HasAI = true
|
|
// Add AI Act classification
|
|
for i := range ctx.Classifications {
|
|
if ctx.Classifications[i].Regulation == RegulationAIAct {
|
|
ctx.Classifications[i].ClassificationResult = "High Risk"
|
|
ctx.Classifications[i].RiskLevel = RiskLevelHigh
|
|
}
|
|
}
|
|
// Add the required AI tech file sections
|
|
now := time.Now()
|
|
ctx.TechFileSections = append(ctx.TechFileSections,
|
|
TechFileSection{
|
|
ID: uuid.New(),
|
|
ProjectID: ctx.Project.ID,
|
|
SectionType: "ai_intended_purpose",
|
|
Title: "KI-Zweckbestimmung",
|
|
Content: "Bestimmungsgemaesse Verwendung des KI-Systems...",
|
|
Version: 1,
|
|
Status: TechFileSectionStatusApproved,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
TechFileSection{
|
|
ID: uuid.New(),
|
|
ProjectID: ctx.Project.ID,
|
|
SectionType: "ai_model_description",
|
|
Title: "KI-Modellbeschreibung",
|
|
Content: "Beschreibung des verwendeten KI-Modells...",
|
|
Version: 1,
|
|
Status: TechFileSectionStatusApproved,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
)
|
|
|
|
result := checker.Check(ctx)
|
|
|
|
g42, found := findGate(result, "G42")
|
|
if !found {
|
|
t.Fatal("G42 gate not found in results")
|
|
}
|
|
if !g42.Passed {
|
|
t.Errorf("G42 should pass when HasAI=true and both AI tech file sections are present; details: %s", g42.Details)
|
|
}
|
|
if !result.CanExport {
|
|
t.Error("CanExport should be true when all gates pass including G42 with AI sections")
|
|
for _, g := range result.Gates {
|
|
if g.Required && !g.Passed {
|
|
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 6: Export with empty/minimal project data
|
|
// ============================================================================
|
|
|
|
func TestCEWorkflow_ExportEmptyProject(t *testing.T) {
|
|
exporter := NewDocumentExporter()
|
|
|
|
minimalProject := &Project{
|
|
ID: uuid.New(),
|
|
TenantID: uuid.New(),
|
|
MachineName: "Leeres Testprojekt",
|
|
MachineType: "test",
|
|
Manufacturer: "TestCorp",
|
|
Status: ProjectStatusDraft,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
t.Run("PDF export with empty project succeeds", func(t *testing.T) {
|
|
data, err := exporter.ExportPDF(minimalProject, nil, nil, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExportPDF returned error for empty project: %v", err)
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportPDF returned empty bytes for empty project")
|
|
}
|
|
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
|
t.Errorf("PDF output does not start with %%PDF-, got first 10 bytes: %q", data[:min(10, len(data))])
|
|
}
|
|
})
|
|
|
|
t.Run("Excel export with empty project succeeds", func(t *testing.T) {
|
|
data, err := exporter.ExportExcel(minimalProject, nil, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExportExcel returned error for empty project: %v", err)
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportExcel returned empty bytes for empty project")
|
|
}
|
|
if !bytes.HasPrefix(data, []byte("PK")) {
|
|
t.Errorf("Excel output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
|
}
|
|
})
|
|
|
|
t.Run("Markdown export with empty project succeeds", func(t *testing.T) {
|
|
data, err := exporter.ExportMarkdown(minimalProject, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExportMarkdown returned error for empty project: %v", err)
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportMarkdown returned empty bytes for empty project")
|
|
}
|
|
mdContent := string(data)
|
|
if !strings.Contains(mdContent, minimalProject.MachineName) {
|
|
t.Errorf("Markdown output missing project name %q", minimalProject.MachineName)
|
|
}
|
|
if !strings.Contains(mdContent, "#") {
|
|
t.Error("Markdown output missing header markers")
|
|
}
|
|
})
|
|
|
|
t.Run("DOCX export with empty project succeeds", func(t *testing.T) {
|
|
data, err := exporter.ExportDOCX(minimalProject, nil)
|
|
if err != nil {
|
|
t.Fatalf("ExportDOCX returned error for empty project: %v", err)
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("ExportDOCX returned empty bytes for empty project")
|
|
}
|
|
if !bytes.HasPrefix(data, []byte("PK")) {
|
|
t.Errorf("DOCX output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
|
}
|
|
})
|
|
}
|