Files
BlueArchiveToolkit/infrastructure/src/import.rs
T
nyaKazuha 2079c6a307 feat(sync): 接入解析缓存与汉化发布前置
补齐官方 release 解析缓存、TextUnit 明细索引、资源变更集、Crowdin handoff 预留、ResourceRepository 导入元数据和 localized release patch 前置链路。

同时开放文件级 patch.apply 与 UnityFS TextAsset/string/semantic field patch CLI/RPC 入口,并保留官方原版资源与汉化产物双目录发布状态。

验证:cargo test -p bat-assetbundle --locked;cargo clippy -p bat-assetbundle --all-targets --locked -- -D warnings;cargo test -p bat-infrastructure --locked。
2026-07-31 00:38:45 +08:00

915 lines
33 KiB
Rust

//! 资源导入服务。
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<u8>,
}
impl BundleSource {
/// 创建 Bundle 输入。
pub fn new(path: impl Into<String>, data: impl Into<Vec<u8>>) -> 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<UnityFsImportSummary>,
}
/// 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<String>,
/// 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<String>,
/// 非致命 serialized-file 解析诊断数量。
pub serialized_parse_error_count: usize,
/// 从 TextAsset 和 TypeTree 字段提取出的 TextUnit 数量。
pub text_unit_count: usize,
/// TextUnit 格式标签。
pub text_unit_formats: Vec<String>,
/// TextUnit 提取阶段的非致命诊断数量。
pub text_unit_error_count: usize,
}
/// Manifest 导入报告。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceImportReport {
/// 成功导入的资源。
pub imported: Vec<ImportedResource>,
/// 被跳过的资源路径。
pub skipped: Vec<String>,
/// 按稳定分类标签统计的成功导入数量。
pub category_counts: BTreeMap<String, usize>,
}
/// 将解析后的资源清单写入 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<ResourceImportReport> {
let mut report = ResourceImportReport::default();
// 记录本次已写入的 CAS 对象和资源 id;任一条目失败时回滚,避免留下部分导入状态。
let mut stored_objects: Vec<String> = Vec::new();
let mut added_resources: Vec<String> = 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<String>,
added_resources: &mut Vec<String>,
) -> 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<UnityFsImportSummary> {
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::<BTreeSet<_>>()
.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<Option<&'a BundleSource>> {
// 精确路径优先。
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<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
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<u8> {
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<u8> {
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<ResourceEntry>) -> 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
);
}
}