@forgeax/engine-assets-runtime 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/README.md +442 -175
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/aabb.unit.test.d.ts +2 -0
  4. package/dist/__tests__/aabb.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/artifact-integrity.unit.test.d.ts +2 -0
  6. package/dist/__tests__/artifact-integrity.unit.test.d.ts.map +1 -0
  7. package/dist/__tests__/artifact-io.unit.test.d.ts +2 -0
  8. package/dist/__tests__/artifact-io.unit.test.d.ts.map +1 -0
  9. package/dist/__tests__/asset-evidence-sdk.integration.test.d.ts +2 -0
  10. package/dist/__tests__/asset-evidence-sdk.integration.test.d.ts.map +1 -0
  11. package/dist/__tests__/asset-evidence.unit.test.d.ts +2 -0
  12. package/dist/__tests__/asset-evidence.unit.test.d.ts.map +1 -0
  13. package/dist/__tests__/asset-kind.unit.test.d.ts +2 -0
  14. package/dist/__tests__/asset-kind.unit.test.d.ts.map +1 -0
  15. package/dist/__tests__/asset-registry-catalog-reconcile.integration.test.d.ts +2 -0
  16. package/dist/__tests__/asset-registry-catalog-reconcile.integration.test.d.ts.map +1 -0
  17. package/dist/__tests__/asset-registry-catalog-reconcile.unit.test.d.ts +2 -0
  18. package/dist/__tests__/asset-registry-catalog-reconcile.unit.test.d.ts.map +1 -0
  19. package/dist/__tests__/asset-registry-catalog.unit.test.d.ts +2 -0
  20. package/dist/__tests__/asset-registry-catalog.unit.test.d.ts.map +1 -0
  21. package/dist/__tests__/asset-registry.pack-cache.test.d.ts +2 -0
  22. package/dist/__tests__/asset-registry.pack-cache.test.d.ts.map +1 -0
  23. package/dist/__tests__/asset-registry.unit.test.d.ts +2 -0
  24. package/dist/__tests__/asset-registry.unit.test.d.ts.map +1 -0
  25. package/dist/__tests__/asset-runtime-error-code-owner.test-d.d.ts +2 -0
  26. package/dist/__tests__/asset-runtime-error-code-owner.test-d.d.ts.map +1 -0
  27. package/dist/__tests__/catalog-replica.unit.test.d.ts +2 -0
  28. package/dist/__tests__/catalog-replica.unit.test.d.ts.map +1 -0
  29. package/dist/__tests__/catalog-source-v2.unit.test.d.ts +2 -0
  30. package/dist/__tests__/catalog-source-v2.unit.test.d.ts.map +1 -0
  31. package/dist/__tests__/catalog-source.unit.test.d.ts +2 -0
  32. package/dist/__tests__/catalog-source.unit.test.d.ts.map +1 -0
  33. package/dist/__tests__/catalog-subscription-order.unit.test.d.ts +2 -0
  34. package/dist/__tests__/catalog-subscription-order.unit.test.d.ts.map +1 -0
  35. package/dist/__tests__/catalog-subscription.unit.test.d.ts +2 -0
  36. package/dist/__tests__/catalog-subscription.unit.test.d.ts.map +1 -0
  37. package/dist/__tests__/decode-image-bytes-pass-through.test.d.ts +2 -0
  38. package/dist/__tests__/decode-image-bytes-pass-through.test.d.ts.map +1 -0
  39. package/dist/__tests__/decode-image-bytes.browser.test.d.ts +2 -0
  40. package/dist/__tests__/decode-image-bytes.browser.test.d.ts.map +1 -0
  41. package/dist/__tests__/decode-image-bytes.test-d.d.ts +2 -0
  42. package/dist/__tests__/decode-image-bytes.test-d.d.ts.map +1 -0
  43. package/dist/__tests__/decode-image-bytes.test.d.ts +2 -0
  44. package/dist/__tests__/decode-image-bytes.test.d.ts.map +1 -0
  45. package/dist/__tests__/dynamic-texture-store.unit.test.d.ts +2 -0
  46. package/dist/__tests__/dynamic-texture-store.unit.test.d.ts.map +1 -0
  47. package/dist/__tests__/errors-asset.unit.test.d.ts +2 -0
  48. package/dist/__tests__/errors-asset.unit.test.d.ts.map +1 -0
  49. package/dist/__tests__/inline-pack-loaders.unit.test.d.ts +2 -0
  50. package/dist/__tests__/inline-pack-loaders.unit.test.d.ts.map +1 -0
  51. package/dist/__tests__/instantiate-context.unit.test.d.ts +2 -0
  52. package/dist/__tests__/instantiate-context.unit.test.d.ts.map +1 -0
  53. package/dist/__tests__/instantiate-publication-fence.unit.test.d.ts +2 -0
  54. package/dist/__tests__/instantiate-publication-fence.unit.test.d.ts.map +1 -0
  55. package/dist/__tests__/instantiate.unit.test.d.ts +2 -0
  56. package/dist/__tests__/instantiate.unit.test.d.ts.map +1 -0
  57. package/dist/__tests__/loader-input-contract.unit.test.d.ts +2 -0
  58. package/dist/__tests__/loader-input-contract.unit.test.d.ts.map +1 -0
  59. package/dist/__tests__/loader-registry.unit.test.d.ts +2 -0
  60. package/dist/__tests__/loader-registry.unit.test.d.ts.map +1 -0
  61. package/dist/__tests__/material-dependency-invalidation.unit.test.d.ts +2 -0
  62. package/dist/__tests__/material-dependency-invalidation.unit.test.d.ts.map +1 -0
  63. package/dist/__tests__/material-generation-cache.unit.test.d.ts +2 -0
  64. package/dist/__tests__/material-generation-cache.unit.test.d.ts.map +1 -0
  65. package/dist/__tests__/material-load-consumer.test-d.d.ts +2 -0
  66. package/dist/__tests__/material-load-consumer.test-d.d.ts.map +1 -0
  67. package/dist/__tests__/material-ready.integration.test.d.ts +2 -0
  68. package/dist/__tests__/material-ready.integration.test.d.ts.map +1 -0
  69. package/dist/__tests__/material-stale-generation.unit.test.d.ts +2 -0
  70. package/dist/__tests__/material-stale-generation.unit.test.d.ts.map +1 -0
  71. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
  72. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
  73. package/dist/__tests__/mesh-bin.unit.test.d.ts +2 -0
  74. package/dist/__tests__/mesh-bin.unit.test.d.ts.map +1 -0
  75. package/dist/__tests__/mipmap-generator.unit.test.d.ts +2 -0
  76. package/dist/__tests__/mipmap-generator.unit.test.d.ts.map +1 -0
  77. package/dist/__tests__/pack-artifact-transcode-model-owner.test.d.ts +2 -0
  78. package/dist/__tests__/pack-artifact-transcode-model-owner.test.d.ts.map +1 -0
  79. package/dist/__tests__/pack-basis-load.integration.test.d.ts +2 -0
  80. package/dist/__tests__/pack-basis-load.integration.test.d.ts.map +1 -0
  81. package/dist/__tests__/payload-validate.unit.test.d.ts +2 -0
  82. package/dist/__tests__/payload-validate.unit.test.d.ts.map +1 -0
  83. package/dist/__tests__/plugin.test.d.ts +2 -0
  84. package/dist/__tests__/plugin.test.d.ts.map +1 -0
  85. package/dist/__tests__/registry-concurrency.bench.test.d.ts +2 -0
  86. package/dist/__tests__/registry-concurrency.bench.test.d.ts.map +1 -0
  87. package/dist/__tests__/registry-failure-retry.integration.test.d.ts +2 -0
  88. package/dist/__tests__/registry-failure-retry.integration.test.d.ts.map +1 -0
  89. package/dist/__tests__/registry-load-graph.integration.test.d.ts +2 -0
  90. package/dist/__tests__/registry-load-graph.integration.test.d.ts.map +1 -0
  91. package/dist/__tests__/registry-load-state.unit.test.d.ts +2 -0
  92. package/dist/__tests__/registry-load-state.unit.test.d.ts.map +1 -0
  93. package/dist/__tests__/registry-package-cache.unit.test.d.ts +2 -0
  94. package/dist/__tests__/registry-package-cache.unit.test.d.ts.map +1 -0
  95. package/dist/__tests__/registry-public-ready.unit.test.d.ts +2 -0
  96. package/dist/__tests__/registry-public-ready.unit.test.d.ts.map +1 -0
  97. package/dist/__tests__/resolve-asset-handle.unit.test.d.ts +2 -0
  98. package/dist/__tests__/resolve-asset-handle.unit.test.d.ts.map +1 -0
  99. package/dist/__tests__/scene-handle-fields.unit.test.d.ts +2 -0
  100. package/dist/__tests__/scene-handle-fields.unit.test.d.ts.map +1 -0
  101. package/dist/__tests__/scene-payload.unit.test.d.ts +2 -0
  102. package/dist/__tests__/scene-payload.unit.test.d.ts.map +1 -0
  103. package/dist/__tests__/scene-publication-fence.test.d.ts +2 -0
  104. package/dist/__tests__/scene-publication-fence.test.d.ts.map +1 -0
  105. package/dist/__tests__/scriptable-pack-public-surface.test-d.d.ts +2 -0
  106. package/dist/__tests__/scriptable-pack-public-surface.test-d.d.ts.map +1 -0
  107. package/dist/__tests__/static-rig.integration.test.d.ts +2 -0
  108. package/dist/__tests__/static-rig.integration.test.d.ts.map +1 -0
  109. package/dist/__tests__/validate-material.unit.test.d.ts +2 -0
  110. package/dist/__tests__/validate-material.unit.test.d.ts.map +1 -0
  111. package/dist/__tests__/video-pack-loader.integration.test.d.ts +2 -0
  112. package/dist/__tests__/video-pack-loader.integration.test.d.ts.map +1 -0
  113. package/dist/aabb.d.ts +3 -0
  114. package/dist/aabb.d.ts.map +1 -0
  115. package/dist/asset-registry.d.ts +552 -0
  116. package/dist/asset-registry.d.ts.map +1 -0
  117. package/dist/builtin-asset-registry.d.ts +22 -0
  118. package/dist/builtin-asset-registry.d.ts.map +1 -0
  119. package/dist/catalog-source.d.ts +2 -1
  120. package/dist/catalog-source.d.ts.map +1 -1
  121. package/dist/decode-image-bytes.d.ts +57 -0
  122. package/dist/decode-image-bytes.d.ts.map +1 -0
  123. package/dist/dynamic-texture-store.d.ts +89 -0
  124. package/dist/dynamic-texture-store.d.ts.map +1 -0
  125. package/dist/errors/asset.d.ts +201 -0
  126. package/dist/errors/asset.d.ts.map +1 -0
  127. package/dist/handles.d.ts +141 -0
  128. package/dist/handles.d.ts.map +1 -0
  129. package/dist/image-error.d.ts +6 -0
  130. package/dist/image-error.d.ts.map +1 -0
  131. package/dist/index.d.ts +32 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.mjs +7218 -114
  134. package/dist/index.mjs.map +1 -1
  135. package/dist/internal.d.ts +1 -1
  136. package/dist/internal.d.ts.map +1 -1
  137. package/dist/loader-registry.d.ts +63 -0
  138. package/dist/loader-registry.d.ts.map +1 -0
  139. package/dist/loaders/inline-pack.d.ts +72 -0
  140. package/dist/loaders/inline-pack.d.ts.map +1 -0
  141. package/dist/loaders/mesh-bin.d.ts +21 -0
  142. package/dist/loaders/mesh-bin.d.ts.map +1 -0
  143. package/dist/loaders/pack-artifact.d.ts +20 -0
  144. package/dist/loaders/pack-artifact.d.ts.map +1 -0
  145. package/dist/material/dependency-graph.d.ts +9 -0
  146. package/dist/material/dependency-graph.d.ts.map +1 -0
  147. package/dist/material/generation-cache.d.ts +24 -0
  148. package/dist/material/generation-cache.d.ts.map +1 -0
  149. package/dist/material/inspection.d.ts +24 -0
  150. package/dist/material/inspection.d.ts.map +1 -0
  151. package/dist/material/loader.d.ts +59 -0
  152. package/dist/material/loader.d.ts.map +1 -0
  153. package/dist/material/runtime-shader.d.ts +14 -0
  154. package/dist/material/runtime-shader.d.ts.map +1 -0
  155. package/dist/mesh-bin.d.ts +3 -0
  156. package/dist/mesh-bin.d.ts.map +1 -0
  157. package/dist/mipmap-generator.d.ts +132 -0
  158. package/dist/mipmap-generator.d.ts.map +1 -0
  159. package/dist/payload-validate.d.ts +60 -0
  160. package/dist/payload-validate.d.ts.map +1 -0
  161. package/dist/plugin.d.ts +16 -0
  162. package/dist/plugin.d.ts.map +1 -0
  163. package/dist/registry/artifact-io.d.ts +16 -0
  164. package/dist/registry/artifact-io.d.ts.map +1 -0
  165. package/dist/registry/asset-evidence.d.ts +13 -0
  166. package/dist/registry/asset-evidence.d.ts.map +1 -0
  167. package/dist/registry/catalog-state.d.ts +47 -0
  168. package/dist/registry/catalog-state.d.ts.map +1 -0
  169. package/dist/registry/catalog.d.ts +48 -0
  170. package/dist/registry/catalog.d.ts.map +1 -0
  171. package/dist/registry/instantiate.d.ts +153 -0
  172. package/dist/registry/instantiate.d.ts.map +1 -0
  173. package/dist/registry/load-by-guid.d.ts +247 -0
  174. package/dist/registry/load-by-guid.d.ts.map +1 -0
  175. package/dist/registry/load-state.d.ts +26 -0
  176. package/dist/registry/load-state.d.ts.map +1 -0
  177. package/dist/registry/runtime-projection.d.ts +16 -0
  178. package/dist/registry/runtime-projection.d.ts.map +1 -0
  179. package/dist/registry/scene-publication-fence.d.ts +38 -0
  180. package/dist/registry/scene-publication-fence.d.ts.map +1 -0
  181. package/dist/registry/validate-material.d.ts +77 -0
  182. package/dist/registry/validate-material.d.ts.map +1 -0
  183. package/dist/resolve-asset-handle.d.ts +43 -0
  184. package/dist/resolve-asset-handle.d.ts.map +1 -0
  185. package/dist/resolve-tileset-runtime.d.ts +23 -0
  186. package/dist/resolve-tileset-runtime.d.ts.map +1 -0
  187. package/dist/scene-handle-fields.d.ts +88 -0
  188. package/dist/scene-handle-fields.d.ts.map +1 -0
  189. package/dist/scene-payload.d.ts +15 -0
  190. package/dist/scene-payload.d.ts.map +1 -0
  191. package/dist/wire-default-loaders.d.ts +29 -0
  192. package/dist/wire-default-loaders.d.ts.map +1 -0
  193. package/package.json +22 -9
  194. package/src/__tests__/aabb.unit.test.ts +97 -0
  195. package/src/__tests__/artifact-integrity.unit.test.ts +99 -0
  196. package/src/__tests__/artifact-io.unit.test.ts +127 -0
  197. package/src/__tests__/asset-evidence-sdk.integration.test.ts +32 -0
  198. package/src/__tests__/asset-evidence.unit.test.ts +32 -0
  199. package/src/__tests__/{asset-kind.test.ts → asset-kind.unit.test.ts} +1 -1
  200. package/src/__tests__/asset-registry-catalog-reconcile.integration.test.ts +74 -0
  201. package/src/__tests__/asset-registry-catalog-reconcile.unit.test.ts +89 -0
  202. package/src/__tests__/asset-registry-catalog.unit.test.ts +170 -0
  203. package/src/__tests__/asset-registry.pack-cache.test.ts +96 -0
  204. package/src/__tests__/asset-registry.unit.test.ts +511 -0
  205. package/src/__tests__/asset-runtime-error-code-owner.test-d.ts +63 -0
  206. package/src/__tests__/catalog-replica.unit.test.ts +196 -0
  207. package/src/__tests__/catalog-source-v2.unit.test.ts +158 -0
  208. package/src/__tests__/catalog-source.unit.test.ts +126 -0
  209. package/src/__tests__/catalog-subscription-order.unit.test.ts +65 -0
  210. package/src/__tests__/catalog-subscription.unit.test.ts +28 -0
  211. package/src/__tests__/decode-image-bytes-pass-through.test.ts +34 -0
  212. package/src/__tests__/decode-image-bytes.browser.test.ts +102 -0
  213. package/src/__tests__/decode-image-bytes.test-d.ts +108 -0
  214. package/src/__tests__/decode-image-bytes.test.ts +90 -0
  215. package/src/__tests__/decode-image-mime-owner.test.ts +2 -5
  216. package/src/__tests__/dynamic-texture-store.unit.test.ts +115 -0
  217. package/src/__tests__/errors-asset.unit.test.ts +92 -0
  218. package/src/__tests__/inline-pack-loaders.unit.test.ts +548 -0
  219. package/src/__tests__/instantiate-context.unit.test.ts +65 -0
  220. package/src/__tests__/instantiate-publication-fence.unit.test.ts +158 -0
  221. package/src/__tests__/instantiate.unit.test.ts +684 -0
  222. package/src/__tests__/loader-input-contract.unit.test.ts +89 -0
  223. package/src/__tests__/loader-registry.unit.test.ts +101 -0
  224. package/src/__tests__/material-dependency-invalidation.unit.test.ts +14 -0
  225. package/src/__tests__/material-generation-cache.unit.test.ts +46 -0
  226. package/src/__tests__/material-load-consumer.test-d.ts +42 -0
  227. package/src/__tests__/material-ready.integration.test.ts +492 -0
  228. package/src/__tests__/material-stale-generation.unit.test.ts +152 -0
  229. package/src/__tests__/mesh-bin-morph-v3.test.ts +22 -0
  230. package/src/__tests__/mesh-bin.unit.test.ts +152 -0
  231. package/src/__tests__/mipmap-generator.unit.test.ts +202 -0
  232. package/src/__tests__/pack-artifact-transcode-model-owner.test.ts +32 -0
  233. package/src/__tests__/pack-basis-load.integration.test.ts +168 -0
  234. package/src/__tests__/payload-validate.unit.test.ts +285 -0
  235. package/src/__tests__/plugin.test.ts +55 -0
  236. package/src/__tests__/registry-concurrency.bench.test.ts +40 -0
  237. package/src/__tests__/registry-failure-retry.integration.test.ts +69 -0
  238. package/src/__tests__/registry-load-graph.integration.test.ts +246 -0
  239. package/src/__tests__/registry-load-state.unit.test.ts +35 -0
  240. package/src/__tests__/registry-package-cache.unit.test.ts +53 -0
  241. package/src/__tests__/registry-public-ready.unit.test.ts +38 -0
  242. package/src/__tests__/resolve-asset-handle.unit.test.ts +238 -0
  243. package/src/__tests__/scene-handle-fields.unit.test.ts +129 -0
  244. package/src/__tests__/scene-payload.unit.test.ts +151 -0
  245. package/src/__tests__/scene-publication-fence.test.ts +129 -0
  246. package/src/__tests__/scriptable-pack-public-surface.test-d.ts +16 -0
  247. package/src/__tests__/static-rig.integration.test.ts +8 -0
  248. package/src/__tests__/validate-material.unit.test.ts +359 -0
  249. package/src/__tests__/video-pack-loader.integration.test.ts +83 -0
  250. package/src/aabb.ts +107 -0
  251. package/src/asset-registry.ts +2040 -0
  252. package/src/builtin-asset-registry.ts +164 -0
  253. package/src/catalog-source.ts +14 -70
  254. package/src/decode-image-bytes.ts +126 -0
  255. package/src/dynamic-texture-store.ts +234 -0
  256. package/src/errors/asset.ts +347 -0
  257. package/src/handles.ts +180 -0
  258. package/src/image-error.ts +45 -0
  259. package/src/index.ts +177 -19
  260. package/src/internal.ts +1 -0
  261. package/src/loader-registry.ts +157 -0
  262. package/src/loaders/inline-pack.ts +837 -0
  263. package/src/loaders/mesh-bin.ts +304 -0
  264. package/src/loaders/pack-artifact.ts +665 -0
  265. package/src/material/dependency-graph.ts +46 -0
  266. package/src/material/generation-cache.ts +177 -0
  267. package/src/material/inspection.ts +46 -0
  268. package/src/material/loader.ts +326 -0
  269. package/src/material/runtime-shader.ts +133 -0
  270. package/src/mesh-bin.ts +5 -0
  271. package/src/mipmap-generator.ts +450 -0
  272. package/src/payload-validate.ts +573 -0
  273. package/src/plugin.ts +48 -0
  274. package/src/registry/artifact-io.ts +259 -0
  275. package/src/registry/asset-evidence.ts +51 -0
  276. package/src/registry/catalog-state.ts +409 -0
  277. package/src/registry/catalog.ts +369 -0
  278. package/src/registry/instantiate.ts +1056 -0
  279. package/src/registry/load-by-guid.ts +1657 -0
  280. package/src/registry/load-state.ts +115 -0
  281. package/src/registry/runtime-projection.ts +63 -0
  282. package/src/registry/scene-publication-fence.ts +256 -0
  283. package/src/registry/validate-material.ts +386 -0
  284. package/src/resolve-asset-handle.ts +147 -0
  285. package/src/resolve-tileset-runtime.ts +72 -0
  286. package/src/scene-handle-fields.ts +221 -0
  287. package/src/scene-payload.ts +207 -0
  288. package/src/wire-default-loaders.ts +79 -0
  289. package/dist/__tests__/asset-graph-red.integration.test.d.ts +0 -2
  290. package/dist/__tests__/asset-graph-red.integration.test.d.ts.map +0 -1
  291. package/dist/__tests__/asset-kind.test.d.ts +0 -2
  292. package/dist/__tests__/asset-kind.test.d.ts.map +0 -1
  293. package/dist/__tests__/asset-registry-core.integration.test.d.ts +0 -2
  294. package/dist/__tests__/asset-registry-core.integration.test.d.ts.map +0 -1
  295. package/dist/__tests__/asset-registry-public-api.test-d.d.ts +0 -2
  296. package/dist/__tests__/asset-registry-public-api.test-d.d.ts.map +0 -1
  297. package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts +0 -2
  298. package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts.map +0 -1
  299. package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts +0 -2
  300. package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts.map +0 -1
  301. package/dist/__tests__/catalog-session-red.unit.test.d.ts +0 -2
  302. package/dist/__tests__/catalog-session-red.unit.test.d.ts.map +0 -1
  303. package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts +0 -2
  304. package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts.map +0 -1
  305. package/src/__tests__/asset-graph-red.integration.test.ts +0 -113
  306. package/src/__tests__/asset-registry-core.integration.test.ts +0 -161
  307. package/src/__tests__/asset-registry-public-api.test-d.ts +0 -31
  308. package/src/__tests__/asset-runtime-core-lifecycle.integration.test.ts +0 -79
  309. package/src/__tests__/asset-runtime-snapshot.unit.test.ts +0 -23
  310. package/src/__tests__/catalog-session-red.unit.test.ts +0 -276
  311. package/src/__tests__/registry-lifecycle-red.integration.test.ts +0 -80
