diff options
Diffstat (limited to 'adapters/fparkan-render-vulkan/src/ffi/tests.rs')
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi/tests.rs | 690 |
1 files changed, 690 insertions, 0 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs new file mode 100644 index 0000000..097cc43 --- /dev/null +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -0,0 +1,690 @@ +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); +} + +#[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 report = select_physical_device(&[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\"]}" + ); +} + +#[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, + "725529e9449fa53017e7df75f3f14c76d53479a5a7617d55ec78280b3059bc44" + ); +} + +#[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(), + } +} |
