Files
Faset_Engine/tests/assets_pipeline.cpp
T

74 lines
11 KiB
C++

#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/hash.hpp>
#include <bit>
#include <chrono>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace faset::assets;
namespace fs=std::filesystem;
namespace {
void require(bool value,const std::string& message){if(!value)throw std::runtime_error(message);}
void u32(std::vector<unsigned char>& data,std::uint32_t value){for(int i=0;i<4;++i)data.push_back(static_cast<unsigned char>(value>>(8*i)));}
void save(const fs::path& file,const std::vector<unsigned char>& data){std::ofstream out(file,std::ios::binary);out.write(reinterpret_cast<const char*>(data.data()),static_cast<std::streamsize>(data.size()));}
void save(const fs::path& file,const std::string& text){std::ofstream(file,std::ios::binary)<<text;}
std::vector<unsigned char> geometry(float x){std::vector<unsigned char> bin;for(float f:{x,0.f,0.f,1.f,0.f,0.f,0.f,1.f,0.f})u32(bin,std::bit_cast<std::uint32_t>(f));for(unsigned char c:{0,0,1,0,2,0})bin.push_back(c);return bin;}
Json document(const std::string& name,bool stable,bool second,float x=0) {
Json node{{"name",name},{"mesh",0}};if(stable)node["extras"]={{"faset_id","node-door"}};
Json j={{"asset",{{"version","2.0"}}},{"scene",0},{"scenes",Json::array({{{"nodes",Json::array({0})}}})},{"nodes",Json::array({node})},{"buffers",Json::array({{{"byteLength",42}}})},{"bufferViews",Json::array({{{"buffer",0},{"byteOffset",0},{"byteLength",36},{"target",34962}},{{"buffer",0},{"byteOffset",36},{"byteLength",6},{"target",34963}}})},{"accessors",Json::array({{{"bufferView",0},{"componentType",5126},{"count",3},{"type","VEC3"},{"min",{std::min(0.f,x),0,0}},{"max",{std::max(1.f,x),1,0}}},{{"bufferView",1},{"componentType",5123},{"count",3},{"type","SCALAR"}}})},{"meshes",Json::array({{{"name","Triangle"},{"extras",{{"faset_id","mesh-triangle"}}},{"primitives",Json::array({{{"attributes",{{"POSITION",0}}},{"indices",1},{"material",0}}})}}})},{"materials",Json::array({{{"name","Red"},{"pbrMetallicRoughness",{{"baseColorFactor",{1.0,0.2,0.1,1.0}},{"metallicFactor",0.2},{"roughnessFactor",0.6}}}}})}};
if(second){j["nodes"].push_back({{"name","Handle"},{"mesh",0},{"extras",{{"faset_id","node-handle"}}}});j["scenes"][0]["nodes"].push_back(1);}
return j;
}
void glb(const fs::path& path,const std::string& name="Door",bool stable=true,bool second=false,float x=0) {
auto json=document(name,stable,second,x).dump();while(json.size()%4)json+=' ';
auto binary=geometry(x);while(binary.size()%4)binary.push_back(0);
std::vector<unsigned char> result;u32(result,0x46546c67);u32(result,2);u32(result,static_cast<std::uint32_t>(12+8+json.size()+8+binary.size()));u32(result,static_cast<std::uint32_t>(json.size()));u32(result,0x4e4f534a);result.insert(result.end(),json.begin(),json.end());u32(result,static_cast<std::uint32_t>(binary.size()));u32(result,0x004e4942);result.insert(result.end(),binary.begin(),binary.end());save(path,result);
}
void success(const ImportResult& result){if(!result.ok()){std::string text="Import failed: ";for(const auto& d:result.diagnostics)text+=d+"; ";throw std::runtime_error(text);}}
}
int main() {
const auto root=fs::temp_directory_path()/("faset-assets-test-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(root);
try {
AssetPipeline pipeline(root/"cache");const auto source=root/"door.glb";glb(source);
auto first=pipeline.import_asset({source});success(first);require(!first.asset_id.empty(),"persistent identity missing");
auto asset=pipeline.load_asset(first.asset_id);require(asset.meshes.size()==1&&asset.nodes.size()==1,"mesh/node extraction");require(asset.meshes[0].primitives[0].indices==std::vector<std::uint32_t>({0,1,2}),"index extraction");require(asset.meshes[0].primitives[0].vertices[0].normal[2]==1,"generated normal");require(asset.materials[0].base_color[1]>.19f&&asset.materials[0].metallic==.2f,"PBR extraction");
const auto node_id=asset.nodes[0].id;
const Json custom{{node_id,{{"gameplay",{{"locked",true}}},{"physics",{{"mass",12}}},{"material","custom-brass"}}}};
pipeline.set_overrides(first.asset_id,custom);
auto unchanged=pipeline.import_asset({source});success(unchanged);require(unchanged.cache_hit&&unchanged.generation==first.generation,"content cache hit");
glb(source,"Renamed panel",true,false,.25f);auto modified=pipeline.import_asset({source});success(modified);require(modified.asset_id==first.asset_id&&modified.generation!=first.generation,"stable asset identity and changed generation");
asset=pipeline.load_asset(first.asset_id);require(asset.nodes[0].id==node_id&&asset.nodes[0].name=="Renamed panel","faset_id survives rename");require(asset.meshes[0].primitives[0].vertices[0].position[0]==.25f,"new binary geometry loaded");require(pipeline.overrides(first.asset_id)==custom,"reimport erased authoring overrides");
glb(source,"Renamed panel",true,true,.25f);auto added=pipeline.import_asset({source});success(added);
glb(source,"Renamed panel",true,false,.25f);auto removed=pipeline.import_asset({source});require(removed.status==ImportStatus::conflict&&!removed.removed_output_ids.empty(),"deletion must conflict");require(pipeline.load_asset(first.asset_id).generation==added.generation,"conflict replaced active generation");require(pipeline.overrides(first.asset_id)==custom,"conflict erased overrides");
ImportRequest resolve{source};resolve.allow_removed_outputs=true;auto resolved=pipeline.import_asset(resolve);success(resolved);require(pipeline.load_asset(first.asset_id).nodes.size()==1,"explicit deletion resolution");
const auto active=resolved.generation;
save(source,std::string("not a GLB"));auto failed=pipeline.import_asset({source});require(failed.status==ImportStatus::failed,"invalid source accepted");require(pipeline.load_asset(first.asset_id).generation==active,"failed import replaced active");
glb(source,"Renamed panel",true,false,.5f);ImportJob* job_ptr=nullptr;ImportJob job([&](const ImportProgress& p){if(p.fraction>=.9f)job_ptr->cancel();});job_ptr=&job;
auto cancelled=pipeline.import_asset({source},job);require(cancelled.status==ImportStatus::cancelled,"cancel before commit failed");require(pipeline.load_asset(first.asset_id).generation==active,"cancel replaced active");
ImportRequest changed_settings{source};changed_settings.settings={{"target","test-profile"}};auto settings=pipeline.import_asset(changed_settings);success(settings);require(settings.generation!=active,"recipe omitted from cache key");
const auto plain=root/"ordinary.glb";glb(plain,"Ordinary",false);auto standard=pipeline.import_asset({plain});success(standard);require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,"ordinary GLB wrongly marked stable source");
glb(plain,"Renamed without ID",false);require(pipeline.import_asset({plain}).status==ImportStatus::conflict,"ambiguous rename silently matched");
auto duplicate=document("Duplicate",true,true);duplicate["nodes"][1]["extras"]["faset_id"]="node-door";duplicate["buffers"][0]["uri"]="mesh.bin";save(root/"mesh.bin",geometry(0));save(root/"duplicate.gltf",duplicate.dump());require(pipeline.import_asset({root/"duplicate.gltf"}).status==ImportStatus::failed,"duplicate source ID accepted");
auto external=document("External",true,false);external["buffers"][0]["uri"]="mesh.bin";external["images"]=Json::array({{{"uri","pixel.png"},{"mimeType","image/png"}}});external["textures"]=Json::array({{{"source",0}}});external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"]={{"index",0}};
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
const std::vector<unsigned char> png={137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,11,73,68,65,84,120,156,99,96,0,2,0,0,5,0,1,165,246,69,64,0,0,0,0,73,69,78,68,174,66,96,130};
save(root/"pixel.png",png);save(root/"external.gltf",external.dump());auto ext=pipeline.import_asset({root/"external.gltf"});success(ext);auto ext_asset=pipeline.load_asset(ext.asset_id);require(ext_asset.textures.size()==1&&ext_asset.textures[0].bytes.size()==png.size(),"external image not extracted");require(ext_asset.materials[0].base_color_texture==0,"material texture reference lost");
save(root/"mesh.bin",geometry(.3f));auto dependent=pipeline.import_asset({root/"external.gltf"});success(dependent);require(dependent.generation!=ext.generation,"buffer dependency not invalidated");
// A Blender bundle keeps its logical source stable while immutable payload paths change.
const auto bundle_dir=root/"bundle";fs::create_directories(bundle_dir/"payload");
glb(bundle_dir/"payload"/"first.glb","Bundle",true,false);
Json bundle{{"schema_version",1},{"asset_id","bundle-asset"},{"files",Json::array({{{"path","payload/first.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"first.glb")}}})}};
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_first=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_first);require(bundle_first.asset_id=="bundle-asset","bundle identity lost");
glb(bundle_dir/"payload"/"second.glb","Bundle renamed",true,false,.4f);bundle["files"][0]={{"path","payload/second.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"second.glb")}};
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_second=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_second);require(bundle_second.asset_id==bundle_first.asset_id&&bundle_second.generation!=bundle_first.generation,"bundle reimport identity/generation");
bundle["files"][0]["sha256"]=std::string(64,'0');save(bundle_dir/"manifest.json",bundle.dump());require(pipeline.import_asset({bundle_dir/"manifest.json"}).status==ImportStatus::failed,"bundle checksum ignored");require(pipeline.load_asset("bundle-asset").generation==bundle_second.generation,"bad bundle replaced active");
// Changing a dependency after it was snapshotted cannot publish mixed content.
const auto dependency_active=pipeline.load_asset(ext.asset_id).generation;
ImportJob mutate([&](const ImportProgress& progress){if(progress.fraction==.5f)save(root/"mesh.bin",geometry(.7f));});
require(pipeline.import_asset({root/"external.gltf"},mutate).status==ImportStatus::failed,"concurrent dependency edit accepted");require(pipeline.load_asset(ext.asset_id).generation==dependency_active,"concurrent edit changed active");
fs::remove_all(root/"cache");auto restored=pipeline.import_asset({source});success(restored);require(restored.asset_id==first.asset_id,"cleared cache changed AssetId");require(restored.manifest["settings"]==changed_settings.settings,"persisted recipe lost");require(pipeline.overrides(first.asset_id)==custom,"cleared cache lost authoring overrides");
fs::remove_all(root);std::cout<<"assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, failure, cancellation OK\n";return 0;
} catch(const std::exception& e){std::cerr<<e.what()<<"\nFixtures retained: "<<root<<'\n';return 1;}
}