Skip to main content

car/
decode.rs

1use std::{borrow::Cow, io::Read};
2
3use flate2::read::MultiGzDecoder;
4use lzfse_rust::LzfseRingDecoder;
5use thiserror::Error;
6
7use crate::car::{CarError, CarResult};
8use crate::model::rendition::{
9    CompressionType, MultisizeImageSetEntry, Rendition, RenditionThemeCBCK,
10};
11use crate::model::{ColorSpace, Encoding};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OrientationPolicy {
15    Ignore,
16    ApplyExif,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct DecodeOptions {
21    pub max_output_bytes: Option<usize>,
22    pub orientation_policy: OrientationPolicy,
23}
24
25impl Default for DecodeOptions {
26    fn default() -> Self {
27        Self {
28            max_output_bytes: None,
29            orientation_policy: OrientationPolicy::Ignore,
30        }
31    }
32}
33
34impl DecodeOptions {
35    pub fn with_max_output_bytes(mut self, max_output_bytes: usize) -> Self {
36        self.max_output_bytes = Some(max_output_bytes);
37        self
38    }
39
40    pub fn apply_exif_orientation(mut self, apply: bool) -> Self {
41        self.orientation_policy = if apply {
42            OrientationPolicy::ApplyExif
43        } else {
44            OrientationPolicy::Ignore
45        };
46        self
47    }
48
49    pub(crate) fn check_output_bytes(&self, requested: usize, stage: DecodeStage) -> CarResult<()> {
50        if let Some(limit) = self.max_output_bytes
51            && requested > limit
52        {
53            return Err(CarError::DecodeBudgetExceeded(
54                DecodeBudgetError::OutputBytesExceeded {
55                    limit,
56                    requested,
57                    stage,
58                },
59            ));
60        }
61        Ok(())
62    }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum DecodeStage {
67    BorrowedPayload,
68    Uncompressed,
69    Lzfse,
70    Zip,
71    Lzvn,
72    Deepmap2,
73    ImageConversion,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Error)]
77pub enum DecodeBudgetError {
78    #[error(
79        "decoded output would exceed budget at {stage:?}: requested {requested}, limit {limit}"
80    )]
81    OutputBytesExceeded {
82        limit: usize,
83        requested: usize,
84        stage: DecodeStage,
85    },
86}
87
88#[derive(Debug)]
89pub enum RenditionData {
90    Color {
91        color_space: ColorSpace,
92        components: Vec<f64>,
93    },
94    Image {
95        data: Vec<u8>,
96    },
97    RawData {
98        data: Vec<u8>,
99    },
100    MultisizeImageSet {
101        entries: Vec<MultisizeImageSetEntry>,
102    },
103}
104
105#[derive(Debug)]
106pub enum RenditionDataRef<'a> {
107    Color {
108        color_space: Cow<'a, ColorSpace>,
109        components: Cow<'a, [f64]>,
110    },
111    Image {
112        data: Cow<'a, [u8]>,
113    },
114    RawData {
115        data: Cow<'a, [u8]>,
116    },
117    MultisizeImageSet {
118        entries: Cow<'a, [MultisizeImageSetEntry]>,
119    },
120}
121
122impl<'a> RenditionDataRef<'a> {
123    pub fn into_owned(self) -> RenditionData {
124        match self {
125            Self::Color {
126                color_space,
127                components,
128            } => RenditionData::Color {
129                color_space: color_space.into_owned(),
130                components: components.into_owned(),
131            },
132            Self::Image { data } => RenditionData::Image {
133                data: data.into_owned(),
134            },
135            Self::RawData { data } => RenditionData::RawData {
136                data: data.into_owned(),
137            },
138            Self::MultisizeImageSet { entries } => RenditionData::MultisizeImageSet {
139                entries: entries.into_owned(),
140            },
141        }
142    }
143
144    fn from_owned(data: RenditionData) -> Self {
145        match data {
146            RenditionData::Color {
147                color_space,
148                components,
149            } => Self::Color {
150                color_space: Cow::Owned(color_space),
151                components: Cow::Owned(components),
152            },
153            RenditionData::Image { data } => Self::Image {
154                data: Cow::Owned(data),
155            },
156            RenditionData::RawData { data } => Self::RawData {
157                data: Cow::Owned(data),
158            },
159            RenditionData::MultisizeImageSet { entries } => Self::MultisizeImageSet {
160                entries: Cow::Owned(entries),
161            },
162        }
163    }
164}
165
166fn check_borrowed_payload_bytes<T>(options: &DecodeOptions, payload: &[T]) -> CarResult<()> {
167    options.check_output_bytes(std::mem::size_of_val(payload), DecodeStage::BorrowedPayload)
168}
169
170impl crate::car::CSIItem {
171    pub fn decode_data_ref(&self) -> CarResult<Option<RenditionDataRef<'_>>> {
172        self.decode_data_ref_with_options(&DecodeOptions::default())
173    }
174
175    pub fn decode_data_ref_with_options(
176        &self,
177        options: &DecodeOptions,
178    ) -> CarResult<Option<RenditionDataRef<'_>>> {
179        let rendition = match &self.header.rendition {
180            None => return Ok(None),
181            Some(r) => r,
182        };
183
184        match rendition {
185            Rendition::Color(rc) => {
186                check_borrowed_payload_bytes(options, rc.components.as_slice())?;
187                Ok(Some(RenditionDataRef::Color {
188                    color_space: Cow::Borrowed(&rc.color_space),
189                    components: Cow::Borrowed(&rc.components),
190                }))
191            }
192            Rendition::RawData(rd) => {
193                check_borrowed_payload_bytes(options, rd.raw_data.as_slice())?;
194                Ok(Some(RenditionDataRef::RawData {
195                    data: Cow::Borrowed(rd.raw_data.as_slice()),
196                }))
197            }
198            Rendition::MultisizeImageSet(mis) => {
199                check_borrowed_payload_bytes(options, mis.entries.as_slice())?;
200                Ok(Some(RenditionDataRef::MultisizeImageSet {
201                    entries: Cow::Borrowed(mis.entries.as_slice()),
202                }))
203            }
204            Rendition::ThemeCBCK(cbck) => decode_theme_cbck(self, cbck, options)
205                .map(|data| data.map(RenditionDataRef::from_owned)),
206            Rendition::Unknown { tag, .. } => Err(CarError::DecodeFailed(format!(
207                "Unknown rendition type: {:?}",
208                tag
209            ))),
210        }
211    }
212
213    pub fn decode_data_owned(&self) -> CarResult<Option<RenditionData>> {
214        self.decode_data_ref_with_options(&DecodeOptions::default())
215            .map(|data| data.map(RenditionDataRef::into_owned))
216    }
217
218    pub fn decode_data_owned_with_options(
219        &self,
220        options: &DecodeOptions,
221    ) -> CarResult<Option<RenditionData>> {
222        self.decode_data_ref_with_options(options)
223            .map(|data| data.map(RenditionDataRef::into_owned))
224    }
225
226    pub fn decode_data(&self) -> CarResult<Option<RenditionData>> {
227        self.decode_data_owned()
228    }
229
230    pub fn decode_data_with_options(
231        &self,
232        options: &DecodeOptions,
233    ) -> CarResult<Option<RenditionData>> {
234        self.decode_data_owned_with_options(options)
235    }
236}
237
238fn deepmap2_pixel_format_override(encoding: Encoding) -> Option<deepmap2::PixelFormat> {
239    match encoding {
240        Encoding::ARGB => Some(deepmap2::PixelFormat::Rgba8888),
241        Encoding::ARGB16 => Some(deepmap2::PixelFormat::Rgba8888),
242        Encoding::GA8 => Some(deepmap2::PixelFormat::GA88),
243        _ => None,
244    }
245}
246
247/// 像素编码的每像素字节数(仅对具有固定 pixel stride 的 raster encoding 返回 Some)
248fn encoding_bytes_per_pixel(encoding: Encoding) -> Option<usize> {
249    match encoding {
250        Encoding::ARGB => Some(4),
251        Encoding::GRAY => Some(1),
252        Encoding::GA8 => Some(2),
253        Encoding::ARGB16 => Some(8),
254        Encoding::GA16 => Some(4),
255        Encoding::RGB5 => Some(2),
256        _ => None,
257    }
258}
259
260fn chunk_output_height(
261    chunk: &crate::model::rendition::ThemeChunk,
262    total_chunks: usize,
263    full_height: u16,
264) -> CarResult<Option<u16>> {
265    match chunk.part_header.as_ref().map(|header| header.height) {
266        Some(height) if total_chunks == 1 && height == 0 => Ok(Some(full_height)),
267        None if total_chunks == 1 => Ok(Some(full_height)),
268        Some(0) | None => Ok(None),
269        Some(height) => u16::try_from(height).map(Some).map_err(|_| {
270            CarError::DecodeFailed(format!("Theme part height exceeds u16: {height}"))
271        }),
272    }
273}
274
275fn dmp2_region(chunk: &[u8], dmp2_pos: usize) -> &[u8] {
276    let search_start = dmp2_pos.saturating_sub(0x40);
277    if let Some(prev_kcbc_rel) = deepmap2::tile::find_bytes(&chunk[search_start..dmp2_pos], b"KCBC")
278    {
279        let kcbc_pos = search_start + prev_kcbc_rel;
280        if kcbc_pos + 20 <= chunk.len() {
281            let block_length =
282                u32::from_le_bytes(chunk[kcbc_pos + 0x10..kcbc_pos + 0x14].try_into().unwrap())
283                    as usize;
284            let block_end = kcbc_pos.saturating_add(block_length);
285            if block_length >= 0x20 && block_end > dmp2_pos && block_end <= chunk.len() {
286                return &chunk[dmp2_pos..block_end];
287            }
288        }
289    }
290
291    if let Some(next_rel) = deepmap2::tile::find_bytes(&chunk[dmp2_pos + 4..], b"KCBC") {
292        let next_kcbc = dmp2_pos + 4 + next_rel;
293        return &chunk[dmp2_pos..next_kcbc];
294    }
295
296    &chunk[dmp2_pos..]
297}
298
299fn decode_theme_cbck(
300    item: &crate::car::CSIItem,
301    cbck: &RenditionThemeCBCK,
302    options: &DecodeOptions,
303) -> CarResult<Option<RenditionData>> {
304    match &cbck.compression_type {
305        CompressionType::Uncompressed => {
306            let total: usize = cbck.chunks.iter().map(|c| c.raw_data.len()).sum();
307            options.check_output_bytes(total, DecodeStage::Uncompressed)?;
308            let mut data = Vec::with_capacity(total);
309            for chunk in &cbck.chunks {
310                data.extend_from_slice(chunk.raw_data.as_slice());
311            }
312            Ok(Some(RenditionData::Image { data }))
313        }
314        CompressionType::Lzfse => {
315            let decode_chunk = |raw: &[u8]| -> CarResult<Vec<u8>> {
316                let cap = raw.len().saturating_mul(16).max(4096);
317                let mut output = Vec::with_capacity(cap);
318                let mut decoder = LzfseRingDecoder::default();
319                let n = decoder.decode_bytes(raw, &mut output).map_err(|_| {
320                    CarError::DecodeFailed("LZFSE decompression failed".to_string())
321                })?;
322                if n == 0 && !raw.is_empty() {
323                    return Err(CarError::DecodeFailed(
324                        "LZFSE produced zero bytes for non-empty input".to_string(),
325                    ));
326                }
327                output.truncate(n as usize);
328                Ok(output)
329            };
330
331            // 顺序解压:避免嵌套并行与外层 extract par_iter 争抢线程池。
332            let mut all_data = Vec::new();
333            let mut total = 0usize;
334            for chunk in &cbck.chunks {
335                let part = decode_chunk(chunk.raw_data.as_slice())?;
336                total = total.checked_add(part.len()).ok_or_else(|| {
337                    CarError::DecodeFailed("LZFSE output length overflow".to_string())
338                })?;
339                options.check_output_bytes(total, DecodeStage::Lzfse)?;
340                all_data.reserve(part.len());
341                all_data.extend_from_slice(&part);
342            }
343
344            Ok(Some(RenditionData::Image { data: all_data }))
345        }
346        CompressionType::Zip => {
347            let mut all_data = Vec::new();
348            let mut total = 0usize;
349            for chunk in &cbck.chunks {
350                let mut decoder = MultiGzDecoder::new(chunk.raw_data.as_slice());
351                let mut part = Vec::new();
352                decoder.read_to_end(&mut part).map_err(|err| {
353                    CarError::DecodeFailed(format!("Zip/gzip decompression failed: {err}"))
354                })?;
355                total = total.checked_add(part.len()).ok_or_else(|| {
356                    CarError::DecodeFailed("Zip/gzip output length overflow".to_string())
357                })?;
358                options.check_output_bytes(total, DecodeStage::Zip)?;
359                all_data.reserve(part.len());
360                all_data.extend_from_slice(&part);
361            }
362
363            Ok(Some(RenditionData::Image { data: all_data }))
364        }
365        CompressionType::Lzvn => {
366            let width = item.header.width as usize;
367            let height = item.header.height as usize;
368            // 与 Uncompressed/Lzfse 分支的契约一致:返回原始编码字节,
369            // 按实际 encoding 的 bpp(以及可能存在的 BytesPerRow stride)计算
370            // decoded_len —— lzvn::decode_raw 要求精确长度。
371            let bpp = encoding_bytes_per_pixel(item.header.encoding).ok_or_else(|| {
372                CarError::DecodeFailed(format!(
373                    "LZVN decompression unsupported for encoding {:?}",
374                    item.header.encoding
375                ))
376            })?;
377            let min_row = width * bpp;
378            // 优先使用 BytesPerRow TLV(可能大于 width*bpp,表示带行填充),
379            // 否则回退到紧凑布局。to_image() 端会基于同一 TLV 剥离 stride。
380            let bytes_per_row = match item.bytes_per_row_tlv() {
381                Some(bpr) => {
382                    let bpr = bpr as usize;
383                    if bpr < min_row {
384                        return Err(CarError::DecodeFailed(format!(
385                            "bytes_per_row ({}) smaller than minimum row size ({})",
386                            bpr, min_row
387                        )));
388                    }
389                    bpr
390                }
391                None => min_row,
392            };
393            let expected_total = bytes_per_row * height;
394            options.check_output_bytes(expected_total, DecodeStage::Lzvn)?;
395            let mut all_data = Vec::with_capacity(expected_total);
396            let is_single = cbck.chunks.len() == 1;
397            for chunk in &cbck.chunks {
398                // 单 chunk 对应整图,多 chunk 必须依据 part_header.height 计算本段行数。
399                let expected_chunk = if is_single {
400                    expected_total
401                } else {
402                    let chunk_height = chunk
403                        .part_header
404                        .as_ref()
405                        .map(|h| h.height as usize)
406                        .filter(|h| *h > 0)
407                        .ok_or_else(|| {
408                            CarError::DecodeFailed(
409                                "LZVN multi-chunk missing part_header height".to_string(),
410                            )
411                        })?;
412                    chunk_height * bytes_per_row
413                };
414                let decompressed =
415                    deepmap2::codec::decompress_lzvn(chunk.raw_data.as_slice(), expected_chunk)?;
416                all_data.extend_from_slice(&decompressed);
417            }
418            if all_data.len() != expected_total {
419                return Err(CarError::DecodeFailed(format!(
420                    "LZVN output size mismatch: expected {}, got {}",
421                    expected_total,
422                    all_data.len()
423                )));
424            }
425            Ok(Some(RenditionData::Image { data: all_data }))
426        }
427        CompressionType::Deepmap2 => {
428            let width = u16::try_from(item.header.width)
429                .map_err(|_| CarError::DecodeFailed("CSI width exceeds u16".to_string()))?;
430            let height = u16::try_from(item.header.height)
431                .map_err(|_| CarError::DecodeFailed("CSI height exceeds u16".to_string()))?;
432            let pixel_format_override = deepmap2_pixel_format_override(item.header.encoding);
433            let mut all_data = Vec::new();
434            options.check_output_bytes(
435                (width as usize)
436                    .saturating_mul(height as usize)
437                    .saturating_mul(4),
438                DecodeStage::Deepmap2,
439            )?;
440            let mut expected_width: Option<u16> = None;
441            let mut decoded_height = 0u32;
442            for chunk in &cbck.chunks {
443                let output_height = chunk_output_height(chunk, cbck.chunks.len(), height)?;
444                let chunk = chunk.raw_data.as_slice();
445                let kcbc_pos = deepmap2::tile::find_bytes(chunk, b"KCBC");
446                let dmp2_pos = deepmap2::tile::find_bytes(chunk, b"dmp2");
447                let img = match (kcbc_pos, dmp2_pos) {
448                    (Some(k), Some(d)) if k <= d => deepmap2::decode_kcbc_with_options(
449                        &chunk[k..],
450                        Some(width),
451                        None,
452                        pixel_format_override,
453                    )?,
454                    (Some(_), Some(d)) => deepmap2::decode_with_options(
455                        dmp2_region(chunk, d),
456                        Some(width),
457                        output_height,
458                        pixel_format_override,
459                    )?,
460                    (Some(k), None) => deepmap2::decode_kcbc_with_options(
461                        &chunk[k..],
462                        Some(width),
463                        None,
464                        pixel_format_override,
465                    )?,
466                    (None, Some(d)) => deepmap2::decode_with_options(
467                        dmp2_region(chunk, d),
468                        Some(width),
469                        output_height,
470                        pixel_format_override,
471                    )?,
472                    (None, None) => {
473                        return Err(CarError::DecodeFailed(
474                            "No deepmap2 magic found in chunk".to_string(),
475                        ));
476                    }
477                };
478                match expected_width {
479                    None => expected_width = Some(img.width),
480                    Some(w) if w != img.width => {
481                        return Err(CarError::DecodeFailed(format!(
482                            "Deepmap2 chunk width mismatch: expected {}, got {}",
483                            w, img.width
484                        )));
485                    }
486                    _ => {}
487                }
488                decoded_height = decoded_height.saturating_add(u32::from(img.height));
489                all_data.extend_from_slice(&img.rgba);
490                options.check_output_bytes(all_data.len(), DecodeStage::Deepmap2)?;
491            }
492            if cbck.chunks.len() > 1 && decoded_height != u32::from(height) {
493                return Err(CarError::DecodeFailed(format!(
494                    "Deepmap2 chunk height mismatch: expected {}, got {}",
495                    height, decoded_height
496                )));
497            }
498            Ok(Some(RenditionData::Image { data: all_data }))
499        }
500        _ => Err(CarError::UnsupportedCompression(cbck.compression_type)),
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use std::borrow::Cow;
507    use std::io::{Cursor, Write};
508
509    use deku::{DekuReader, reader::Reader};
510    use flate2::{Compression, write::GzEncoder};
511    use std::path::PathBuf;
512
513    use super::{
514        DecodeBudgetError, DecodeOptions, DecodeStage, RenditionData, RenditionDataRef,
515        deepmap2_pixel_format_override, dmp2_region,
516    };
517    use crate::car::{CSIItem, Car, CarError};
518    use crate::model::CSIHeader;
519    use crate::model::rendition::{AttributeType, CompressionType, LayoutType};
520
521    const COMP_UNCOMPRESSED: u32 = 0;
522    const COMP_ZIP: u32 = 2;
523    const COMP_LZVN: u32 = 3;
524    const COMP_LZFSE: u32 = 4;
525    const COMP_HEVC: u32 = 9;
526    const COMP_DEEPMAP2: u32 = 11;
527
528    fn fixture_path(name: &str) -> PathBuf {
529        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
530            .join("../car-tests/data")
531            .join(name)
532    }
533
534    fn fixture_car() -> Car {
535        Car::new(fixture_path("Assets.car")).expect("load test Assets.car")
536    }
537
538    #[test]
539    fn decode_data_returns_none_when_rendition_is_missing() {
540        let item = build_item(2, 2, None);
541
542        assert!(matches!(item.decode_data(), Ok(None)));
543    }
544
545    #[test]
546    fn decode_data_rejects_unknown_rendition_variant() {
547        let item = build_item(2, 2, Some(&build_unknown_rendition(*b"FAIL", b"raw")));
548
549        let err = match item.decode_data() {
550            Ok(_) => panic!("unknown rendition should fail"),
551            Err(err) => err,
552        };
553        assert!(matches!(err, CarError::DecodeFailed(_)));
554        assert!(format!("{err}").contains("Unknown rendition type"));
555    }
556
557    #[test]
558    fn decode_data_rejects_invalid_lzfse_payload() {
559        let item = build_item(
560            2,
561            2,
562            Some(&build_theme_cbck_single(COMP_LZFSE, b"not-valid-lzfse")),
563        );
564
565        let err = match item.decode_data() {
566            Ok(_) => panic!("invalid LZFSE should fail"),
567            Err(err) => err,
568        };
569        assert!(matches!(err, CarError::DecodeFailed(_)));
570        assert!(format!("{err}").contains("LZFSE"));
571    }
572
573    #[test]
574    fn decode_data_rejects_unsupported_compression() {
575        let item = build_item(
576            2,
577            2,
578            Some(&build_theme_cbck_single(COMP_HEVC, b"hevc-bytes")),
579        );
580
581        let err = match item.decode_data() {
582            Ok(_) => panic!("unsupported compression should fail"),
583            Err(err) => err,
584        };
585        assert!(matches!(
586            err,
587            CarError::UnsupportedCompression(CompressionType::HEVC)
588        ));
589    }
590
591    #[test]
592    fn decode_data_decodes_zip_gzip_chunks() {
593        let first = gzip_bytes(&[1, 2, 3, 4]);
594        let second = gzip_bytes(&[5, 6, 7, 8]);
595        let item = build_item(
596            2,
597            1,
598            Some(&build_theme_cbck_multi(COMP_ZIP, &[&first, &second])),
599        );
600
601        let decoded = item.decode_data().expect("gzip Zip decode should succeed");
602        let Some(RenditionData::Image { data }) = decoded else {
603            panic!("expected RenditionData::Image");
604        };
605        assert_eq!(data, vec![1, 2, 3, 4, 5, 6, 7, 8]);
606    }
607
608    #[test]
609    fn decode_data_rejects_invalid_zip_gzip_payload() {
610        let item = build_item(
611            2,
612            1,
613            Some(&build_theme_cbck_single(COMP_ZIP, b"not-valid-gzip")),
614        );
615
616        let err = item
617            .decode_data()
618            .expect_err("invalid gzip Zip payload should fail");
619        assert!(
620            matches!(err, CarError::DecodeFailed(_)),
621            "expected DecodeFailed, got {err:?}"
622        );
623        assert!(format!("{err}").contains("Zip/gzip decompression failed"));
624    }
625
626    #[test]
627    fn decode_data_with_options_rejects_uncompressed_output_over_budget() {
628        let item = build_item(
629            2,
630            2,
631            Some(&build_theme_cbck_single(COMP_UNCOMPRESSED, &[1, 2, 3, 4])),
632        );
633        let options = DecodeOptions::default().with_max_output_bytes(3);
634
635        let err = item
636            .decode_data_with_options(&options)
637            .expect_err("decoded bytes should exceed budget");
638        assert!(matches!(err, CarError::DecodeBudgetExceeded(_)));
639    }
640
641    #[test]
642    fn decode_data_ref_with_options_rejects_raw_data_payload_over_budget() {
643        let item = build_item(1, 1, Some(&build_raw_data_rendition(&[1, 2, 3, 4])));
644        let options = DecodeOptions::default().with_max_output_bytes(3);
645
646        let err = item
647            .decode_data_ref_with_options(&options)
648            .expect_err("borrowed raw data should exceed budget");
649        assert_borrowed_payload_budget(err);
650    }
651
652    #[test]
653    fn raw_data_payload_bytes_view_and_borrowed_decode() {
654        let payload = [1, 2, 3, 4];
655        let item = build_item(1, 1, Some(&build_raw_data_rendition(&payload)));
656        let Some(crate::model::rendition::Rendition::RawData(raw)) = &item.header.rendition else {
657            panic!("expected raw data rendition");
658        };
659
660        assert_eq!(raw.raw_data.as_slice(), payload);
661        assert_eq!(raw.raw_data.to_vec(), payload);
662
663        let decoded = item
664            .decode_data_ref()
665            .expect("raw data should decode")
666            .expect("raw data should be present");
667        let RenditionDataRef::RawData {
668            data: Cow::Borrowed(bytes),
669        } = decoded
670        else {
671            panic!("expected borrowed raw data");
672        };
673        assert_eq!(bytes, payload);
674    }
675
676    #[test]
677    fn decode_data_with_options_rejects_color_payload_over_budget() {
678        let item = build_item(1, 1, Some(&build_color_rendition(&[1.0])));
679        let options = DecodeOptions::default().with_max_output_bytes(7);
680
681        let err = item
682            .decode_data_with_options(&options)
683            .expect_err("borrowed color data should exceed budget");
684        assert_borrowed_payload_budget(err);
685    }
686
687    #[test]
688    fn decode_data_with_options_rejects_multisize_payload_over_budget() {
689        let item = build_item(1, 1, Some(&build_multisize_rendition(&[(16, 16, 0, 0)])));
690        let options = DecodeOptions::default().with_max_output_bytes(0);
691
692        let err = item
693            .decode_data_with_options(&options)
694            .expect_err("borrowed multisize data should exceed budget");
695        assert_borrowed_payload_budget(err);
696    }
697
698    #[test]
699    fn decode_data_scans_for_offset_dmp2_magic() {
700        let dmp2 = build_dmp2_none_g8(2, 1, &[1, 2]);
701        let prefixed = build_prefixed_chunk(b"prefix", &dmp2);
702        let item = build_item(
703            2,
704            1,
705            Some(&build_theme_cbck_single(COMP_DEEPMAP2, &prefixed)),
706        );
707
708        let decoded = item.decode_data().expect("decode should succeed");
709        let Some(RenditionData::Image { data }) = decoded else {
710            panic!("expected image data");
711        };
712        assert_eq!(data, rgba_from_grays(&[1, 2]));
713    }
714
715    #[test]
716    fn decode_data_scans_for_offset_kcbc_magic() {
717        let dmp2 = build_dmp2_none_g8(2, 1, &[7, 8]);
718        let kcbc = build_kcbc(&[(0, 0, 1, dmp2.as_slice())]);
719        let prefixed = build_prefixed_chunk(b"wrapper", &kcbc);
720        let item = build_item(
721            2,
722            1,
723            Some(&build_theme_cbck_single(COMP_DEEPMAP2, &prefixed)),
724        );
725
726        let decoded = item.decode_data().expect("decode should succeed");
727        let Some(RenditionData::Image { data }) = decoded else {
728            panic!("expected image data");
729        };
730        assert_eq!(data, rgba_from_grays(&[7, 8]));
731    }
732
733    #[test]
734    fn decode_data_prefers_earlier_dmp2_over_later_kcbc() {
735        // dmp2 magic appears before a spurious KCBC sequence in the same chunk
736        let dmp2 = build_dmp2_none_g8(2, 1, &[5, 6]);
737        let mut chunk = Vec::new();
738        chunk.extend_from_slice(b"junk");
739        chunk.extend_from_slice(&dmp2);
740        // Append KCBC bytes after the dmp2 payload — should be ignored
741        chunk.extend_from_slice(b"KCBC");
742        chunk.extend_from_slice(&[0u8; 16]);
743        let item = build_item(2, 1, Some(&build_theme_cbck_single(COMP_DEEPMAP2, &chunk)));
744
745        let decoded = item.decode_data().expect("decode should succeed");
746        let Some(RenditionData::Image { data }) = decoded else {
747            panic!("expected image data");
748        };
749        assert_eq!(data, rgba_from_grays(&[5, 6]));
750    }
751
752    #[test]
753    fn decode_data_prefers_earlier_kcbc_over_later_dmp2() {
754        // KCBC magic appears before a trailing dmp2 sequence
755        let inner_dmp2 = build_dmp2_none_g8(2, 1, &[9, 10]);
756        let kcbc = build_kcbc(&[(0, 0, 1, inner_dmp2.as_slice())]);
757        let mut chunk = Vec::new();
758        chunk.extend_from_slice(b"hdr");
759        chunk.extend_from_slice(&kcbc);
760        // Append a standalone dmp2 after KCBC — should be ignored
761        let trailing_dmp2 = build_dmp2_none_g8(2, 1, &[99, 99]);
762        chunk.extend_from_slice(&trailing_dmp2);
763        let item = build_item(2, 1, Some(&build_theme_cbck_single(COMP_DEEPMAP2, &chunk)));
764
765        let decoded = item.decode_data().expect("decode should succeed");
766        let Some(RenditionData::Image { data }) = decoded else {
767            panic!("expected image data");
768        };
769        assert_eq!(data, rgba_from_grays(&[9, 10]));
770    }
771
772    #[test]
773    fn decode_data_reassembles_multi_chunk_deepmap2_strips() {
774        let first = build_dmp2_none_g8(2, 1, &[1, 2]);
775        let second = build_dmp2_none_g8(2, 1, &[3, 4]);
776        let item = build_item(
777            2,
778            2,
779            Some(&build_theme_cbck_multi(
780                COMP_DEEPMAP2,
781                &[first.as_slice(), second.as_slice()],
782            )),
783        );
784
785        let decoded = item.decode_data().expect("decode should succeed");
786        let Some(RenditionData::Image { data }) = decoded else {
787            panic!("expected image data");
788        };
789        assert_eq!(data, rgba_from_grays(&[1, 2, 3, 4]));
790    }
791
792    #[test]
793    fn decode_data_rejects_deepmap2_chunk_without_magic() {
794        let item = build_item(
795            2,
796            1,
797            Some(&build_theme_cbck_single(COMP_DEEPMAP2, b"missing-magic")),
798        );
799
800        let err = match item.decode_data() {
801            Ok(_) => panic!("missing magic should fail"),
802            Err(err) => err,
803        };
804        assert!(matches!(err, CarError::DecodeFailed(_)));
805        assert!(format!("{err}").contains("magic"));
806    }
807
808    #[test]
809    fn decode_data_rejects_invalid_multi_chunk_geometry() {
810        let first = build_dmp2_none_g8(2, 1, &[1, 2]);
811        let second = build_dmp2_none_g8(1, 1, &[3]);
812        let item = build_item(
813            2,
814            2,
815            Some(&build_theme_cbck_multi(
816                COMP_DEEPMAP2,
817                &[first.as_slice(), second.as_slice()],
818            )),
819        );
820
821        let err = match item.decode_data() {
822            Ok(_) => panic!("mismatched widths should fail"),
823            Err(err) => err,
824        };
825        assert!(matches!(
826            err,
827            CarError::Deepmap2(_) | CarError::DecodeFailed(_)
828        ));
829    }
830
831    #[test]
832    fn decode_data_lzvn_honors_bytes_per_row_stride() {
833        // 回归:LZVN 分支此前按 width*height*bpp 硬算 expected_total,忽略了
834        // BytesPerRow TLV 声明的行步幅,导致带 stride 填充的合法资源
835        // 在 lzvn::decode_raw 阶段就因长度不匹配而失败,根本走不到
836        // to_image() 的去填充逻辑。
837        //
838        // 构造 2×3 GRAY(bpp=1)图像,BytesPerRow=4(每行 2 字节填充)。
839        // 原始字节按 stride*height=12 组织,经 LZVN 压缩后作为 ThemeCBCK 载荷。
840        let raw_pixels: [u8; 12] = [
841            0x11, 0x22, 0x00, 0x00, // row 0: [0x11, 0x22] + 2 字节填充
842            0x33, 0x44, 0x00, 0x00, // row 1
843            0x55, 0x66, 0x00, 0x00, // row 2
844        ];
845        let compressed = lzvn::encode_raw(&raw_pixels);
846        let rendition = build_theme_cbck_single(COMP_LZVN, &compressed);
847        let tlv = bytes_per_row_tlv(4);
848        let item = build_item_ex(2, 3, b"YARG", &tlv, Some(&rendition));
849
850        let decoded = item
851            .decode_data()
852            .expect("LZVN decode with stride should succeed");
853        let Some(RenditionData::Image { data }) = decoded else {
854            panic!("expected RenditionData::Image");
855        };
856        // decode_data 应返回带 stride 的原始字节(与 Uncompressed/Lzfse 契约一致),
857        // 由 to_image() 基于同一 BytesPerRow TLV 剥离填充。
858        assert_eq!(data, raw_pixels);
859    }
860
861    #[test]
862    fn decode_data_lzvn_rejects_bytes_per_row_below_minimum() {
863        // BytesPerRow TLV 若小于 width*bpp 应直接报错,而不是尝试解码后静默出错。
864        let raw_pixels: [u8; 4] = [0x11, 0x22, 0x33, 0x44];
865        let compressed = lzvn::encode_raw(&raw_pixels);
866        let rendition = build_theme_cbck_single(COMP_LZVN, &compressed);
867        let tlv = bytes_per_row_tlv(1); // width=2 → min_row=2,但 TLV 声明 1
868        let item = build_item_ex(2, 2, b"YARG", &tlv, Some(&rendition));
869
870        let err = match item.decode_data() {
871            Ok(_) => panic!("expected bytes_per_row underflow to fail"),
872            Err(err) => err,
873        };
874        assert!(matches!(err, CarError::DecodeFailed(_)));
875        assert!(format!("{err}").contains("bytes_per_row"));
876    }
877
878    #[test]
879    fn decode_data_decodes_real_assets_car_deepmap2_problem_cases() {
880        let car = fixture_car();
881        for name in [
882            "2016_coin1",
883            "AirKissHelper",
884            "CarPlayCallRecord",
885            "CardPkgGiftCardBackGroundImage",
886            "ChatMessagesPreviewSelectZoomBorder",
887            "DeleteData_Loading",
888        ] {
889            let item = car
890                .rendtions_with_name(name)
891                .and_then(|items| {
892                    items
893                        .iter()
894                        .find(|item| !matches!(item.layout(), LayoutType::InternalReference))
895                })
896                .unwrap_or_else(|| panic!("missing fixture rendition: {name}"));
897            let decoded = item
898                .decode_data()
899                .unwrap_or_else(|err| panic!("decode should succeed for {name}: {err}"));
900            assert!(
901                matches!(decoded, Some(RenditionData::Image { .. })),
902                "{name} should decode to image data"
903            );
904        }
905
906        let item = car
907            .rendtions_with_name("live_playmembers_background")
908            .and_then(|items| {
909                items
910                    .iter()
911                    .find(|item| !matches!(item.layout(), LayoutType::InternalReference))
912            })
913            .expect("missing fixture rendition: live_playmembers_background");
914        let decoded = item.decode_data().unwrap_or_else(|err| {
915            panic!("decode should succeed for live_playmembers_background: {err}")
916        });
917        let Some(RenditionData::Image { data }) = decoded else {
918            panic!("live_playmembers_background should decode to image data");
919        };
920        assert_eq!(data.len(), 1170 * 828 * 4);
921    }
922
923    #[test]
924    fn decode_data_decodes_bootimage_ipad_palette_lzvn_tail_chunk() {
925        let car = fixture_car();
926        let item = car
927            .rendtions_with_name("BootImage")
928            .and_then(|items| {
929                items
930                    .iter()
931                    .find(|item| item.name() == "Default@2x~ipad.png")
932            })
933            .expect("missing BootImage Default@2x~ipad.png rendition");
934
935        let decoded = item
936            .decode_data()
937            .unwrap_or_else(|err| panic!("decode should succeed for BootImage iPad: {err}"));
938        let Some(RenditionData::Image { data }) = decoded else {
939            panic!("BootImage iPad should decode to image data");
940        };
941        assert_eq!(data.len(), 1536 * 2048 * 4);
942    }
943
944    #[test]
945    fn decode_data_decodes_real_assets_car_zip_app_icons() {
946        let car = fixture_car();
947        let items = car
948            .rendtions_with_name("AppIcon")
949            .expect("AppIcon fixture facet");
950
951        for (name, expected_len) in [
952            ("AppIcon_1024.png", 1024 * 1024 * 4),
953            ("AppIcon_1024_dark.png", 1024 * 1024 * 4),
954            ("AppIcon_1024_tinted.png", 1024 * 1024 * 2),
955        ] {
956            let variants = items
957                .iter()
958                .filter(|item| item.name() == name)
959                .collect::<Vec<_>>();
960            assert_eq!(
961                variants.len(),
962                2,
963                "{name} should have phone and pad variants"
964            );
965
966            let mut decoded_by_idiom = Vec::new();
967            for item in variants {
968                assert_eq!(item.compression(), Some(CompressionType::Zip));
969                assert_eq!(item.unsupported_reason(), None);
970                let idiom = item
971                    .attributes()
972                    .iter()
973                    .find(|attr| attr.name == AttributeType::Idiom)
974                    .map(|attr| attr.val)
975                    .expect("AppIcon Zip variant should carry idiom");
976                let decoded = item
977                    .decode_data()
978                    .unwrap_or_else(|err| panic!("Zip decode should succeed for {name}: {err}"));
979                let Some(RenditionData::Image { data }) = decoded else {
980                    panic!("{name} should decode to image data");
981                };
982                assert_eq!(data.len(), expected_len, "{name} decoded length");
983                decoded_by_idiom.push((idiom, item.key_values().to_vec(), data));
984            }
985
986            decoded_by_idiom.sort_by_key(|(idiom, _, _)| *idiom);
987            assert_eq!(
988                decoded_by_idiom[0].0, 1,
989                "{name} first idiom should be phone"
990            );
991            assert_eq!(
992                decoded_by_idiom[1].0, 2,
993                "{name} second idiom should be pad"
994            );
995            assert_ne!(
996                decoded_by_idiom[0].1, decoded_by_idiom[1].1,
997                "{name} phone and pad keys should differ"
998            );
999            assert_eq!(
1000                decoded_by_idiom[0].2, decoded_by_idiom[1].2,
1001                "{name} phone and pad payloads should match"
1002            );
1003        }
1004    }
1005
1006    #[test]
1007    fn deepmap2_pixel_format_override_matches_csi_mapping() {
1008        assert_eq!(
1009            deepmap2_pixel_format_override(crate::model::Encoding::ARGB),
1010            Some(deepmap2::PixelFormat::Rgba8888)
1011        );
1012        assert_eq!(
1013            deepmap2_pixel_format_override(crate::model::Encoding::ARGB16),
1014            Some(deepmap2::PixelFormat::Rgba8888)
1015        );
1016        assert_eq!(
1017            deepmap2_pixel_format_override(crate::model::Encoding::GA8),
1018            Some(deepmap2::PixelFormat::GA88)
1019        );
1020    }
1021
1022    #[test]
1023    fn dmp2_region_stops_before_next_kcbc() {
1024        let mut chunk = Vec::new();
1025        chunk.extend_from_slice(b"prefix");
1026        let dmp2_pos = chunk.len();
1027        chunk.extend_from_slice(b"dmp2");
1028        chunk.extend_from_slice(&[1, 2, 3, 4]);
1029        chunk.extend_from_slice(b"KCBC");
1030        chunk.extend_from_slice(&[9, 9, 9, 9]);
1031
1032        assert_eq!(dmp2_region(&chunk, dmp2_pos), b"dmp2\x01\x02\x03\x04");
1033    }
1034
1035    fn build_item(width: u32, height: u32, rendition: Option<&[u8]>) -> CSIItem {
1036        build_item_ex(width, height, b"ATAD", &[], rendition)
1037    }
1038
1039    fn build_item_ex(
1040        width: u32,
1041        height: u32,
1042        encoding: &[u8; 4],
1043        tlv: &[u8],
1044        rendition: Option<&[u8]>,
1045    ) -> CSIItem {
1046        let bytes = build_csi_header(width, height, encoding, tlv, rendition);
1047        let mut reader = Reader::new(Cursor::new(bytes));
1048        let header =
1049            CSIHeader::from_reader_with_ctx(&mut reader, ()).expect("test header should decode");
1050        CSIItem {
1051            attrs: Vec::new(),
1052            header,
1053            key_values: Box::default(),
1054        }
1055    }
1056
1057    fn build_csi_header(
1058        width: u32,
1059        height: u32,
1060        encoding: &[u8; 4],
1061        tlv: &[u8],
1062        rendition: Option<&[u8]>,
1063    ) -> Vec<u8> {
1064        let mut bytes = Vec::new();
1065        bytes.extend_from_slice(b"ISTC");
1066        bytes.extend_from_slice(&1u32.to_le_bytes());
1067        bytes.extend_from_slice(&0u32.to_le_bytes());
1068        bytes.extend_from_slice(&width.to_le_bytes());
1069        bytes.extend_from_slice(&height.to_le_bytes());
1070        bytes.extend_from_slice(&100u32.to_le_bytes());
1071        bytes.extend_from_slice(encoding);
1072        bytes.extend_from_slice(&0u32.to_le_bytes());
1073        bytes.extend_from_slice(&0u32.to_le_bytes());
1074        bytes.extend_from_slice(&1000u32.to_le_bytes());
1075        bytes.extend_from_slice(&[0u8; 128]);
1076        bytes.extend_from_slice(&(tlv.len() as u32).to_le_bytes());
1077        bytes.extend_from_slice(&1u32.to_le_bytes());
1078        bytes.extend_from_slice(&0u32.to_le_bytes());
1079        bytes.extend_from_slice(&(rendition.map_or(0, |data| data.len()) as u32).to_le_bytes());
1080        bytes.extend_from_slice(tlv);
1081        if let Some(rendition) = rendition {
1082            bytes.extend_from_slice(rendition);
1083        }
1084        bytes
1085    }
1086
1087    fn bytes_per_row_tlv(bpr: u32) -> Vec<u8> {
1088        let mut bytes = Vec::new();
1089        bytes.extend_from_slice(&1007u32.to_le_bytes()); // BytesPerRow tag
1090        bytes.extend_from_slice(&4u32.to_le_bytes()); // length
1091        bytes.extend_from_slice(&bpr.to_le_bytes());
1092        bytes
1093    }
1094
1095    fn assert_borrowed_payload_budget(err: CarError) {
1096        match err {
1097            CarError::DecodeBudgetExceeded(DecodeBudgetError::OutputBytesExceeded {
1098                stage: DecodeStage::BorrowedPayload,
1099                ..
1100            }) => {}
1101            err => panic!("expected borrowed payload budget error, got {err:?}"),
1102        }
1103    }
1104
1105    fn build_color_rendition(components: &[f64]) -> Vec<u8> {
1106        let mut bytes = Vec::new();
1107        bytes.extend_from_slice(b"RLOC");
1108        bytes.extend_from_slice(&1u32.to_le_bytes());
1109        bytes.extend_from_slice(&1u32.to_le_bytes()); // ColorSpace::SRGB
1110        bytes.extend_from_slice(&(components.len() as u32).to_le_bytes());
1111        for component in components {
1112            bytes.extend_from_slice(&component.to_le_bytes());
1113        }
1114        bytes
1115    }
1116
1117    fn build_raw_data_rendition(raw_data: &[u8]) -> Vec<u8> {
1118        let mut bytes = Vec::new();
1119        bytes.extend_from_slice(b"DWAR");
1120        bytes.extend_from_slice(&1u32.to_le_bytes());
1121        bytes.extend_from_slice(&(raw_data.len() as u32).to_le_bytes());
1122        bytes.extend_from_slice(raw_data);
1123        bytes
1124    }
1125
1126    fn build_multisize_rendition(entries: &[(u32, u32, u16, u16)]) -> Vec<u8> {
1127        let mut bytes = Vec::new();
1128        bytes.extend_from_slice(b"SISM");
1129        bytes.extend_from_slice(&1u32.to_le_bytes());
1130        bytes.extend_from_slice(&(entries.len() as u32).to_le_bytes());
1131        for &(width, height, index, idiom) in entries {
1132            bytes.extend_from_slice(&width.to_le_bytes());
1133            bytes.extend_from_slice(&height.to_le_bytes());
1134            bytes.extend_from_slice(&index.to_le_bytes());
1135            bytes.extend_from_slice(&idiom.to_le_bytes());
1136        }
1137        bytes
1138    }
1139
1140    fn build_unknown_rendition(tag: [u8; 4], raw_data: &[u8]) -> Vec<u8> {
1141        let mut bytes = Vec::new();
1142        bytes.extend_from_slice(&tag);
1143        bytes.extend_from_slice(&1u32.to_le_bytes());
1144        bytes.extend_from_slice(&(raw_data.len() as u32).to_le_bytes());
1145        bytes.extend_from_slice(raw_data);
1146        bytes
1147    }
1148
1149    fn build_theme_cbck_single(compression: u32, chunk: &[u8]) -> Vec<u8> {
1150        let mut bytes = Vec::new();
1151        bytes.extend_from_slice(b"MLEC");
1152        bytes.extend_from_slice(&0u32.to_le_bytes());
1153        bytes.extend_from_slice(&compression.to_le_bytes());
1154        bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
1155        bytes.extend_from_slice(chunk);
1156        bytes
1157    }
1158
1159    fn build_theme_cbck_multi(compression: u32, chunks: &[&[u8]]) -> Vec<u8> {
1160        let mut bytes = Vec::new();
1161        bytes.extend_from_slice(b"MLEC");
1162        bytes.extend_from_slice(&1u32.to_le_bytes());
1163        bytes.extend_from_slice(&compression.to_le_bytes());
1164        bytes.extend_from_slice(&(chunks.len() as u32).to_le_bytes());
1165        for chunk in chunks {
1166            bytes.extend_from_slice(b"PART");
1167            bytes.extend_from_slice(&0u32.to_le_bytes());
1168            bytes.extend_from_slice(&0u32.to_le_bytes());
1169            bytes.extend_from_slice(&0u32.to_le_bytes());
1170            bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
1171            bytes.extend_from_slice(chunk);
1172        }
1173        bytes
1174    }
1175
1176    fn gzip_bytes(raw: &[u8]) -> Vec<u8> {
1177        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1178        encoder.write_all(raw).expect("write gzip test payload");
1179        encoder.finish().expect("finish gzip test payload")
1180    }
1181
1182    fn build_prefixed_chunk(prefix: &[u8], payload: &[u8]) -> Vec<u8> {
1183        let mut bytes = Vec::new();
1184        bytes.extend_from_slice(prefix);
1185        bytes.extend_from_slice(payload);
1186        bytes
1187    }
1188
1189    fn build_dmp2_none_g8(width: u16, height: u16, pixels: &[u8]) -> Vec<u8> {
1190        assert_eq!(pixels.len(), width as usize * height as usize);
1191        let mut data = Vec::new();
1192        data.extend_from_slice(b"dmp2");
1193        data.push(0x01);
1194        data.push(0x00);
1195        data.push(0x00);
1196        data.push(0x01);
1197        data.extend_from_slice(&width.to_le_bytes());
1198        data.extend_from_slice(&height.to_le_bytes());
1199        data.extend_from_slice(pixels);
1200        data
1201    }
1202
1203    fn build_kcbc(tiles: &[(u32, u32, u32, &[u8])]) -> Vec<u8> {
1204        let mut data = Vec::new();
1205        for &(row, col, row_hint, dmp2) in tiles {
1206            let block_len = (20 + dmp2.len()) as u32;
1207            data.extend_from_slice(b"KCBC");
1208            data.extend_from_slice(&col.to_le_bytes());
1209            data.extend_from_slice(&row.to_le_bytes());
1210            data.extend_from_slice(&row_hint.to_le_bytes());
1211            data.extend_from_slice(&block_len.to_le_bytes());
1212            data.extend_from_slice(dmp2);
1213        }
1214        data
1215    }
1216
1217    fn rgba_from_grays(values: &[u8]) -> Vec<u8> {
1218        let mut data = Vec::with_capacity(values.len() * 4);
1219        for value in values {
1220            data.extend_from_slice(&[*value, *value, *value, 0xFF]);
1221        }
1222        data
1223    }
1224
1225    fn lzfse_compress(data: &[u8]) -> Vec<u8> {
1226        let mut encoder = lzfse_rust::LzfseRingEncoder::default();
1227        let mut out = Vec::new();
1228        encoder.encode_bytes(data, &mut out).expect("lzfse encode");
1229        out
1230    }
1231
1232    #[test]
1233    fn decode_data_lzfse_multi_chunk_preserves_order() {
1234        // 两个独立 LZFSE chunk,解压后应按原顺序拼接
1235        let chunk0 = lzfse_compress(&[1u8, 2, 3]);
1236        let chunk1 = lzfse_compress(&[4u8, 5, 6]);
1237        let item = build_item(
1238            3,
1239            2,
1240            Some(&build_theme_cbck_multi(
1241                COMP_LZFSE,
1242                &[chunk0.as_slice(), chunk1.as_slice()],
1243            )),
1244        );
1245        let decoded = item
1246            .decode_data()
1247            .expect("multi-chunk LZFSE should succeed");
1248        let Some(RenditionData::Image { data }) = decoded else {
1249            panic!("expected Image data");
1250        };
1251        assert_eq!(data, [1u8, 2, 3, 4, 5, 6]);
1252    }
1253
1254    #[test]
1255    fn decode_data_lzfse_multi_chunk_fails_on_bad_chunk() {
1256        // 第一个 chunk 合法,第二个 chunk 损坏,整体应报 DecodeFailed
1257        let good = lzfse_compress(&[1u8, 2, 3]);
1258        let bad = b"not-valid-lzfse-data".as_slice();
1259        let item = build_item(
1260            3,
1261            2,
1262            Some(&build_theme_cbck_multi(COMP_LZFSE, &[good.as_slice(), bad])),
1263        );
1264        let err = item.decode_data().expect_err("bad LZFSE chunk should fail");
1265        assert!(matches!(err, CarError::DecodeFailed(_)));
1266        assert!(format!("{err}").contains("LZFSE"));
1267    }
1268}