use super::*; use crate::policy::{KHR_PORTABILITY_SUBSET_EXTENSION, KHR_SWAPCHAIN_EXTENSION}; use crate::shader_manifest::{ SHADER_COMPILER_BINARY_SHA256, SHADER_COMPILER_NAME, SHADER_COMPILER_VERSION, SHADER_MANIFEST_SCHEMA, SHADER_TARGET_ENV, SPIRV_MAGIC, SPIRV_VALIDATOR_BINARY_SHA256, SPIRV_VALIDATOR_NAME, SPIRV_VALIDATOR_VERSION, SPIRV_VERSION_1_0, TRIANGLE_VERTEX_COMPILE_COMMAND, TRIANGLE_VERTEX_SOURCE_PATH, TRIANGLE_VERTEX_SOURCE_SHA256, TRIANGLE_VERTEX_SPIRV_PATH, TRIANGLE_VERTEX_VALIDATE_COMMAND, }; use crate::*; use fparkan_platform::RenderRequest; use fparkan_render::{ DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase, }; use fparkan_render::{RenderBackend, RenderError}; #[test] fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(), RenderError> { let mut backend = VulkanPlanningBackend::new(); let request = RenderRequest { presentation: fparkan_platform::PresentationMode::Immediate, ..RenderRequest::conservative() }; backend.set_render_request(request); assert_eq!(backend.render_request(), request); assert_eq!(backend.report().request.current_request, request); assert_eq!(backend.report().request.request_updates, 1); let commands = fparkan_render::RenderCommandList { commands: vec![ RenderCommand::BeginFrame, RenderCommand::Draw(DrawCommand { id: DrawId(11), phase: RenderPhase::Opaque, object_id: None, mesh: GpuMeshId(1), material: GpuMaterialId(2), transform: [1.0; 16], range: IndexRange { start: 0, count: 3 }, stable_order: 7, }), RenderCommand::EndFrame, ], }; backend.execute(&commands)?; assert_eq!(backend.state(), VulkanPlanningBackendState::Configured); assert_eq!(backend.report().execution.planned_frames, 1); assert_eq!(backend.report().execution.submission_plans, 1); assert_eq!(backend.report().execution.simulated_presents, 1); assert!(backend.report().execution.last_capture_size > 0); assert_eq!( backend.report().last_frame_submission, Some(VulkanFrameSubmissionPlan { schema: 1, frames_in_flight: 2, command_buffers: 2, semaphores_per_frame: 2, fences_per_frame: 1, draw_count: 1, indexed_vertex_count: 3, }) ); Ok(()) } #[test] fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> { let commands = fparkan_render::RenderCommandList { commands: vec![ RenderCommand::BeginFrame, RenderCommand::Draw(DrawCommand { id: DrawId(11), phase: RenderPhase::Opaque, object_id: None, mesh: GpuMeshId(1), material: GpuMaterialId(2), transform: [1.0; 16], range: IndexRange { start: 0, count: 3 }, stable_order: 7, }), RenderCommand::EndFrame, ], }; let swapchain = VulkanSwapchainPlan { schema: 1, extent: (1, 1), format: VulkanSurfaceFormat { format: vk::Format::B8G8R8A8_SRGB.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), }, present_mode: vk::PresentModeKHR::FIFO.as_raw(), image_count: 3, }; let plan = plan_vulkan_frame_submission(&swapchain, &commands)?; assert_eq!(plan.frames_in_flight, 2); assert_eq!(plan.command_buffers, 3); assert_eq!(plan.draw_count, 1); assert_eq!(plan.indexed_vertex_count, 3); assert_eq!( render_frame_submission_plan_json(&plan), "{\"schema\":1,\"frames_in_flight\":2,\"command_buffers\":3,\"semaphores_per_frame\":2,\"fences_per_frame\":1,\"draw_count\":1,\"indexed_vertex_count\":3}" ); Ok(()) } #[test] fn device_scoring_is_deterministic_and_prefers_discrete_unified_queue() { let devices = vec![ device("SwiftShader", VulkanDeviceType::Cpu, 0, true, false), device("Discrete", VulkanDeviceType::DiscreteGpu, 1, true, false), device( "Integrated", VulkanDeviceType::IntegratedGpu, 2, true, false, ), ]; let report = select_physical_device(&devices).expect("selected device"); assert_eq!(report.device_name, "Discrete"); assert_eq!(report.graphics_queue_family, 1); assert_eq!(report.present_queue_family, 1); assert!(!report.portability_subset); assert_eq!(report.enabled_extensions, vec![KHR_SWAPCHAIN_EXTENSION]); } #[test] fn device_selection_skips_rejected_candidates_before_accepting_valid_gpu() { let mut rejected = device("Rejected", VulkanDeviceType::DiscreteGpu, 0, true, false); rejected.queue_families[0].present = false; let accepted = device("Accepted", VulkanDeviceType::IntegratedGpu, 2, true, false); let report = select_physical_device(&[rejected, accepted]).expect("selected fallback device"); assert_eq!(report.device_name, "Accepted"); assert_eq!(report.graphics_queue_family, 2); assert_eq!(report.present_queue_family, 2); assert_eq!( report.rejected_devices, vec![VulkanRejectedDeviceReport { device_name: "Rejected".to_string(), reason_code: "no_present_queue", reason: "Vulkan device Rejected has no present queue".to_string(), }] ); } #[test] fn queue_family_selection_prefers_lowest_index_unified_family() { let mut candidate = device( "Unified later in list", VulkanDeviceType::DiscreteGpu, 7, true, false, ); candidate.queue_families = vec![ VulkanQueueFamily { index: 9, graphics: true, present: true, }, VulkanQueueFamily { index: 3, graphics: true, present: true, }, VulkanQueueFamily { index: 1, graphics: true, present: false, }, ]; let report = select_physical_device(&[candidate]).expect("selected unified queue"); assert_eq!(report.graphics_queue_family, 3); assert_eq!(report.present_queue_family, 3); } #[test] fn portability_subset_is_reported_and_enabled_when_exposed() { let report = select_physical_device(&[device( "MoltenVK", VulkanDeviceType::IntegratedGpu, 0, true, true, )]) .expect("selected device"); assert!(report.portability_subset); assert_eq!( report.enabled_extensions, vec![ KHR_SWAPCHAIN_EXTENSION.to_string(), KHR_PORTABILITY_SUBSET_EXTENSION.to_string() ] ); } #[test] fn missing_loader_candidates_are_reported() { assert_eq!( select_physical_device(&[]), Err(VulkanCapabilityError::NoPhysicalDevice) ); } #[test] fn rejects_low_api_version() { let mut candidate = device("Old GPU", VulkanDeviceType::DiscreteGpu, 0, true, false); candidate.api_version = vk::API_VERSION_1_0; assert!(matches!( select_physical_device(&[candidate]), Err(VulkanCapabilityError::ApiVersionTooLow { .. }) )); } #[test] fn rejects_missing_graphics_present_swapchain_and_format() { let mut no_graphics = device("No graphics", VulkanDeviceType::DiscreteGpu, 0, true, false); no_graphics.queue_families[0].graphics = false; assert!(matches!( select_physical_device(&[no_graphics]), Err(VulkanCapabilityError::NoGraphicsQueue { .. }) )); let mut no_present = device("No present", VulkanDeviceType::DiscreteGpu, 0, true, false); no_present.queue_families[0].present = false; assert!(matches!( select_physical_device(&[no_present]), Err(VulkanCapabilityError::NoPresentQueue { .. }) )); let no_swapchain = device( "No swapchain", VulkanDeviceType::DiscreteGpu, 0, false, false, ); assert!(matches!( select_physical_device(&[no_swapchain]), Err(VulkanCapabilityError::MissingSwapchainExtension { .. }) )); let mut no_format = device("No format", VulkanDeviceType::DiscreteGpu, 0, true, false); no_format.surface_formats.clear(); assert!(matches!( select_physical_device(&[no_format]), Err(VulkanCapabilityError::MissingSurfaceFormat { .. }) )); let mut no_present_mode = device( "No present mode", VulkanDeviceType::DiscreteGpu, 0, true, false, ); no_present_mode.present_modes.clear(); assert!(matches!( select_physical_device(&[no_present_mode]), Err(VulkanCapabilityError::MissingPresentMode { .. }) )); let mut no_color_attachment = device( "No color attachment", VulkanDeviceType::DiscreteGpu, 0, true, false, ); no_color_attachment .surface_capabilities .supported_usage_flags = vk::ImageUsageFlags::TRANSFER_DST.as_raw(); assert!(matches!( select_physical_device(&[no_color_attachment]), Err(VulkanCapabilityError::MissingColorAttachmentUsage { .. }) )); } #[test] fn capability_report_json_is_stable() { let mut rejected = device("Rejected", VulkanDeviceType::IntegratedGpu, 0, true, false); rejected.present_modes.clear(); let report = select_physical_device(&[ rejected, device("GPU \"A\"", VulkanDeviceType::DiscreteGpu, 3, true, false), ]) .expect("selected device"); assert_eq!( render_capability_report_json(&report), "{\"schema\":1,\"vulkan_api\":\"1.1.0\",\"device_name\":\"GPU \\\"A\\\"\",\"score\":1101,\"graphics_queue_family\":3,\"present_queue_family\":3,\"portability_subset\":false,\"enabled_extensions\":[\"VK_KHR_swapchain\"],\"rejected_devices\":[{\"device_name\":\"Rejected\",\"reason_code\":\"missing_present_mode\",\"reason\":\"Vulkan device Rejected has no supported present mode\"}]}" ); } #[test] fn loader_probe_report_json_is_stable() { assert_eq!( vulkan_entry_symbol_name().to_bytes(), b"vkGetInstanceProcAddr" ); assert_eq!( render_loader_probe_report_json(&VulkanLoaderProbeReport { schema: 1, loader_available: true, instance_api_version: vk::API_VERSION_1_2, }), "{\"schema\":1,\"loader_available\":true,\"instance_api\":\"1.2.0\"}" ); } #[test] fn loader_error_display_is_actionable() { assert_eq!( VulkanLoaderError::Unavailable { message: "dlopen failed".to_string(), } .to_string(), "Vulkan loader is unavailable: dlopen failed" ); } #[test] fn instance_plan_is_sorted_deduplicated_and_portability_aware() { let plan = plan_vulkan_instance(&VulkanInstanceConfig { application_name: "FParkan".to_string(), required_extensions: vec![ "VK_KHR_surface".to_string(), KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(), "VK_KHR_surface".to_string(), ], enable_portability_enumeration: true, enable_validation: true, }); assert_eq!( render_instance_plan_json(&plan), "{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_EXT_debug_utils\",\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}" ); } #[test] fn instance_plan_adds_portability_extension_when_requested() { let plan = plan_vulkan_instance(&VulkanInstanceConfig { application_name: "FParkan".to_string(), required_extensions: vec!["VK_KHR_surface".to_string()], enable_portability_enumeration: true, enable_validation: false, }); assert_eq!( plan.enabled_extensions, vec![ KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(), "VK_KHR_surface".to_string() ] ); assert_eq!(plan.create_flags, 1); } #[test] fn invalid_instance_extension_name_is_reported_before_loader_use() { assert_eq!( cstring_vec(&["bad\0extension".to_string()]), Err(VulkanInstanceError::InvalidExtensionName { extension: "bad\0extension".to_string() }) ); } #[test] fn missing_instance_extension_is_reported_before_create_instance() { assert_eq!( ensure_instance_extensions_available( &[ "VK_EXT_debug_utils".to_string(), "VK_KHR_surface".to_string(), ], &["VK_KHR_surface".to_string()], ), Err(VulkanInstanceError::MissingInstanceExtension { extension: "VK_EXT_debug_utils".to_string(), }) ); } #[test] fn surface_plan_requires_native_handles() { assert_eq!( plan_vulkan_surface(None), Err(VulkanSurfaceError::MissingNativeHandles) ); assert_eq!( VulkanSurfaceError::MissingNativeHandles.to_string(), "native window/display handles are required for Vulkan surface creation" ); } #[test] fn surface_plan_json_is_stable() { assert_eq!( render_surface_plan_json(&VulkanSurfacePlan { schema: 1, required_instance_extensions: vec![ "VK_KHR_surface".to_string(), "VK_EXT_metal_surface".to_string(), ], }), "{\"schema\":1,\"required_instance_extensions\":[\"VK_KHR_surface\",\"VK_EXT_metal_surface\"]}" ); } #[test] fn static_surface_extension_name_is_decoded() { let name = extension_name(ash::khr::surface::NAME.as_ptr()).expect("extension name"); assert_eq!(name, "VK_KHR_surface"); } #[test] fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() { let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan"); assert_eq!( plan.format, VulkanSurfaceFormat { format: vk::Format::B8G8R8A8_SRGB.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), } ); assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw()); assert_eq!(plan.extent, (1024, 720)); assert_eq!(plan.image_count, 3); } #[test] fn swapchain_plan_uses_fifo_and_current_extent_fallbacks() { let mut request = swapchain_request(); request.preferred_present_mode = vk::PresentModeKHR::IMMEDIATE.as_raw(); request.present_modes = vec![vk::PresentModeKHR::FIFO.as_raw()]; request.capabilities.current_extent = Some((800, 600)); let plan = plan_vulkan_swapchain(&request).expect("swapchain plan"); assert_eq!(plan.present_mode, vk::PresentModeKHR::FIFO.as_raw()); assert_eq!(plan.extent, (800, 600)); } #[test] fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() { let mut request = swapchain_request(); request.formats = vec![VulkanSurfaceFormat { format: vk::Format::UNDEFINED.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), }]; let plan = plan_vulkan_swapchain(&request).expect("swapchain plan"); assert_eq!( plan.format, VulkanSurfaceFormat { format: vk::Format::B8G8R8A8_SRGB.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), } ); } #[test] fn swapchain_plan_rejects_missing_surface_data_and_empty_extent() { let mut request = swapchain_request(); request.formats.clear(); assert_eq!( plan_vulkan_swapchain(&request), Err(VulkanSwapchainError::MissingSurfaceFormat) ); let mut request = swapchain_request(); request.present_modes.clear(); assert_eq!( plan_vulkan_swapchain(&request), Err(VulkanSwapchainError::MissingPresentMode) ); let mut request = swapchain_request(); request.capabilities.current_extent = Some((0, 600)); assert_eq!( plan_vulkan_swapchain(&request), Err(VulkanSwapchainError::EmptyExtent) ); } #[test] fn swapchain_plan_json_and_recreation_reports_are_stable() { let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan"); assert_eq!( render_swapchain_plan_json(&plan), "{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3}" ); let report = swapchain_recreation_report( VulkanSwapchainRecreationReason::OutOfDate, (1024, 720), (1280, 720), ); assert_eq!( render_swapchain_recreation_report_json(&report), "{\"schema\":1,\"reason\":\"out_of_date\",\"previous_extent\":[1024,720],\"next_extent\":[1280,720]}" ); } #[test] fn triangle_shader_manifest_hashes_are_stable() { let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest"); assert_eq!(report.schema, SHADER_MANIFEST_SCHEMA); assert_eq!(report.target_env, SHADER_TARGET_ENV); assert_eq!( report.compiler, VulkanShaderToolManifest { name: SHADER_COMPILER_NAME, version: SHADER_COMPILER_VERSION, binary_sha256: SHADER_COMPILER_BINARY_SHA256, } ); assert_eq!( report.validator, VulkanShaderToolManifest { name: SPIRV_VALIDATOR_NAME, version: SPIRV_VALIDATOR_VERSION, binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256, } ); assert_eq!(report.modules.len(), 2); assert_eq!(report.modules[0].name, "triangle.vert"); assert_eq!(report.modules[0].stage, VulkanShaderStage::Vertex); assert_eq!(report.modules[0].source_path, TRIANGLE_VERTEX_SOURCE_PATH); assert_eq!( report.modules[0].source_sha256, TRIANGLE_VERTEX_SOURCE_SHA256 ); assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH); assert_eq!(report.modules[0].word_count, 253); assert_eq!( report.modules[0].sha256, "9023b1cc856c98ecd21755596c4e9d1e62cc63e1787f8c43ada2101544e8d0d1" ); assert_eq!(report.modules[0].descriptor_sets, 0); assert_eq!(report.modules[0].push_constant_bytes, 0); assert_eq!( report.modules[0].compile_command, TRIANGLE_VERTEX_COMPILE_COMMAND ); assert_eq!( report.modules[0].validate_command, TRIANGLE_VERTEX_VALIDATE_COMMAND ); assert!(!report.modules[0].interface_hash.is_empty()); assert_eq!( report.modules[1].sha256, "6efe2c9716ae845c471ecbaac2c83e56a17a37dc017dd63f0a05f0d9161f44ba" ); assert_eq!( report.manifest_hash, "20fb84fb6edbd6897e2ea3c2ec3a6db3826a84b46c4efb69027c1cfc0119ccf2" ); } #[test] fn shader_manifest_report_json_is_stable() { let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest"); let json = render_shader_manifest_report_json(&report); assert!(json.contains(SHADER_COMPILER_NAME)); assert!(json.contains(SPIRV_VALIDATOR_NAME)); assert!(json.contains(TRIANGLE_VERTEX_SOURCE_PATH)); assert!(json.contains(TRIANGLE_VERTEX_COMPILE_COMMAND)); } #[test] fn checked_in_shader_manifest_matches_generated_report() { let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest"); assert_eq!( render_shader_manifest_report_json(&report), include_str!("../../shaders/manifest.json").trim() ); } #[test] fn shader_manifest_rejects_invalid_spirv_containers() { let mut module = triangle_shader_manifest().remove(0); module.words = &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0]; assert_eq!( validate_shader_manifest(&[module]), Err(VulkanShaderManifestError::InvalidMagic { name: "triangle.vert", found: 0xFFFF_FFFF, }) ); let mut module = triangle_shader_manifest().remove(0); module.words = &[SPIRV_MAGIC, 0, 0, 1, 0]; assert_eq!( validate_shader_manifest(&[module]), Err(VulkanShaderManifestError::UnsupportedVersion { name: "triangle.vert", found: 0, }) ); let mut module = triangle_shader_manifest().remove(0); module.words = &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0]; assert_eq!( validate_shader_manifest(&[module]), Err(VulkanShaderManifestError::InvalidBound { name: "triangle.vert", }) ); } fn device( name: &str, device_type: VulkanDeviceType, queue_index: u32, swapchain: bool, portability_subset: bool, ) -> VulkanPhysicalDeviceRecord { let mut extensions = Vec::new(); if swapchain { extensions.push(KHR_SWAPCHAIN_EXTENSION.to_string()); } if portability_subset { extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string()); } VulkanPhysicalDeviceRecord { name: name.to_string(), api_version: MIN_VULKAN_API_VERSION, device_type, extensions, queue_families: vec![VulkanQueueFamily { index: queue_index, graphics: true, present: true, }], surface_formats: vec![VulkanSurfaceFormat { format: vk::Format::B8G8R8A8_SRGB.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), }], present_modes: vec![ vk::PresentModeKHR::FIFO.as_raw(), vk::PresentModeKHR::MAILBOX.as_raw(), ], surface_capabilities: default_surface_capabilities(), } } fn swapchain_request() -> VulkanSwapchainRequest { VulkanSwapchainRequest { drawable_extent: (1280, 720), formats: vec![ VulkanSurfaceFormat { format: vk::Format::R8G8B8A8_UNORM.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), }, VulkanSurfaceFormat { format: vk::Format::B8G8R8A8_SRGB.as_raw(), color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), }, ], present_modes: vec![ vk::PresentModeKHR::FIFO.as_raw(), vk::PresentModeKHR::MAILBOX.as_raw(), ], capabilities: default_surface_capabilities(), preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(), } } fn default_surface_capabilities() -> VulkanSwapchainSurfaceCapabilities { VulkanSwapchainSurfaceCapabilities { current_extent: None, min_extent: (320, 240), max_extent: (1024, 768), min_image_count: 2, max_image_count: 3, supported_usage_flags: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(), } }