//! 资源导入服务。 use bat_adapters::manifest::GenericManifest; use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry}; use bat_assetbundle::TextUnitExtractor; use bat_core::domain::{Resource, ResourceEntry, ResourceMetadata, ResourceType}; use bat_core::repositories::{CasRepository, ResourceRepository}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; /// 待导入的 AssetBundle 数据。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct BundleSource { /// Bundle 路径或文件名。 pub path: String, /// Bundle 原始字节。 pub data: Vec, } impl BundleSource { /// 创建 Bundle 输入。 pub fn new(path: impl Into, data: impl Into>) -> Self { Self { path: path.into(), data: data.into(), } } } /// 单个导入结果。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportedResource { /// 写入 `ResourceRepository` 的资源 ID。 pub id: String, /// Manifest 中的资源路径。 pub source_path: String, /// Manifest 或路径推断出的资源类型。 pub resource_type: ResourceType, /// 导入链路使用的稳定分类标签。 pub category: ResourceImportCategory, /// CAS 返回的对象 ID。 pub object_id: String, /// 写入 CAS 的字节数。 pub bytes: u64, /// UnityFS 解析摘要,仅 AssetBundle 资源会填充。 pub unityfs: Option, } /// Stable resource category produced by the import pipeline. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ResourceImportCategory { /// UnityFS AssetBundle resource. AssetBundle, /// Unity TextAsset or text-like payload. TextAsset, /// Table bundle/database resource. Table, /// Media payload such as audio, video, texture, or media archive. Media, /// Manifest/catalog sidecar. Manifest, /// Resource that does not match a known category yet. Other, } impl ResourceImportCategory { /// Returns a stable lowercase label for reports and golden tests. pub fn as_str(self) -> &'static str { match self { Self::AssetBundle => "asset_bundle", Self::TextAsset => "text_asset", Self::Table => "table", Self::Media => "media", Self::Manifest => "manifest", Self::Other => "other", } } } /// 导入时解析到的 UnityFS 基础结构摘要。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnityFsImportSummary { /// Bundle 声明的 Unity 版本。 pub unity_version: String, /// UnityFS block 数量。 pub block_count: usize, /// UnityFS directory 数量。 pub directory_count: usize, /// UnityFS directory 路径。 pub directories: Vec, /// UnityFS directory 解出的文件数量。 pub file_count: usize, /// 成功解析出的 Unity serialized file 数量。 pub serialized_file_count: usize, /// 成功解析出的 TextAsset 数量。 pub text_asset_count: usize, /// TextAsset 名称列表。 pub text_assets: Vec, /// 非致命 serialized-file 解析诊断数量。 pub serialized_parse_error_count: usize, /// 从 TextAsset 和 TypeTree 字段提取出的 TextUnit 数量。 pub text_unit_count: usize, /// TextUnit 格式标签。 pub text_unit_formats: Vec, /// TextUnit 提取阶段的非致命诊断数量。 pub text_unit_error_count: usize, } /// Manifest 导入报告。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ResourceImportReport { /// 成功导入的资源。 pub imported: Vec, /// 被跳过的资源路径。 pub skipped: Vec, /// 按稳定分类标签统计的成功导入数量。 pub category_counts: BTreeMap, } /// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。 pub struct ResourceImportService<'a> { cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository, unity_adapters: UnityAdapterRegistry, } impl<'a> ResourceImportService<'a> { /// 创建导入服务。 pub fn new(cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository) -> Self { Self::with_unity_adapters(cas, resources, UnityAdapterRegistry::with_defaults()) } /// 使用指定 Unity 适配器注册表创建导入服务。 pub fn with_unity_adapters( cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository, unity_adapters: UnityAdapterRegistry, ) -> Self { Self { cas, resources, unity_adapters, } } /// 导入 manifest 中可用的资源数据。 /// /// AssetBundle 条目必须能在 `bundles` 中按完整路径或文件名找到对应 /// 数据并成功解析 UnityFS,否则返回错误。TextAsset/Table/Media 等 /// 非 AssetBundle 条目在有数据时写入 CAS 和 `ResourceRepository`;缺少 /// 数据时记录到 `skipped`,用于渐进式导入官方下载结果。 pub async fn import_manifest_bundles( &self, manifest: &GenericManifest, bundles: &[BundleSource], ) -> bat_core::Result { let mut report = ResourceImportReport::default(); // 记录本次已写入的 CAS 对象和资源 id;任一条目失败时回滚,避免留下部分导入状态。 let mut stored_objects: Vec = Vec::new(); let mut added_resources: Vec = Vec::new(); for entry in &manifest.resources { if let Err(error) = self .import_single_entry( entry, bundles, &mut report, &mut stored_objects, &mut added_resources, ) .await { self.rollback_import(&stored_objects, &added_resources) .await; return Err(error); } } Ok(report) } async fn import_single_entry( &self, entry: &ResourceEntry, bundles: &[BundleSource], report: &mut ResourceImportReport, stored_objects: &mut Vec, added_resources: &mut Vec, ) -> bat_core::Result<()> { let category = classify_import_category(entry.resource_type, &entry.path); let Some(bundle) = find_bundle(bundles, &entry.path)? else { if entry.resource_type == ResourceType::AssetBundle { return Err(bat_core::Error::InvalidArgument(format!( "Missing bundle data for manifest resource: {}", entry.path ))); } report.skipped.push(entry.path.clone()); return Ok(()); }; let unityfs = if entry.resource_type == ResourceType::AssetBundle { Some(self.parse_unityfs_summary(&entry.path, bundle).await?) } else { None }; let object_id = self.cas.store(&bundle.data).await?; stored_objects.push(object_id.clone()); let mut stored_entry = entry.clone(); stored_entry.hash = object_id.clone(); stored_entry.size = bundle.data.len() as u64; let resource = Resource { id: resource_id_for_path(&entry.path), local_path: PathBuf::from(&entry.path), entry: stored_entry, metadata: ResourceMetadata::default(), }; let id = self.resources.add(resource).await?; added_resources.push(id.clone()); report.imported.push(ImportedResource { id, source_path: entry.path.clone(), resource_type: entry.resource_type, category, object_id, bytes: bundle.data.len() as u64, unityfs, }); *report .category_counts .entry(category.as_str().to_string()) .or_insert(0) += 1; Ok(()) } /// 回滚本次导入已写入的资源索引和 CAS 引用(best-effort)。 /// /// 撤销 `resources.add` 与 `cas.store`:删除本次新增的资源行,并对本次 /// store 增加的引用逐一 `remove_reference`(同一对象被引用多次则递减多次), /// 使中途失败不留下部分导入状态。 async fn rollback_import(&self, stored_objects: &[String], added_resources: &[String]) { for id in added_resources { let _ = self.resources.delete(id).await; } for object_id in stored_objects { let _ = self.cas.remove_reference(object_id).await; } } async fn parse_unityfs_summary( &self, manifest_path: &str, bundle: &BundleSource, ) -> bat_core::Result { let raw_bundle = RawAssetBundle { data: bundle.data.clone(), path: Some(manifest_path.to_string()), }; let adapter = self .unity_adapters .select_adapter(&raw_bundle) .map_err(|error| { bat_core::Error::InvalidArgument(format!( "No Unity adapter for manifest resource {}: {}", manifest_path, error )) })?; let parsed = adapter.parse(&raw_bundle).await.map_err(|error| { bat_core::Error::InvalidArgument(format!( "Invalid UnityFS bundle for manifest resource {}: {}", manifest_path, error )) })?; let text_units = TextUnitExtractor::new().extract_bundle(&parsed, Some(manifest_path)); let text_unit_formats = text_units .units .iter() .filter_map(|unit| unit.context.get("format").cloned()) .collect::>() .into_iter() .collect(); Ok(UnityFsImportSummary { unity_version: parsed.unity_version, block_count: parsed.blocks.len(), directory_count: parsed.directories.len(), directories: parsed .directories .into_iter() .map(|directory| directory.path) .collect(), file_count: parsed.files.len(), serialized_file_count: parsed.serialized_files.len(), text_asset_count: parsed.text_assets.len(), text_assets: parsed .text_assets .into_iter() .map(|asset| asset.name) .collect(), serialized_parse_error_count: parsed.serialized_parse_errors.len(), text_unit_count: text_units.units.len(), text_unit_formats, text_unit_error_count: text_units.errors.len(), }) } } fn resource_id_for_path(path: &str) -> String { format!("resource/{}", path) } fn find_bundle<'a>( bundles: &'a [BundleSource], manifest_path: &str, ) -> bat_core::Result> { // 精确路径优先。 if let Some(bundle) = bundles.iter().find(|bundle| bundle.path == manifest_path) { return Ok(Some(bundle)); } // 文件名回退:仅当恰好唯一匹配时采用,避免跨目录同名(如 en/data.bundle 与 // jp/data.bundle)静默取错数据;多个同名 bundle 时显式报错而非猜测。 let Some(manifest_name) = file_name(manifest_path) else { return Ok(None); }; let mut matches = bundles .iter() .filter(|bundle| file_name(&bundle.path) == Some(manifest_name)); let Some(first) = matches.next() else { return Ok(None); }; if matches.next().is_some() { return Err(bat_core::Error::InvalidArgument(format!( "manifest 资源 {manifest_path} 存在多个同名 bundle,无法确定使用哪一个" ))); } Ok(Some(first)) } fn file_name(path: &str) -> Option<&str> { Path::new(path).file_name()?.to_str() } fn classify_import_category(resource_type: ResourceType, path: &str) -> ResourceImportCategory { match resource_type { ResourceType::AssetBundle => ResourceImportCategory::AssetBundle, ResourceType::TextAsset => ResourceImportCategory::TextAsset, ResourceType::TableBundle => ResourceImportCategory::Table, ResourceType::Media => ResourceImportCategory::Media, ResourceType::Manifest => ResourceImportCategory::Manifest, ResourceType::Other => classify_import_category_from_path(path), } } fn classify_import_category_from_path(path: &str) -> ResourceImportCategory { let normalized = path.replace('\\', "/").to_ascii_lowercase(); if normalized.contains("tablebundles/") { ResourceImportCategory::Table } else if normalized.contains("mediaresources/") || normalized.contains("mediaresources-") || matches!( normalized.rsplit('.').next(), Some("mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb") ) { ResourceImportCategory::Media } else if normalized.contains("textassets/") || matches!( normalized.rsplit('.').next(), Some("txt" | "csv" | "xml" | "yaml" | "yml") ) { ResourceImportCategory::TextAsset } else if normalized.contains("catalog") || normalized.ends_with(".hash") { ResourceImportCategory::Manifest } else { ResourceImportCategory::Other } } #[cfg(test)] mod tests { use super::*; use crate::{FileSystemCasRepository, InMemoryResourceRepository}; use bat_adapters::manifest::{GenericManifest, ManifestFormat, ManifestMetadata}; use bat_core::domain::ResourceEntry; use bat_core::repositories::cas_repository::CasRepository; use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository}; use std::collections::HashMap; use tempfile::TempDir; fn push_c_string(data: &mut Vec, value: &str) { data.extend_from_slice(value.as_bytes()); data.push(0); } fn push_u16(data: &mut Vec, value: u16) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u32(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_be_bytes()); } fn push_i32(data: &mut Vec, value: i32) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u64(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_be_bytes()); } fn push_i16_le(data: &mut Vec, value: i16) { data.extend_from_slice(&value.to_le_bytes()); } fn push_u32_le(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i32_le(data: &mut Vec, value: i32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i64_le(data: &mut Vec, value: i64) { data.extend_from_slice(&value.to_le_bytes()); } fn push_u64_le(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_le_bytes()); } fn align(data: &mut Vec, alignment: usize) { let remainder = data.len() % alignment; if remainder != 0 { data.resize(data.len() + alignment - remainder, 0); } } fn synthetic_minimal_unityfs_bundle() -> Vec { let mut blocks_info = Vec::new(); blocks_info.extend_from_slice(&[0; 16]); push_i32(&mut blocks_info, 1); push_u32(&mut blocks_info, 4); push_u32(&mut blocks_info, 4); push_u16(&mut blocks_info, 0); push_i32(&mut blocks_info, 1); push_u64(&mut blocks_info, 0); push_u64(&mut blocks_info, 4); push_u32(&mut blocks_info, 0); push_c_string(&mut blocks_info, "SYNTHETIC-CAB"); let mut data = Vec::new(); push_c_string(&mut data, "UnityFS"); push_u32(&mut data, 8); push_c_string(&mut data, "5.x.x"); push_c_string(&mut data, "2021.3.56f2"); push_u64(&mut data, 0); push_u32(&mut data, blocks_info.len() as u32); push_u32(&mut data, blocks_info.len() as u32); push_u32(&mut data, 0); align(&mut data, 16); data.extend_from_slice(&blocks_info); data.extend_from_slice(b"data"); let total_size = data.len() as u64; let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len(); data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); data } fn synthetic_text_asset_unityfs_bundle() -> Vec { let serialized_file = synthetic_serialized_text_asset(); let mut blocks_info = Vec::new(); blocks_info.extend_from_slice(&[1; 16]); push_i32(&mut blocks_info, 1); push_u32(&mut blocks_info, serialized_file.len() as u32); push_u32(&mut blocks_info, serialized_file.len() as u32); push_u16(&mut blocks_info, 0); push_i32(&mut blocks_info, 1); push_u64(&mut blocks_info, 0); push_u64(&mut blocks_info, serialized_file.len() as u64); push_u32(&mut blocks_info, 0); push_c_string(&mut blocks_info, "CAB-scenario"); let mut data = Vec::new(); push_c_string(&mut data, "UnityFS"); push_u32(&mut data, 8); push_c_string(&mut data, "5.x.x"); push_c_string(&mut data, "2021.3.56f2"); push_u64(&mut data, 0); push_u32(&mut data, blocks_info.len() as u32); push_u32(&mut data, blocks_info.len() as u32); push_u32(&mut data, 0); align(&mut data, 16); data.extend_from_slice(&blocks_info); data.extend_from_slice(&serialized_file); let total_size = data.len() as u64; let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len(); data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes()); data } fn synthetic_serialized_text_asset() -> Vec { let mut object_data = Vec::new(); push_u32_le(&mut object_data, 8); object_data.extend_from_slice(b"Scenario"); align(&mut object_data, 4); push_u32_le(&mut object_data, 15); object_data.extend_from_slice("こんにちは".as_bytes()); let mut metadata = Vec::new(); metadata.extend_from_slice(b"2021.3.56f2\0"); push_i32_le(&mut metadata, 19); metadata.push(0); push_i32_le(&mut metadata, 1); push_i32_le(&mut metadata, 49); metadata.push(0); push_i16_le(&mut metadata, 0); metadata.extend_from_slice(&[0; 16]); push_i32_le(&mut metadata, 1); align(&mut metadata, 4); push_i64_le(&mut metadata, 1); push_u64_le(&mut metadata, 0); push_u32_le(&mut metadata, object_data.len() as u32); push_i32_le(&mut metadata, 0); let header_len = 48usize; let data_offset = header_len + metadata.len(); let file_size = data_offset + object_data.len(); let mut file = Vec::new(); push_u32(&mut file, metadata.len() as u32); push_u32(&mut file, file_size as u32); push_u32(&mut file, 22); push_u32(&mut file, 0); file.push(0); file.extend_from_slice(&[0, 0, 0]); push_u32(&mut file, metadata.len() as u32); push_u64(&mut file, file_size as u64); push_u64(&mut file, data_offset as u64); push_u64(&mut file, 0); file.extend_from_slice(&metadata); file.extend_from_slice(&object_data); file } fn synthetic_manifest() -> GenericManifest { GenericManifest { format: ManifestFormat::AddressablesCatalog, resources: vec![ ResourceEntry { path: "synthetic/minimal.bundle".to_string(), hash: "synthetic-manifest-hash".to_string(), size: 99, resource_type: ResourceType::AssetBundle, address: None, dependencies: Vec::new(), crc: None, }, ResourceEntry { path: "synthetic/catalog.json".to_string(), hash: "synthetic-catalog-hash".to_string(), size: 1, resource_type: ResourceType::Manifest, address: None, dependencies: Vec::new(), crc: None, }, ResourceEntry { path: "TextAssets/dialogue.csv".to_string(), hash: "synthetic-text-hash".to_string(), size: 1, resource_type: ResourceType::TextAsset, address: Some("dialogue".to_string()), dependencies: Vec::new(), crc: None, }, ResourceEntry { path: "TableBundles/ExcelDB.db".to_string(), hash: "synthetic-table-hash".to_string(), size: 1, resource_type: ResourceType::TableBundle, address: None, dependencies: Vec::new(), crc: None, }, ResourceEntry { path: "MediaResources-Windows/voice/title.acb".to_string(), hash: "synthetic-media-hash".to_string(), size: 1, resource_type: ResourceType::Media, address: None, dependencies: Vec::new(), crc: None, }, ], metadata: ManifestMetadata { locator_id: None, cdn_prefixes: Vec::new(), extra: HashMap::new(), }, } } fn manifest_with(resources: Vec) -> GenericManifest { GenericManifest { format: ManifestFormat::AddressablesCatalog, resources, metadata: ManifestMetadata { locator_id: None, cdn_prefixes: Vec::new(), extra: HashMap::new(), }, } } fn text_entry(path: &str) -> ResourceEntry { ResourceEntry { path: path.to_string(), hash: "h".to_string(), size: 1, resource_type: ResourceType::TextAsset, address: None, dependencies: Vec::new(), crc: None, } } #[tokio::test] async fn import_rolls_back_when_a_later_entry_fails() { let temp_dir = TempDir::new().unwrap(); let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); let resources = InMemoryResourceRepository::new(); let service = ResourceImportService::new(&cas, &resources); // 先一个可导入的 TextAsset,再一个数据非 UnityFS 的 AssetBundle(解析失败)。 let manifest = manifest_with(vec![ text_entry("TextAssets/ok.csv"), ResourceEntry { path: "bad.bundle".to_string(), hash: "h2".to_string(), size: 1, resource_type: ResourceType::AssetBundle, address: None, dependencies: Vec::new(), crc: None, }, ]); let result = service .import_manifest_bundles( &manifest, &[ BundleSource::new("ok.csv", b"a,b".to_vec()), BundleSource::new("bad.bundle", b"not-unityfs".to_vec()), ], ) .await; assert!(result.is_err()); // 回滚:先前成功的 TextAsset 资源行不应残留。 assert!(resources .list(ResourceQuery::all()) .await .unwrap() .is_empty()); // 其 CAS 对象引用已回滚为 0。 let ok_object = cas.store(b"a,b").await.unwrap(); assert_eq!(cas.get_reference_count(&ok_object).await.unwrap(), 1); } #[tokio::test] async fn import_rejects_same_name_bundle_ambiguity() { let temp_dir = TempDir::new().unwrap(); let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); let resources = InMemoryResourceRepository::new(); let service = ResourceImportService::new(&cas, &resources); // 精确路径 jp/data.txt 不存在,回退按文件名匹配时存在两个 data.txt。 let manifest = manifest_with(vec![text_entry("jp/data.txt")]); let error = service .import_manifest_bundles( &manifest, &[ BundleSource::new("en/data.txt", b"en".to_vec()), BundleSource::new("other/data.txt", b"other".to_vec()), ], ) .await .unwrap_err(); assert!(error.to_string().contains("多个同名 bundle")); } #[tokio::test] async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() { let temp_dir = TempDir::new().unwrap(); let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); let resources = InMemoryResourceRepository::new(); let service = ResourceImportService::new(&cas, &resources); let report = service .import_manifest_bundles( &synthetic_manifest(), &[ BundleSource::new("minimal.bundle", synthetic_minimal_unityfs_bundle()), BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()), BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()), BundleSource::new("title.acb", b"media-fixture".to_vec()), ], ) .await .unwrap(); assert_eq!(report.imported.len(), 4); assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]); assert!(cas.exists(&report.imported[0].object_id).await.unwrap()); assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle); assert_eq!( report.imported[0].category, ResourceImportCategory::AssetBundle ); let unityfs = report.imported[0].unityfs.as_ref().unwrap(); assert_eq!(unityfs.unity_version, "2021.3.56f2"); assert_eq!(unityfs.block_count, 1); assert_eq!(unityfs.directory_count, 1); assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]); assert_eq!(unityfs.file_count, 1); assert_eq!(unityfs.serialized_file_count, 0); assert_eq!(unityfs.text_asset_count, 0); assert!(unityfs.text_assets.is_empty()); assert_eq!(unityfs.serialized_parse_error_count, 0); assert_eq!(unityfs.text_unit_count, 0); assert!(unityfs.text_unit_formats.is_empty()); assert_eq!(unityfs.text_unit_error_count, 0); assert_eq!( report.imported[1].category, ResourceImportCategory::TextAsset ); assert_eq!(report.imported[2].category, ResourceImportCategory::Table); assert_eq!(report.imported[3].category, ResourceImportCategory::Media); assert_eq!(report.category_counts.get("asset_bundle"), Some(&1)); assert_eq!(report.category_counts.get("text_asset"), Some(&1)); assert_eq!(report.category_counts.get("table"), Some(&1)); assert_eq!(report.category_counts.get("media"), Some(&1)); let indexed = resources.list(ResourceQuery::all()).await.unwrap(); assert_eq!(indexed.len(), 4); let indexed_bundle = indexed .iter() .find(|resource| resource.entry.path == "synthetic/minimal.bundle") .unwrap(); assert_eq!(indexed_bundle.entry.hash, report.imported[0].object_id); assert_eq!( indexed_bundle.entry.size, synthetic_minimal_unityfs_bundle().len() as u64 ); assert_eq!( indexed .iter() .find(|resource| resource.entry.path.ends_with("title.acb")) .unwrap() .entry .resource_type, ResourceType::Media ); assert_eq!( indexed .iter() .find(|resource| resource.entry.path.ends_with("ExcelDB.db")) .unwrap() .entry .resource_type, ResourceType::TableBundle ); assert_eq!( indexed .iter() .find(|resource| resource.entry.path.ends_with("dialogue.csv")) .unwrap() .entry .resource_type, ResourceType::TextAsset ); } #[tokio::test] async fn import_summary_reports_text_assets_inside_assetbundle() { let temp_dir = TempDir::new().unwrap(); let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); let resources = InMemoryResourceRepository::new(); let service = ResourceImportService::new(&cas, &resources); let manifest = manifest_with(vec![ResourceEntry { path: "synthetic/scenario.bundle".to_string(), hash: "synthetic-scenario-hash".to_string(), size: 1, resource_type: ResourceType::AssetBundle, address: None, dependencies: Vec::new(), crc: None, }]); let report = service .import_manifest_bundles( &manifest, &[BundleSource::new( "scenario.bundle", synthetic_text_asset_unityfs_bundle(), )], ) .await .unwrap(); let unityfs = report.imported[0].unityfs.as_ref().unwrap(); assert_eq!(unityfs.file_count, 1); assert_eq!(unityfs.serialized_file_count, 1); assert_eq!(unityfs.text_asset_count, 1); assert_eq!(unityfs.text_assets, vec!["Scenario".to_string()]); assert_eq!(unityfs.serialized_parse_error_count, 0); assert_eq!(unityfs.text_unit_count, 1); assert_eq!(unityfs.text_unit_formats, vec!["plain".to_string()]); assert_eq!(unityfs.text_unit_error_count, 0); } #[tokio::test] async fn returns_error_when_bundle_data_is_missing() { let temp_dir = TempDir::new().unwrap(); let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); let resources = InMemoryResourceRepository::new(); let service = ResourceImportService::new(&cas, &resources); let error = service .import_manifest_bundles(&synthetic_manifest(), &[]) .await .unwrap_err(); assert!(matches!(error, bat_core::Error::InvalidArgument(_))); } #[tokio::test] async fn returns_error_when_bundle_is_not_unityfs() { let temp_dir = TempDir::new().unwrap(); let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); let resources = InMemoryResourceRepository::new(); let service = ResourceImportService::new(&cas, &resources); let error = service .import_manifest_bundles( &synthetic_manifest(), &[BundleSource::new( "minimal.bundle", b"not-a-unityfs-bundle".to_vec(), )], ) .await .unwrap_err(); assert!(matches!(error, bat_core::Error::InvalidArgument(_))); assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0); } #[tokio::test] async fn indexes_non_bundle_resources_without_unityfs_summary() { let temp_dir = TempDir::new().unwrap(); let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); let resources = InMemoryResourceRepository::new(); let service = ResourceImportService::new(&cas, &resources); let mut manifest = synthetic_manifest(); manifest .resources .retain(|entry| entry.resource_type != ResourceType::AssetBundle); let report = service .import_manifest_bundles( &manifest, &[ BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()), BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()), BundleSource::new("title.acb", b"media-fixture".to_vec()), ], ) .await .unwrap(); assert_eq!(report.imported.len(), 3); assert!(report.imported.iter().all(|item| item.unityfs.is_none())); assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]); assert_eq!( resources .count(ResourceQuery::by_type(ResourceType::TextAsset)) .await .unwrap(), 1 ); assert_eq!( resources .count(ResourceQuery::by_type(ResourceType::TableBundle)) .await .unwrap(), 1 ); assert_eq!( resources .count(ResourceQuery::by_type(ResourceType::Media)) .await .unwrap(), 1 ); } }