Skip to main content

pptx_rs/shape/
chartshape.rs

1//! `ChartShape`:高阶图表。
2//!
3//! 图表在 OOXML 中通过 `<p:graphicFrame>` + `<a:graphicData uri="...chart">`
4//! + `<c:chart r:id="..."/>` 引用一个独立的 `/ppt/charts/chartN.xml` part。
5//!   本高阶 API 把 graphicFrame 包装为 [`ChartShape`],提供类型 / 数据 / rid
6//!   的便捷访问,并让 [`Shape`] trait 直接可用。
7//!
8//! # 与 python-pptx 的对应
9//!
10//! - `pptx.chart.Chart` ←→ [`crate::oxml::chart::Chart`](数据/类型/标题);
11//! - `pptx.shapes.graphfrm.GraphicFrame` ←→ 本 [`ChartShape`](承载位置/尺寸 + 引用)。
12//!
13//! # 写出语义
14//!
15//! - `ChartShape` 序列化时只写出 `<p:graphicFrame>` + 引用元素 `<c:chart r:id="..."/>`;
16//! - 真正的 chart 数据由 [`crate::presentation::Presentation::save`] 在
17//!   `to_opc_package` 中遍历每张 slide 的 `chart_entries` 写出独立的 `chartN.xml` part;
18//! - slide 的 `_rels/slideN.xml.rels` 中会添加 `chart` 关系指向该 part。
19//!
20//! # 限制
21//!
22//! - 当前仅支持柱/条/线/饼 4 种类型(见 [`crate::oxml::chart::ChartType`]);
23//! - 数据通过 `<c:numCache>` / `<c:strCache>` 内嵌,不依赖嵌入 Excel;
24//! - 读取已有图表的 graphicFrame 时,**仅保留 r:id 引用**,不解析 chartN.xml 内容。
25
26use crate::oxml::chart::{
27    Chart as OxmlChart, ChartData, ChartSeries, ChartType, DataLabels, SeriesFormat,
28};
29use crate::oxml::shape::{Graphic as OxmlGraphic, GraphicFrame as OxmlFrame};
30use crate::shape::base::Shape;
31use crate::units::Emu;
32
33/// 高阶图表形状(承载 `<p:graphicFrame>` + `<c:chart r:id="..."/>` 引用)。
34///
35/// 通过 [`ChartShape::chart`] / [`ChartShape::chart_mut`] 访问图表数据;
36/// 通过 [`Shape`] trait 方法(`left` / `top` / `width` / `height`)调整位置与尺寸。
37///
38/// # 内部不变量
39///
40/// `frame.graphic` 始终保持为 `Graphic::Chart(_)`。本类型所有便捷方法
41/// (`chart_type` / `set_chart_type` / `data` / `data_mut` / `rid` / `set_rid`)
42/// 在不变量被破坏时**静默忽略**或返回 `None`,绝不 panic——
43/// 这与库整体"零 panic"约定一致(参见 `.trae/rules/project_rules.md` §5)。
44#[derive(Clone, Debug, Default)]
45pub struct ChartShape {
46    /// 内部 oxml 句柄(`GraphicFrame`,承载 `Graphic::Chart`)。
47    pub(crate) frame: OxmlFrame,
48}
49
50impl ChartShape {
51    /// 构造一个指定类型与数据的图表形状(rid 留空,由 presentation 层填充)。
52    ///
53    /// # 参数
54    /// - `chart_type`:图表类型(柱/条/线/饼)。
55    /// - `data`:图表数据(类别 + 系列 + 可选标题)。
56    pub fn new(chart_type: ChartType, data: ChartData) -> Self {
57        let chart = OxmlChart::new(chart_type, data);
58        let frame = OxmlFrame {
59            graphic: OxmlGraphic::Chart(chart),
60            ..Default::default()
61        };
62        ChartShape { frame }
63    }
64
65    /// 从 oxml Frame 构造(通常用于读取已有图表时)。
66    pub fn from_frame(frame: OxmlFrame) -> Self {
67        ChartShape { frame }
68    }
69
70    /// 取内部 oxml Chart 引用。
71    ///
72    /// 返回 `None` 仅在内部不变量被外部错误破坏时(`frame.graphic` 不是 `Chart` 变体)。
73    pub fn chart(&self) -> Option<&OxmlChart> {
74        match &self.frame.graphic {
75            OxmlGraphic::Chart(c) => Some(c),
76            _ => None,
77        }
78    }
79
80    /// 取内部 oxml Chart 可变引用。
81    pub fn chart_mut(&mut self) -> Option<&mut OxmlChart> {
82        match &mut self.frame.graphic {
83            OxmlGraphic::Chart(c) => Some(c),
84            _ => None,
85        }
86    }
87
88    /// 图表类型(便捷访问)。
89    ///
90    /// 若内部不变量被破坏(`frame.graphic` 不是 `Chart`),返回 [`ChartType::Column`] 作为兜底。
91    pub fn chart_type(&self) -> ChartType {
92        self.chart()
93            .map(|c| c.chart_type)
94            .unwrap_or(ChartType::Column)
95    }
96
97    /// 修改图表类型(保留数据不变)。不变量被破坏时静默忽略。
98    pub fn set_chart_type(&mut self, chart_type: ChartType) {
99        if let Some(c) = self.chart_mut() {
100            c.chart_type = chart_type;
101        }
102    }
103
104    /// 图表数据引用。
105    pub fn data(&self) -> Option<&ChartData> {
106        self.chart().map(|c| &c.data)
107    }
108
109    /// 图表数据可变引用。
110    pub fn data_mut(&mut self) -> Option<&mut ChartData> {
111        self.chart_mut().map(|c| &mut c.data)
112    }
113
114    /// 当前关联的关系 id(指向 `/ppt/charts/chartN.xml`)。
115    ///
116    /// 新建时为空字符串;由 `Presentation::to_opc_package` 在写出 chart part 时填充。
117    /// 不变量被破坏时返回空字符串。
118    pub fn rid(&self) -> &str {
119        match &self.frame.graphic {
120            OxmlGraphic::Chart(c) => &c.rid,
121            _ => "",
122        }
123    }
124
125    /// 设置关系 id(一般由 presentation 层自动调用,用户无需直接设置)。
126    /// 不变量被破坏时静默忽略。
127    pub fn set_rid(&mut self, rid: impl Into<String>) {
128        if let Some(c) = self.chart_mut() {
129            c.rid = rid.into();
130        }
131    }
132
133    /// 将本图表形状标记为占位符(TODO-007 图表占位符类型化填充)。
134    ///
135    /// 写出 XML 时会在 `<p:nvGraphicFramePr>/<p:nvPr>` 内插入
136    /// `<p:ph type="chart" idx="..."/>`,使 PowerPoint 把该 graphicFrame
137    /// 识别为图表占位符的填充实例。
138    ///
139    /// # 参数
140    /// - `ph_idx`:占位符索引(对应 `<p:ph idx="..."/>`)。
141    /// - `ph_type`:占位符类型字符串(如 `"chart"` / `"obj"`),`None` 时省略 `type` 属性。
142    pub fn set_placeholder(&mut self, ph_idx: u32, ph_type: Option<&str>) {
143        self.frame.is_placeholder = true;
144        self.frame.ph_idx = Some(ph_idx);
145        self.frame.ph_type = ph_type.map(|s| s.to_string());
146    }
147
148    /// 清除占位符标记,使本图表形状变为普通 graphicFrame。
149    pub fn clear_placeholder(&mut self) {
150        self.frame.is_placeholder = false;
151        self.frame.ph_idx = None;
152        self.frame.ph_type = None;
153    }
154
155    /// 是否被标记为占位符。
156    pub fn is_placeholder(&self) -> bool {
157        self.frame.is_placeholder
158    }
159
160    /// 占位符索引(若已标记)。
161    pub fn ph_idx(&self) -> Option<u32> {
162        self.frame.ph_idx
163    }
164
165    /// 占位符类型字符串(若已标记)。
166    pub fn ph_type(&self) -> Option<&str> {
167        self.frame.ph_type.as_deref()
168    }
169
170    // ===================== 数据标签便捷 API(TODO-004 数据标签) =====================
171
172    /// 图表级数据标签引用(`<c:dLbls>`,写在图表元素内、`<c:ser>` 之前)。
173    ///
174    /// 系列级 [`ChartShape::series_data_labels`] 会覆盖此处的图表级配置。
175    /// 返回 `None` 仅在内部不变量被破坏时(`frame.graphic` 不是 `Chart` 变体)。
176    pub fn data_labels(&self) -> Option<&DataLabels> {
177        self.chart().and_then(|c| c.data.data_labels.as_ref())
178    }
179
180    /// 设置图表级数据标签。
181    ///
182    /// 传入 `None` 清除现有配置。不变量被破坏时静默忽略。
183    ///
184    /// # 参数
185    /// - `labels`:数据标签配置;`None` 清除,`Some(dl)` 写出 `<c:dLbls>`(若非空)。
186    pub fn set_data_labels(&mut self, labels: Option<DataLabels>) {
187        if let Some(c) = self.chart_mut() {
188            c.data.data_labels = labels;
189        }
190    }
191
192    /// 系列级数据标签引用(按系列索引访问)。
193    ///
194    /// 系列级配置会覆盖图表级 [`ChartShape::data_labels`]。返回 `None` 的情况:
195    /// - 内部不变量被破坏;
196    /// - 索引越界;
197    /// - 该系列未设置系列级数据标签(继承图表级)。
198    pub fn series_data_labels(&self, series_idx: usize) -> Option<&DataLabels> {
199        self.chart()
200            .and_then(|c| c.data.series.get(series_idx))
201            .and_then(|s| s.data_labels.as_ref())
202    }
203
204    /// 设置指定系列的数据标签(覆盖图表级配置)。
205    ///
206    /// # 参数
207    /// - `series_idx`:系列索引(越界时静默忽略);
208    /// - `labels`:数据标签配置;`None` 清除该系列的系列级配置(继承图表级)。
209    pub fn set_series_data_labels(&mut self, series_idx: usize, labels: Option<DataLabels>) {
210        if let Some(c) = self.chart_mut() {
211            if let Some(s) = c.data.series.get_mut(series_idx) {
212                s.data_labels = labels;
213            }
214        }
215    }
216
217    // ===================== 系列样式便捷 API(TODO-004 系列样式) =====================
218
219    /// 查询指定系列的样式(`<c:spPr>`)。
220    ///
221    /// 返回 `None` 的情况:
222    /// - 内部不变量被破坏(`frame.graphic` 不是 `Chart` 变体);
223    /// - 索引越界;
224    /// - 该系列未设置样式(PowerPoint 自动分配主题色)。
225    pub fn series_format(&self, series_idx: usize) -> Option<&SeriesFormat> {
226        self.chart()
227            .and_then(|c| c.data.series.get(series_idx))
228            .and_then(|s| s.format.as_ref())
229    }
230
231    /// 设置指定系列的样式(颜色/填充/边框)。
232    ///
233    /// # 参数
234    /// - `series_idx`:系列索引(越界时静默忽略);
235    /// - `format`:系列样式;`None` 清除该系列的样式(PowerPoint 自动分配主题色)。
236    ///
237    /// # 示例
238    ///
239    /// ```no_run
240    /// use pptx_rs::oxml::chart::{ChartData, ChartSeries, ChartType, SeriesFormat};
241    /// use pptx_rs::oxml::color::Color;
242    /// use pptx_rs::units::RGBColor;
243    /// use pptx_rs::{Presentation, Inches};
244    ///
245    /// let mut prs = Presentation::new().expect("Presentation::new failed");
246    /// let counter = prs.id_counter();
247    /// let slide = prs.slides_mut().add_slide(counter).expect("add_slide failed");
248    /// let data = ChartData {
249    ///     series: vec![ChartSeries::new("S1", vec![1.0, 2.0])],
250    ///     ..Default::default()
251    /// };
252    /// let mut chart = slide.shapes_mut()
253    ///     .add_chart(ChartType::Column, data, Inches(1.0), Inches(1.0), Inches(8.0), Inches(4.0))
254    ///     .expect("add_chart failed");
255    /// // 设置第 0 个系列为红色
256    /// chart.set_series_format(0, Some(SeriesFormat::solid_fill(Color::RGB(RGBColor(0xFF, 0x00, 0x00)))));
257    /// ```
258    pub fn set_series_format(&mut self, series_idx: usize, format: Option<SeriesFormat>) {
259        if let Some(c) = self.chart_mut() {
260            if let Some(s) = c.data.series.get_mut(series_idx) {
261                s.format = format;
262            }
263        }
264    }
265
266    // ===================== 次坐标轴便捷 API(TODO-004 次坐标轴) =====================
267
268    /// 查询指定系列是否绑定到次坐标轴(右侧 Y 轴)。
269    ///
270    /// 返回 `false` 的情况:
271    /// - 内部不变量被破坏;
272    /// - 索引越界;
273    /// - 该系列绑定主轴(`secondary_axis=false`)。
274    ///
275    /// **注意**:饼图/散点图/气泡图不支持次坐标轴,写出时该字段被忽略。
276    pub fn is_series_secondary(&self, series_idx: usize) -> bool {
277        self.chart()
278            .and_then(|c| c.data.series.get(series_idx))
279            .map(|s| s.secondary_axis)
280            .unwrap_or(false)
281    }
282
283    /// 设置指定系列是否绑定到次坐标轴(右侧 Y 轴)。
284    ///
285    /// 典型场景:主轴显示柱形图,次轴显示折线图(双轴组合图)。
286    ///
287    /// # 参数
288    /// - `series_idx`:系列索引(越界时静默忽略);
289    /// - `secondary`:`true` 绑定次轴,`false` 绑定主轴。
290    ///
291    /// # 限制
292    ///
293    /// 饼图/散点图/气泡图不支持次坐标轴(OOXML 规范约束),
294    /// `to_xml` 写出时会忽略该字段。
295    pub fn set_series_secondary(&mut self, series_idx: usize, secondary: bool) {
296        if let Some(c) = self.chart_mut() {
297            if let Some(s) = c.data.series.get_mut(series_idx) {
298                s.secondary_axis = secondary;
299            }
300        }
301    }
302
303    /// 查询次图表类型(组合图模式,模块 D)。
304    ///
305    /// 返回 `None` 的情况:
306    /// - 内部不变量被破坏;
307    /// - 未设置次图表类型(非组合图模式)。
308    ///
309    /// 设置次图表类型后,`secondary_axis=true` 的系列会写入第二个图表元素
310    /// (如 `c:lineChart`),与主图表元素共用类别轴但绑定次 valAx。
311    pub fn secondary_chart_type(&self) -> Option<ChartType> {
312        self.chart().and_then(|c| c.data.secondary_chart_type)
313    }
314
315    /// 设置次图表类型,启用组合图模式(模块 D)。
316    ///
317    /// 典型场景:主轴柱形图 + 次轴折线图(双轴组合图)。
318    /// 设置后,`to_xml` 会把 `secondary_axis=true` 的系列写入第二个图表元素。
319    ///
320    /// # 参数
321    /// - `chart_type`:次图表类型;`None` 清除组合图模式(所有系列写入主图表元素)。
322    ///
323    /// # 示例
324    ///
325    /// ```no_run
326    /// use pptx_rs::oxml::chart::{ChartData, ChartSeries, ChartType};
327    /// use pptx_rs::{Presentation, Inches};
328    ///
329    /// let mut prs = Presentation::new().expect("Presentation::new failed");
330    /// let counter = prs.id_counter();
331    /// let slide = prs.slides_mut().add_slide(counter).expect("add_slide failed");
332    /// let mut s1 = ChartSeries::new("Bar", vec![10.0, 20.0]);
333    /// let mut s2 = ChartSeries::new("Line", vec![5.0, 8.0]);
334    /// s2.secondary_axis = true;  // 折线系列绑定次轴
335    /// let data = ChartData {
336    ///     series: vec![s1, s2],
337    ///     ..Default::default()
338    /// };
339    /// let mut chart = slide.shapes_mut()
340    ///     .add_chart(ChartType::Column, data, Inches(1.0), Inches(1.0), Inches(8.0), Inches(4.0))
341    ///     .expect("add_chart failed");
342    /// // 启用组合图模式:次轴折线图
343    /// chart.set_secondary_chart_type(Some(ChartType::Line));
344    /// ```
345    pub fn set_secondary_chart_type(&mut self, chart_type: Option<ChartType>) {
346        if let Some(c) = self.chart_mut() {
347            c.data.secondary_chart_type = chart_type;
348        }
349    }
350
351    /// 添加一个新系列到图表数据末尾,返回新系列的索引。
352    ///
353    /// 便捷方法:避免外部构造 `ChartData` 字面量。不变量被破坏时返回 `None`。
354    pub fn push_series(&mut self, series: ChartSeries) -> Option<usize> {
355        if let Some(c) = self.chart_mut() {
356            c.data.series.push(series);
357            Some(c.data.series.len() - 1)
358        } else {
359            None
360        }
361    }
362}
363
364impl Shape for ChartShape {
365    fn id(&self) -> u32 {
366        self.frame.id
367    }
368    fn set_id(&mut self, id: u32) {
369        self.frame.id = id;
370    }
371    fn name(&self) -> &str {
372        &self.frame.name
373    }
374    fn set_name(&mut self, name: String) {
375        self.frame.name = name;
376    }
377    fn shape_type(&self) -> &'static str {
378        "chart"
379    }
380
381    fn left(&self) -> Emu {
382        self.frame.properties.xfrm.off_x.unwrap_or_default()
383    }
384    fn set_left(&mut self, emu: Emu) {
385        self.frame.properties.xfrm.off_x = Some(emu);
386    }
387    fn top(&self) -> Emu {
388        self.frame.properties.xfrm.off_y.unwrap_or_default()
389    }
390    fn set_top(&mut self, emu: Emu) {
391        self.frame.properties.xfrm.off_y = Some(emu);
392    }
393    fn width(&self) -> Emu {
394        self.frame.properties.xfrm.ext_cx.unwrap_or_default()
395    }
396    fn set_width(&mut self, emu: Emu) {
397        self.frame.properties.xfrm.ext_cx = Some(emu);
398    }
399    fn height(&self) -> Emu {
400        self.frame.properties.xfrm.ext_cy.unwrap_or_default()
401    }
402    fn set_height(&mut self, emu: Emu) {
403        self.frame.properties.xfrm.ext_cy = Some(emu);
404    }
405
406    /// 图表不支持旋转(OOXML 规范)。调用 [`Shape::set_rotation`] 会被忽略。
407    fn rotation(&self) -> f64 {
408        0.0
409    }
410    fn set_rotation(&mut self, _deg: f64) {}
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::oxml::chart::{ChartCategory, ChartSeries};
417
418    /// `new` 正确构造 ChartShape:类型/数据/rid 默认值均正确。
419    #[test]
420    fn new_chart_shape_basics() {
421        let data = ChartData {
422            categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
423            series: vec![ChartSeries::new("S", vec![1.0, 2.0])],
424            title: Some("T".to_string()),
425            data_labels: None,
426            ..Default::default()
427        };
428        let mut s = ChartShape::new(ChartType::Column, data);
429        assert_eq!(s.chart_type(), ChartType::Column);
430        assert_eq!(s.chart().unwrap().data.categories.len(), 2);
431        assert_eq!(s.chart().unwrap().data.series.len(), 1);
432        assert_eq!(s.rid(), "");
433        // 切换类型不丢数据
434        s.set_chart_type(ChartType::Line);
435        assert_eq!(s.chart_type(), ChartType::Line);
436        assert_eq!(s.chart().unwrap().data.series[0].values, vec![1.0, 2.0]);
437    }
438
439    /// `set_rid` 同步到内部 oxml Chart。
440    #[test]
441    fn set_rid_propagates() {
442        let mut s = ChartShape::new(ChartType::Pie, ChartData::default());
443        s.set_rid("rIdChart1");
444        assert_eq!(s.rid(), "rIdChart1");
445    }
446
447    /// `Shape` trait 的几何 setter 正确写入 frame.properties.xfrm。
448    #[test]
449    fn shape_trait_geometry() {
450        let mut s = ChartShape::new(ChartType::Bar, ChartData::default());
451        s.set_left(Emu(100));
452        s.set_top(Emu(200));
453        s.set_width(Emu(300));
454        s.set_height(Emu(400));
455        assert_eq!(s.left(), Emu(100));
456        assert_eq!(s.top(), Emu(200));
457        assert_eq!(s.width(), Emu(300));
458        assert_eq!(s.height(), Emu(400));
459        // 旋转被忽略
460        s.set_rotation(45.0);
461        assert_eq!(s.rotation(), 0.0);
462    }
463
464    /// `to_xml` 通过 frame 序列化包含 `<c:chart r:id="..."/>` 引用。
465    #[test]
466    fn frame_xml_contains_chart_reference() {
467        let mut s = ChartShape::new(ChartType::Column, ChartData::default());
468        s.set_rid("rIdChart7");
469        let mut w = crate::oxml::writer::XmlWriter::new();
470        s.frame.write_xml(&mut w);
471        let xml = w.into_string();
472        assert!(xml.contains("<p:graphicFrame>"), "xml: {}", xml);
473        assert!(xml.contains("<c:chart"), "xml: {}", xml);
474        assert!(xml.contains("r:id=\"rIdChart7\""), "xml: {}", xml);
475    }
476
477    // ===================== 数据标签便捷 API 测试 =====================
478
479    /// `set_data_labels` / `data_labels` 往返:设置后能正确读取。
480    #[test]
481    fn data_labels_setter_and_getter() {
482        let mut s = ChartShape::new(ChartType::Column, ChartData::default());
483        assert!(s.data_labels().is_none());
484        // 设置图表级数据标签
485        s.set_data_labels(Some(DataLabels::show_values()));
486        assert_eq!(s.data_labels().unwrap().show_val, Some(true));
487        // 清除
488        s.set_data_labels(None);
489        assert!(s.data_labels().is_none());
490    }
491
492    /// `set_series_data_labels` / `series_data_labels` 按索引访问。
493    #[test]
494    fn series_data_labels_by_index() {
495        let data = ChartData {
496            categories: vec![ChartCategory::new("A")],
497            series: vec![
498                ChartSeries::new("S1", vec![1.0]),
499                ChartSeries::new("S2", vec![2.0]),
500            ],
501            title: None,
502            data_labels: None,
503            ..Default::default()
504        };
505        let mut s = ChartShape::new(ChartType::Column, data);
506        // 初始无系列级 dLbls
507        assert!(s.series_data_labels(0).is_none());
508        // 为第 1 个系列设置
509        s.set_series_data_labels(0, Some(DataLabels::show_values()));
510        assert_eq!(s.series_data_labels(0).unwrap().show_val, Some(true));
511        // 第 2 个系列仍为 None
512        assert!(s.series_data_labels(1).is_none());
513        // 越界访问返回 None
514        assert!(s.series_data_labels(99).is_none());
515        // 越界设置静默忽略(不 panic)
516        s.set_series_data_labels(99, Some(DataLabels::show_values()));
517    }
518
519    // ===================== 系列样式便捷 API 测试 =====================
520
521    /// `set_series_format` / `series_format` 按索引访问 + 清除 + 越界静默忽略。
522    #[test]
523    fn series_format_setter_and_getter() {
524        use crate::oxml::color::Color;
525        use crate::units::RGBColor;
526        let data = ChartData {
527            categories: vec![ChartCategory::new("A")],
528            series: vec![ChartSeries::new("S1", vec![1.0])],
529            ..Default::default()
530        };
531        let mut s = ChartShape::new(ChartType::Column, data);
532        // 初始无样式
533        assert!(s.series_format(0).is_none());
534        // 设置实色填充(红色)
535        s.set_series_format(
536            0,
537            Some(SeriesFormat::solid_fill(Color::RGB(RGBColor(
538                0xFF, 0x00, 0x00,
539            )))),
540        );
541        assert!(s.series_format(0).is_some());
542        // 清除
543        s.set_series_format(0, None);
544        assert!(s.series_format(0).is_none());
545        // 越界静默忽略(不 panic)
546        s.set_series_format(99, Some(SeriesFormat::default()));
547        assert!(s.series_format(99).is_none());
548    }
549
550    // ===================== 次坐标轴便捷 API 测试 =====================
551
552    /// `set_series_secondary` / `is_series_secondary` 按索引访问。
553    #[test]
554    fn series_secondary_axis_setter_and_getter() {
555        let data = ChartData {
556            categories: vec![ChartCategory::new("A")],
557            series: vec![
558                ChartSeries::new("S1", vec![1.0]),
559                ChartSeries::new("S2", vec![2.0]),
560            ],
561            title: None,
562            data_labels: None,
563            ..Default::default()
564        };
565        let mut s = ChartShape::new(ChartType::Column, data);
566        // 初始所有系列绑定主轴
567        assert!(!s.is_series_secondary(0));
568        assert!(!s.is_series_secondary(1));
569        // 把第 2 个系列绑定到次轴
570        s.set_series_secondary(1, true);
571        assert!(!s.is_series_secondary(0));
572        assert!(s.is_series_secondary(1));
573        // 越界访问返回 false(不 panic)
574        assert!(!s.is_series_secondary(99));
575        // 越界设置静默忽略
576        s.set_series_secondary(99, true);
577    }
578
579    /// `push_series` 添加新系列到末尾,返回索引。
580    #[test]
581    fn push_series_appends_to_end() {
582        let data = ChartData {
583            categories: vec![ChartCategory::new("A")],
584            series: vec![ChartSeries::new("S1", vec![1.0])],
585            title: None,
586            data_labels: None,
587            ..Default::default()
588        };
589        let mut s = ChartShape::new(ChartType::Column, data);
590        assert_eq!(s.data().unwrap().series.len(), 1);
591        let idx = s.push_series(ChartSeries::new("S2", vec![2.0]));
592        assert_eq!(idx, Some(1));
593        assert_eq!(s.data().unwrap().series.len(), 2);
594        assert_eq!(s.data().unwrap().series[1].name, "S2");
595    }
596}