@@ -0,0 +1,492 @@
1
+ import {
2
+ type CookedMaterialRecord,
3
+ createMaterialArtifactDigest,
4
+ serializeCookedMaterialRecord,
5
+ } from '@forgeax/engine-pack';
6
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
7
+ import { ShaderRegistry } from '@forgeax/engine-shader';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import { AssetRegistry } from '../asset-registry';
10
+ import { MaterialGenerationCache } from '../material/generation-cache';
11
+ import { inspectMaterialRuntime } from '../material/inspection.js';
12
+ import { createMaterialLoader, type MaterialPublication } from '../material/loader.js';
13
+ import { materialParametersToParamSchema } from '../material/runtime-shader.js';
14
+
15
+ const MATERIAL_GUID = 'mat-ready';
16
+ const ARTIFACT_BYTES = new TextEncoder().encode('published material artifact');
17
+ const PRODUCTION_GUID = '019f0000-0000-7000-8000-000000000701';
18
+
19
+ function cookedRecord(guid = MATERIAL_GUID): CookedMaterialRecord {
20
+ const artifactDigest = createMaterialArtifactDigest(ARTIFACT_BYTES);
21
+ return {
22
+ schemaVersion: 'material-cook/2',
23
+ guid,
24
+ materialGuid: guid,
25
+ publicationGeneration: 7,
26
+ specializationKey: 'material-specialization/7/ready',
27
+ artifactDigest,
28
+ sourceClosure: ['materials/ready.material.json', 'shaders/ready.wgsl'],
29
+ parameterContract: { parameters: [], values: {} },
30
+ resolved: { passes: [], parameters: [], values: {} },
31
+ refs: { parent: [], textures: [], samplers: [], modules: [] },
32
+ artifact: {
33
+ mediaType: 'text/wgsl',
34
+ path: `materials/${guid}/shader.wgsl`,
35
+ digest: artifactDigest,
36
+ bytes: ARTIFACT_BYTES,
37
+ },
38
+ receipt: {
39
+ schemaVersion: 'material-cook/2',
40
+ sourceClosure: ['materials/ready.material.json', 'shaders/ready.wgsl'],
41
+ profile: 'webgpu/v1',
42
+ compilerVersion: 'compiler/1',
43
+ inputDigest: 'input-digest',
44
+ outputDigest: artifactDigest,
45
+ layoutIdentity: 'sha256:ready-layout',
46
+ derivedInterface: { layoutIdentity: 'sha256:ready-layout' },
47
+ publicationGeneration: 7,
48
+ },
49
+ };
50
+ }
51
+
52
+ function publication(
53
+ overrides: {
54
+ readonly record?: unknown;
55
+ readonly artifact?: MaterialPublication['artifact'] | null;
56
+ } = {},
57
+ ): MaterialPublication {
58
+ const record = cookedRecord();
59
+ return {
60
+ guid: MATERIAL_GUID,
61
+ record: overrides.record ?? record,
62
+ ...(overrides.artifact === null
63
+ ? {}
64
+ : { artifact: overrides.artifact ?? { bytes: ARTIFACT_BYTES } }),
65
+ };
66
+ }
67
+
68
+ function createLoader(loadPublication: () => Promise<MaterialPublication | undefined>) {
69
+ return createMaterialLoader({ loadPublication, loadReference: async () => true });
70
+ }
71
+
72
+ describe('material runtime readiness', () => {
73
+ it('preserves authored numeric defaults in the runtime parameter contract', () => {
74
+ expect(
75
+ materialParametersToParamSchema(
76
+ [
77
+ { name: 'baseColor', type: 'color', default: [1, 1, 1, 1] },
78
+ { name: 'roughness', type: 'f32', default: 0.5 },
79
+ { name: 'baseColorTexture', type: 'texture' },
80
+ ],
81
+ 'published::custom',
82
+ ),
83
+ ).toEqual([
84
+ { name: 'baseColor', type: 'color', default: [1, 1, 1, 1] },
85
+ { name: 'roughness', type: 'f32', default: 0.5 },
86
+ { name: 'baseColorTexture', type: 'texture2d' },
87
+ ]);
88
+ });
89
+
90
+ it('loads only a cooked record with its artifact and complete references', async () => {
91
+ const loader = createLoader(async () => publication());
92
+
93
+ const result = await loader.load({
94
+ guid: MATERIAL_GUID,
95
+ specializationKey: 'material-specialization/7/ready',
96
+ });
97
+
98
+ expect(result.status).toBe('Ready');
99
+ if (result.status !== 'Ready') return;
100
+ expect(result).toMatchObject({
101
+ materialGuid: MATERIAL_GUID,
102
+ publicationGeneration: 7,
103
+ specializationKey: 'material-specialization/7/ready',
104
+ artifactDigest: expect.stringMatching(/^sha256:/),
105
+ sourceClosure: ['materials/ready.material.json', 'shaders/ready.wgsl'],
106
+ parameterContract: { parameters: [], values: {} },
107
+ });
108
+ expect(inspectMaterialRuntime(result)).toMatchObject({
109
+ materialGuid: MATERIAL_GUID,
110
+ readiness: 'ready',
111
+ publicationGeneration: 7,
112
+ specializationKey: 'material-specialization/7/ready',
113
+ artifactDigest: expect.stringMatching(/^sha256:/),
114
+ sourceClosure: ['materials/ready.material.json', 'shaders/ready.wgsl'],
115
+ parameterContract: { parameters: [], values: {} },
116
+ status: 'Ready',
117
+ });
118
+ });
119
+
120
+ it('returns a structured missing-cook failure without compiling at runtime', async () => {
121
+ const loader = createLoader(async () => undefined);
122
+ const result = await loader.load({ guid: 'mat-missing', specializationKey: 'key-a' });
123
+
124
+ expect(result).toMatchObject({
125
+ status: 'Error',
126
+ error: { code: 'material-specialization-not-cooked' },
127
+ });
128
+ });
129
+
130
+ it.each([
131
+ ['artifact missing', () => ({ artifact: null }), 'asset-artifact-missing'],
132
+ [
133
+ 'artifact bytes tampered',
134
+ () => ({ artifact: { bytes: new TextEncoder().encode('tampered artifact') } }),
135
+ 'asset-artifact-integrity-mismatch',
136
+ ],
137
+ [
138
+ 'record field tampered',
139
+ () => ({ record: { ...cookedRecord(), publicationGeneration: undefined } }),
140
+ 'material-cook-record-invalid',
141
+ ],
142
+ ] as const)('rejects %s on the published tuple', async (_label, mutate, code) => {
143
+ const loader = createLoader(async () => publication(mutate()));
144
+ const result = await loader.load({
145
+ guid: MATERIAL_GUID,
146
+ specializationKey: 'material-specialization/7/ready',
147
+ });
148
+
149
+ expect(result).toMatchObject({ status: 'Error', error: { code } });
150
+ });
151
+
152
+ it('keeps publication generations distinct in the material cache', async () => {
153
+ const cache = new MaterialGenerationCache();
154
+ let calls = 0;
155
+ const load = async () => {
156
+ calls += 1;
157
+ return calls;
158
+ };
159
+
160
+ const first = await cache.resolve(MATERIAL_GUID, 'material-specialization/7/ready', load, 7);
161
+ const second = await cache.resolve(MATERIAL_GUID, 'material-specialization/7/ready', load, 8);
162
+
163
+ expect(first).toBe(1);
164
+ expect(second).toBe(2);
165
+ expect(calls).toBe(2);
166
+ });
167
+
168
+ it('loads the published record sidecar through the production GUID route', async () => {
169
+ const record = cookedRecord(PRODUCTION_GUID);
170
+ const packageUrl = `/materials/${PRODUCTION_GUID}.pack.json`;
171
+ const descriptor = {
172
+ path: record.artifact.path,
173
+ mediaType: record.artifact.mediaType,
174
+ byteLength: ARTIFACT_BYTES.byteLength,
175
+ integrity: { algorithm: 'sha256' as const, digest: record.artifactDigest },
176
+ };
177
+ const pack = {
178
+ schemaVersion: '2.0.0',
179
+ kind: 'internal-text-package',
180
+ assets: [
181
+ {
182
+ guid: PRODUCTION_GUID,
183
+ kind: 'material',
184
+ payload: {
185
+ kind: 'material',
186
+ passes: [{ name: 'Forward', program: { module: 'core/pbr' } }],
187
+ parameters: [],
188
+ values: {},
189
+ },
190
+ refs: [],
191
+ artifacts: { [record.artifact.path]: descriptor },
192
+ },
193
+ ],
194
+ };
195
+ const fetcher = vi.fn(async (input: RequestInfo | URL) => {
196
+ const url = String(input);
197
+ if (url.endsWith('/pack-index.json')) {
198
+ return new Response(
199
+ JSON.stringify([{ guid: PRODUCTION_GUID, packageUrl, kind: 'material' }]),
200
+ );
201
+ }
202
+ if (url.endsWith('.record.json')) {
203
+ return new Response(serializeCookedMaterialRecord(record), {
204
+ headers: { 'content-type': 'application/json' },
205
+ });
206
+ }
207
+ if (url.endsWith('.pack.json')) return new Response(JSON.stringify(pack));
208
+ if (url.endsWith('.wgsl')) return new Response(ARTIFACT_BYTES);
209
+ return new Response('not found', { status: 404 });
210
+ });
211
+ vi.stubGlobal('fetch', fetcher);
212
+
213
+ try {
214
+ const registry = new AssetRegistry({} as never);
215
+ registry.configurePackIndex('/pack-index.json');
216
+ const parsedGuid = AssetGuid.parse(PRODUCTION_GUID);
217
+ expect(parsedGuid.ok).toBe(true);
218
+ if (!parsedGuid.ok) return;
219
+ const payloadResult = await registry.loadByGuid(parsedGuid.value);
220
+
221
+ expect(payloadResult).toMatchObject({ ok: true });
222
+ expect(registry.getMaterialReadiness(PRODUCTION_GUID)).toMatchObject({
223
+ status: 'Ready',
224
+ materialGuid: PRODUCTION_GUID,
225
+ publicationGeneration: 7,
226
+ artifactDigest: record.artifactDigest,
227
+ });
228
+ expect(fetcher).toHaveBeenCalled();
229
+ } finally {
230
+ vi.unstubAllGlobals();
231
+ }
232
+ });
233
+
234
+ it('does not require a cooked artifact for an Engine-owned material module', async () => {
235
+ const packageUrl = `/materials/${PRODUCTION_GUID}.pack.json`;
236
+ const pack = {
237
+ schemaVersion: '2.0.0',
238
+ kind: 'internal-text-package',
239
+ assets: [
240
+ {
241
+ guid: PRODUCTION_GUID,
242
+ kind: 'material',
243
+ payload: {
244
+ kind: 'material',
245
+ passes: [{ name: 'Forward', program: { module: 'forgeax::default-standard-pbr' } }],
246
+ values: { baseColor: [1, 0.2, 0.05, 1] },
247
+ },
248
+ refs: [],
249
+ artifacts: {},
250
+ },
251
+ ],
252
+ };
253
+ vi.stubGlobal(
254
+ 'fetch',
255
+ vi.fn(async (input: RequestInfo | URL) => {
256
+ const url = String(input);
257
+ if (url.endsWith('/pack-index.json')) {
258
+ return new Response(
259
+ JSON.stringify([{ guid: PRODUCTION_GUID, packageUrl, kind: 'material' }]),
260
+ );
261
+ }
262
+ return new Response(JSON.stringify(pack));
263
+ }),
264
+ );
265
+
266
+ try {
267
+ const registry = new AssetRegistry({} as never);
268
+ registry.configurePackIndex('/pack-index.json');
269
+ const parsedGuid = AssetGuid.parse(PRODUCTION_GUID);
270
+ expect(parsedGuid.ok).toBe(true);
271
+ if (!parsedGuid.ok) return;
272
+
273
+ const result = await registry.loadByGuid(parsedGuid.value);
274
+
275
+ expect(result).toMatchObject({ ok: true, value: { kind: 'material' } });
276
+ expect(registry.getMaterialReadiness(PRODUCTION_GUID)).toBeUndefined();
277
+ } finally {
278
+ vi.unstubAllGlobals();
279
+ }
280
+ });
281
+
282
+ it('installs authored pass modules from MaterialReady without a host registration step', async () => {
283
+ const customShaderId = 'published::custom';
284
+ const baseRecord = cookedRecord(PRODUCTION_GUID);
285
+ const record: CookedMaterialRecord = {
286
+ ...baseRecord,
287
+ resolved: {
288
+ ...baseRecord.resolved,
289
+ passes: [{ name: 'Forward', program: { module: customShaderId } }],
290
+ },
291
+ refs: { ...baseRecord.refs, modules: [customShaderId] },
292
+ };
293
+ const packageUrl = `/materials/${PRODUCTION_GUID}.pack.json`;
294
+ const descriptor = {
295
+ path: record.artifact.path,
296
+ mediaType: record.artifact.mediaType,
297
+ byteLength: ARTIFACT_BYTES.byteLength,
298
+ integrity: { algorithm: 'sha256' as const, digest: record.artifactDigest },
299
+ };
300
+ const pack = {
301
+ schemaVersion: '2.0.0',
302
+ kind: 'internal-text-package',
303
+ assets: [
304
+ {
305
+ guid: PRODUCTION_GUID,
306
+ kind: 'material',
307
+ payload: {
308
+ kind: 'material',
309
+ passes: [{ name: 'Forward', program: { module: customShaderId } }],
310
+ parameters: [],
311
+ values: {},
312
+ },
313
+ refs: [],
314
+ artifacts: { [record.artifact.path]: descriptor },
315
+ },
316
+ ],
317
+ };
318
+ const fetcher = vi.fn(async (input: RequestInfo | URL) => {
319
+ const url = String(input);
320
+ if (url.endsWith('/pack-index.json')) {
321
+ return new Response(
322
+ JSON.stringify([{ guid: PRODUCTION_GUID, packageUrl, kind: 'material' }]),
323
+ );
324
+ }
325
+ if (url.endsWith('.record.json')) {
326
+ return new Response(serializeCookedMaterialRecord(record), {
327
+ headers: { 'content-type': 'application/json' },
328
+ });
329
+ }
330
+ if (url.endsWith('.pack.json')) return new Response(JSON.stringify(pack));
331
+ if (url.endsWith('.wgsl')) return new Response(ARTIFACT_BYTES);
332
+ return new Response('not found', { status: 404 });
333
+ });
334
+ vi.stubGlobal('fetch', fetcher);
335
+
336
+ const shaderRegistry = new ShaderRegistry({
337
+ device: {
338
+ createShaderModule: () => {
339
+ throw new Error('not used');
340
+ },
341
+ } as never,
342
+ manifestUrl: undefined,
343
+ });
344
+ try {
345
+ const registry = new AssetRegistry(shaderRegistry);
346
+ registry.configurePackIndex('/pack-index.json');
347
+ const parsedGuid = AssetGuid.parse(PRODUCTION_GUID);
348
+ expect(parsedGuid.ok).toBe(true);
349
+ if (!parsedGuid.ok) return;
350
+
351
+ const result = await registry.loadByGuid(parsedGuid.value);
352
+
353
+ expect(result).toMatchObject({ ok: true });
354
+ const lookup = shaderRegistry.findMaterialArtifact(customShaderId);
355
+ expect(lookup).toMatchObject({
356
+ ok: true,
357
+ value: { source: new TextDecoder().decode(ARTIFACT_BYTES), paramSchema: [] },
358
+ });
359
+ } finally {
360
+ vi.unstubAllGlobals();
361
+ }
362
+ });
363
+
364
+ it('installs authored pass modules from an inline cooked artifact', async () => {
365
+ const customShaderId = 'published::inline-custom';
366
+ const baseRecord = cookedRecord(PRODUCTION_GUID);
367
+ const record: CookedMaterialRecord = {
368
+ ...baseRecord,
369
+ resolved: {
370
+ ...baseRecord.resolved,
371
+ passes: [{ name: 'Forward', program: { module: customShaderId } }],
372
+ },
373
+ refs: { ...baseRecord.refs, modules: [customShaderId] },
374
+ };
375
+ const packageUrl = `/materials/${PRODUCTION_GUID}.inline.pack.json`;
376
+ const pack = {
377
+ schemaVersion: '2.0.0',
378
+ kind: 'internal-text-package',
379
+ assets: [
380
+ {
381
+ guid: PRODUCTION_GUID,
382
+ kind: 'material',
383
+ payload: {
384
+ kind: 'material',
385
+ passes: [{ name: 'Forward', program: { module: customShaderId } }],
386
+ parameters: [],
387
+ values: {},
388
+ cooked: JSON.parse(serializeCookedMaterialRecord(record)),
389
+ },
390
+ refs: [],
391
+ artifacts: {},
392
+ },
393
+ ],
394
+ };
395
+ vi.stubGlobal(
396
+ 'fetch',
397
+ vi.fn(async (input: RequestInfo | URL) => {
398
+ const url = String(input);
399
+ if (url.endsWith('/pack-index.json')) {
400
+ return new Response(
401
+ JSON.stringify([{ guid: PRODUCTION_GUID, packageUrl, kind: 'material' }]),
402
+ );
403
+ }
404
+ if (url.endsWith('.inline.pack.json')) return new Response(JSON.stringify(pack));
405
+ return new Response('not found', { status: 404 });
406
+ }),
407
+ );
408
+
409
+ const shaderRegistry = new ShaderRegistry({
410
+ device: {
411
+ createShaderModule: () => {
412
+ throw new Error('not used');
413
+ },
414
+ } as never,
415
+ manifestUrl: undefined,
416
+ });
417
+ try {
418
+ const registry = new AssetRegistry(shaderRegistry);
419
+ registry.configurePackIndex('/pack-index.json');
420
+ const parsedGuid = AssetGuid.parse(PRODUCTION_GUID);
421
+ expect(parsedGuid.ok).toBe(true);
422
+ if (!parsedGuid.ok) return;
423
+
424
+ const result = await registry.loadByGuid(parsedGuid.value);
425
+
426
+ expect(result).toMatchObject({ ok: true });
427
+ expect(shaderRegistry.findMaterialArtifact(customShaderId)).toMatchObject({
428
+ ok: true,
429
+ value: { source: new TextDecoder().decode(ARTIFACT_BYTES), paramSchema: [] },
430
+ });
431
+ } finally {
432
+ vi.unstubAllGlobals();
433
+ }
434
+ });
435
+
436
+ it('does not reinterpret the Pack descriptor base64 digest as a material digest', async () => {
437
+ const record = cookedRecord(PRODUCTION_GUID);
438
+ const packageUrl = `/materials/${PRODUCTION_GUID}.pack.json`;
439
+ const descriptor = {
440
+ path: record.artifact.path,
441
+ mediaType: record.artifact.mediaType,
442
+ byteLength: ARTIFACT_BYTES.byteLength,
443
+ integrity: {
444
+ algorithm: 'sha256' as const,
445
+ digest: 'xEjSZ79vI9pvhM0qf+ot+tnK32nBxZHXVAn4OYdaJ3M=',
446
+ },
447
+ };
448
+ const pack = {
449
+ schemaVersion: '2.0.0',
450
+ kind: 'internal-text-package',
451
+ assets: [
452
+ {
453
+ guid: PRODUCTION_GUID,
454
+ kind: 'material',
455
+ payload: {
456
+ kind: 'material',
457
+ passes: [{ name: 'Forward', program: { module: 'core/pbr' } }],
458
+ parameters: [],
459
+ values: {},
460
+ },
461
+ refs: [],
462
+ artifacts: { [record.artifact.path]: descriptor },
463
+ },
464
+ ],
465
+ };
466
+ const fetcher = vi.fn(async (input: RequestInfo | URL) => {
467
+ const url = String(input);
468
+ if (url.endsWith('/pack-index.json')) {
469
+ return new Response(
470
+ JSON.stringify([{ guid: PRODUCTION_GUID, packageUrl, kind: 'material' }]),
471
+ );
472
+ }
473
+ if (url.endsWith('.record.json')) return new Response(serializeCookedMaterialRecord(record));
474
+ if (url.endsWith('.pack.json')) return new Response(JSON.stringify(pack));
475
+ if (url.endsWith('.wgsl')) return new Response(ARTIFACT_BYTES);
476
+ return new Response('not found', { status: 404 });
477
+ });
478
+ vi.stubGlobal('fetch', fetcher);
479
+ try {
480
+ const registry = new AssetRegistry({} as never);
481
+ registry.configurePackIndex('/pack-index.json');
482
+ const parsedGuid = AssetGuid.parse(PRODUCTION_GUID);
483
+ expect(parsedGuid.ok).toBe(true);
484
+ if (!parsedGuid.ok) return;
485
+ const result = await registry.loadByGuid(parsedGuid.value);
486
+ expect(result).toMatchObject({ ok: true });
487
+ expect(registry.getMaterialReadiness(PRODUCTION_GUID)).toMatchObject({ status: 'Ready' });
488
+ } finally {
489
+ vi.unstubAllGlobals();
490
+ }
491
+ });
492
+ });
@@ -0,0 +1,152 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { type CookedMaterialRecord, createMaterialArtifactDigest } from '@forgeax/engine-pack';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { MaterialGenerationCache } from '../material/generation-cache.js';
5
+ import { createMaterialLoader } from '../material/loader.js';
6
+
7
+ const CUSTOM_SHADER_FIXTURE = new URL(
8
+ '../../../../apps/hello/custom-shader/assets/pulse-material.pack.json',
9
+ import.meta.url,
10
+ );
11
+
12
+ const DERIVED_MATERIAL_GUID = '01935b00-7d8c-7c4e-9f12-345678abcd03';
13
+ const SPECIALIZATION_KEY = 'my-game::pulse-material';
14
+ const SPECIALIZATION_DEPENDENCIES = ['my-game::pulse-material', 'pack:pulse-material'] as const;
15
+
16
+ function createCookedMaterialLoader() {
17
+ const fixture = JSON.parse(readFileSync(CUSTOM_SHADER_FIXTURE, 'utf8')) as {
18
+ assets: readonly { guid: string; payload?: { cooked?: unknown } }[];
19
+ };
20
+ const cookedByGuid = new Map(
21
+ fixture.assets.map((entry) => [entry.guid.toLowerCase(), entry.payload?.cooked]),
22
+ );
23
+ return createMaterialLoader({
24
+ loadPublication: async (guid) => {
25
+ const raw = cookedByGuid.get(guid.toLowerCase()) as CookedMaterialRecord | undefined;
26
+ if (raw === undefined) return undefined;
27
+ const bytes = new TextEncoder().encode('published pulse artifact');
28
+ const digest = createMaterialArtifactDigest(bytes);
29
+ return {
30
+ guid,
31
+ record: {
32
+ ...raw,
33
+ schemaVersion: 'material-cook/2',
34
+ materialGuid: raw.guid,
35
+ publicationGeneration: 1,
36
+ specializationKey: SPECIALIZATION_KEY,
37
+ artifactDigest: digest,
38
+ sourceClosure: raw.receipt.sourceClosure,
39
+ parameterContract: { parameters: raw.resolved.parameters, values: raw.resolved.values },
40
+ artifact: { ...raw.artifact, digest, bytes },
41
+ receipt: {
42
+ ...raw.receipt,
43
+ schemaVersion: 'material-cook/2',
44
+ layoutIdentity: 'sha256:pulse-layout',
45
+ derivedInterface: { layoutIdentity: 'sha256:pulse-layout' },
46
+ publicationGeneration: 1,
47
+ outputDigest: digest,
48
+ },
49
+ },
50
+ artifact: { bytes },
51
+ };
52
+ },
53
+ loadReference: async () => true,
54
+ });
55
+ }
56
+
57
+ describe('material stale generation publication', () => {
58
+ it('retries once and reports the generation vector when dependencies change', async () => {
59
+ const cache = new MaterialGenerationCache();
60
+ let calls = 0;
61
+ const result = await cache.loadWithGeneration('mat-a', ['texture/a'], async (generation) => {
62
+ calls += 1;
63
+ if (calls === 1) cache.bump('texture/a');
64
+ return { generation, value: calls };
65
+ });
66
+
67
+ expect(result.ok).toBe(true);
68
+ expect(calls).toBe(2);
69
+ expect(cache.generationError('mat-a')).toBeUndefined();
70
+ });
71
+
72
+ it('exposes stale-generation details after the retry budget is exhausted', async () => {
73
+ const cache = new MaterialGenerationCache();
74
+ const result = await cache.loadWithGeneration('mat-a', ['texture/a'], async (generation) => {
75
+ cache.bump('texture/a');
76
+ return { generation, value: 1 };
77
+ });
78
+
79
+ expect(result).toMatchObject({
80
+ ok: false,
81
+ error: { code: 'material-specialization-stale-generation' },
82
+ });
83
+ });
84
+
85
+ it('retries a cooked material after the stale promise is rejected', async () => {
86
+ const cache = new MaterialGenerationCache();
87
+ const loader = createCookedMaterialLoader();
88
+ const dependencies: string[] = [...SPECIALIZATION_DEPENDENCIES];
89
+ const shaderDependency = SPECIALIZATION_DEPENDENCIES[0];
90
+ let unstable = true;
91
+ let loadCalls = 0;
92
+
93
+ const loadSpecialization = () =>
94
+ cache.loadWithGeneration(DERIVED_MATERIAL_GUID, dependencies, async (generation) => {
95
+ loadCalls += 1;
96
+ const loaded = await loader.load({
97
+ guid: DERIVED_MATERIAL_GUID,
98
+ specializationKey: SPECIALIZATION_KEY,
99
+ });
100
+ expect(loaded.status).toBe('Ready');
101
+ if (loaded.status !== 'Ready') throw new Error('fixture material did not load');
102
+ if (unstable) cache.bump(shaderDependency);
103
+ return { generation, value: loaded.record };
104
+ });
105
+
106
+ const stale = await cache.resolve(
107
+ DERIVED_MATERIAL_GUID,
108
+ SPECIALIZATION_KEY,
109
+ loadSpecialization,
110
+ );
111
+ expect(stale).toMatchObject({
112
+ ok: false,
113
+ error: { code: 'material-specialization-stale-generation' },
114
+ });
115
+ expect(loadCalls).toBe(2);
116
+ expect(cache.generationError(DERIVED_MATERIAL_GUID)?.detail).toMatchObject({
117
+ material: DERIVED_MATERIAL_GUID,
118
+ dependencies: [...SPECIALIZATION_DEPENDENCIES],
119
+ observed: {
120
+ dependencies: { [SPECIALIZATION_DEPENDENCIES[0]]: 1, [SPECIALIZATION_DEPENDENCIES[1]]: 0 },
121
+ },
122
+ current: {
123
+ dependencies: { [SPECIALIZATION_DEPENDENCIES[0]]: 2, [SPECIALIZATION_DEPENDENCIES[1]]: 0 },
124
+ },
125
+ });
126
+ const staleError = cache.generationError(DERIVED_MATERIAL_GUID);
127
+ expect(staleError?.code).toBe('material-specialization-stale-generation');
128
+ expect(staleError?.detail.code).toBe('material-specialization-stale-generation');
129
+ expect(Object.isFrozen(staleError?.detail)).toBe(true);
130
+ expect(Object.isFrozen(staleError?.detail.dependencies)).toBe(true);
131
+ expect(Object.isFrozen(staleError?.detail.observed.dependencies)).toBe(true);
132
+ expect(Object.isFrozen(staleError?.detail.current.dependencies)).toBe(true);
133
+ dependencies.push('mutated-after-publication');
134
+ expect(staleError?.detail.dependencies).toEqual([...SPECIALIZATION_DEPENDENCIES]);
135
+
136
+ const published: unknown[] = [];
137
+ if (stale.ok) published.push(stale.value);
138
+ expect(published).toHaveLength(0);
139
+
140
+ unstable = false;
141
+ const fresh = await cache.resolve(
142
+ DERIVED_MATERIAL_GUID,
143
+ SPECIALIZATION_KEY,
144
+ loadSpecialization,
145
+ );
146
+ expect(fresh.ok).toBe(true);
147
+ expect(loadCalls).toBe(3);
148
+ if (fresh.ok) published.push(fresh.value);
149
+ expect(published).toHaveLength(1);
150
+ expect(cache.generationError(DERIVED_MATERIAL_GUID)).toBeUndefined();
151
+ });
152
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { unpackMeshBinV4 } from '../loaders/mesh-bin';
3
+
4
+ describe('mesh-bin v4 morph validation', () => {
5
+ it('rejects trailing bytes instead of silently accepting a malformed artifact', () => {
6
+ const bytes = new Uint8Array(80 + 1);
7
+ const view = new DataView(bytes.buffer);
8
+ view.setUint32(0, 4, true);
9
+ expect(unpackMeshBinV4(bytes, 'gltf://morph-trailing').ok).toBe(false);
10
+ });
11
+
12
+ it('rejects malformed metadata with a structured recovery error', () => {
13
+ const bytes = new Uint8Array(80);
14
+ const view = new DataView(bytes.buffer);
15
+ view.setUint32(0, 4, true);
16
+ const result = unpackMeshBinV4(bytes, 'gltf://morph-invalid');
17
+ expect(result.ok).toBe(false);
18
+ if (result.ok) return;
19
+ expect(result.error.sourceKey).toBe('gltf://morph-invalid');
20
+ expect(result.error.recovery).toContain('re-cook');
21
+ });
22
+ });