| /* | |
| * Copyright 2025 The Android Open Source Project | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| package androidx.webgpu | |
| import androidx.test.filters.SmallTest | |
| import androidx.webgpu.helper.WebGpu | |
| import androidx.webgpu.helper.createWebGpu | |
| import java.util.concurrent.Executors | |
| import kotlinx.coroutines.CoroutineDispatcher | |
| import kotlinx.coroutines.CoroutineScope | |
| import kotlinx.coroutines.ExecutorCoroutineDispatcher | |
| import kotlinx.coroutines.asCoroutineDispatcher | |
| import kotlinx.coroutines.cancel | |
| import kotlinx.coroutines.launch | |
| import kotlinx.coroutines.runBlocking | |
| import org.junit.Test | |
| import junit.framework.TestCase.assertEquals | |
| import org.junit.After | |
| import org.junit.Before | |
| @Suppress("UNUSED_VARIABLE") | |
| @SmallTest | |
| class ShaderModuleTest { | |
| private val dispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { runnable -> | |
| Thread(runnable, "Test-WebGPU-Thread") | |
| }.asCoroutineDispatcher() | |
| private val testScope = CoroutineScope(dispatcher) | |
| private lateinit var device: GPUDevice | |
| private lateinit var webGpu: WebGpu | |
| private val invalidShader = """ | |
| @vertex fn main() -> @builtin(position) vec4<f32> { | |
| return unknown(0.0, 0.0, 0.0, 1.0); | |
| } | |
| """.trimIndent() | |
| @Before | |
| fun setup(): Unit = runBlocking { | |
| webGpu = createWebGpu(dispatcher) | |
| device = webGpu.device | |
| testScope.launch { | |
| webGpu.processEventsLoop() | |
| } | |
| } | |
| @After | |
| fun teardown() { | |
| if (::webGpu.isInitialized) { | |
| webGpu.close() | |
| } | |
| testScope.cancel() | |
| (dispatcher as? ExecutorCoroutineDispatcher)?.close() | |
| } | |
| // The info request should still succeed even if compilation fails. | |
| private suspend fun getCompilationInfo(code: String): GPUCompilationInfo { | |
| val shaderModule = | |
| device.createShaderModule( | |
| GPUShaderModuleDescriptor( | |
| shaderSourceWGSL = GPUShaderSourceWGSL( | |
| code | |
| ) | |
| ) | |
| ) | |
| return shaderModule.getCompilationInfo() | |
| } | |
| @Test | |
| fun getCompilationInfoReturnsNoErrorsForValidAsciiShader() { | |
| runBlocking { | |
| webGpu.execute { | |
| val code = """ | |
| @vertex fn main() -> @builtin(position) vec4<f32> { | |
| return vec4<f32>(0.0, 0.0, 0.0, 1.0); | |
| } | |
| """.trimIndent() | |
| val info = getCompilationInfo(code) | |
| val errorCount = info.messages.count { it.type == CompilationMessageType.Error } | |
| assertEquals(0, errorCount) | |
| } | |
| } | |
| } | |
| /** | |
| * Verifies that an invalid shader correctly produces a validation error | |
| * and a compilation error message. | |
| */ | |
| @Test | |
| fun invalidShader_producesACompilationError() { | |
| runBlocking { | |
| val unused = webGpu.execute { | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val info = getCompilationInfo(invalidShader) | |
| val unusedException = assertThrowsSuspend( | |
| "The operation should result in a validation error", | |
| ValidationException::class.java | |
| ) { | |
| val unusedError = device.popErrorScope() | |
| } | |
| val errorCount = info.messages.count { it.type == CompilationMessageType.Error } | |
| assertEquals(1, errorCount) | |
| } | |
| } | |
| } | |
| /** | |
| * Verifies that the primary compilation error for the invalid shader is reported | |
| * on the correct line number. | |
| */ | |
| @Test | |
| fun invalidShader_reportsCorrectLineNumber() { | |
| runBlocking { | |
| val unused = webGpu.execute { | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val info = getCompilationInfo(invalidShader) | |
| val errorMessage = info.messages.first { it.type == CompilationMessageType.Error } | |
| val expectedErrorLine = 2L | |
| assertEquals( | |
| "The error should be reported on line $expectedErrorLine.", | |
| expectedErrorLine, | |
| errorMessage.lineNum | |
| ) | |
| val unusedException = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedError = device.popErrorScope() | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Verifies that the absolute character offset reported in the error message is | |
| * mathematically consistent with its reported line number and line position (column). | |
| */ | |
| @Test | |
| fun invalidShader_reportsConsistentOffsetAndLinePosition() { | |
| runBlocking { | |
| val unused = webGpu.execute { | |
| device.pushErrorScope(ErrorFilter.Validation) | |
| val info = getCompilationInfo(invalidShader) | |
| val errorMessage = info.messages.first { it.type == CompilationMessageType.Error } | |
| // Pre-computation based on the error message's reported location | |
| val lines = invalidShader.split('\n') | |
| val lineIndex = (errorMessage.lineNum - 1).toInt() // 1-based line number to 0-based index | |
| val positionOffset = (errorMessage.linePos - 1) // 1-based column to 0-based offset | |
| // Calculate the expected offset from scratch | |
| val calculatedOffset = lines.take(lineIndex).sumOf { it.length + 1 } + positionOffset | |
| assertEquals( | |
| "Calculated offset from line/pos should match the message's absolute offset.", | |
| errorMessage.offset, | |
| calculatedOffset | |
| ) | |
| val unusedException = assertThrowsSuspend(ValidationException::class.java) { | |
| val unusedError = device.popErrorScope() | |
| } | |
| } | |
| } | |
| } | |
| } |