|
29 | 29 | import com.vaadin.flow.component.ai.provider.DatabaseProvider; |
30 | 30 | import com.vaadin.flow.component.ai.provider.LLMProvider; |
31 | 31 | import com.vaadin.flow.component.charts.Chart; |
| 32 | +import com.vaadin.flow.component.charts.model.AbstractSeries; |
32 | 33 | import com.vaadin.flow.component.charts.model.AxisType; |
33 | 34 | import com.vaadin.flow.component.charts.model.ChartType; |
34 | 35 | import com.vaadin.flow.component.charts.model.Configuration; |
35 | 36 | import com.vaadin.flow.component.charts.model.DataSeries; |
36 | 37 | import com.vaadin.flow.component.charts.model.DataSeriesItem; |
37 | 38 | import com.vaadin.flow.component.charts.model.OhlcItem; |
38 | 39 | import com.vaadin.flow.component.charts.model.PlotOptionsFlags; |
| 40 | +import com.vaadin.flow.component.charts.model.PlotOptionsLine; |
39 | 41 | import com.vaadin.flow.component.charts.util.ChartSerialization; |
40 | 42 | import com.vaadin.tests.MockUIExtension; |
41 | 43 |
|
@@ -566,13 +568,196 @@ void perSeriesPlotOptions_appliedToSingleUnnamedSeries() { |
566 | 568 | Assertions.assertEquals(1, series.size()); |
567 | 569 | // Series should be named from title AND have plotOptions applied |
568 | 570 | Assertions.assertEquals("Revenue", series.get(0).getName()); |
569 | | - var plotOptions = ((com.vaadin.flow.component.charts.model.AbstractSeries) series |
570 | | - .get(0)).getPlotOptions(); |
| 571 | + var plotOptions = ((AbstractSeries) series.get(0)).getPlotOptions(); |
571 | 572 | Assertions.assertNotNull(plotOptions, |
572 | 573 | "Per-series plotOptions should be applied to " |
573 | 574 | + "single unnamed series after title-based naming"); |
574 | 575 | } |
575 | 576 |
|
| 577 | + @Test |
| 578 | + void multiQueryUnnamedSeriesReceiveNamesFromConfig() { |
| 579 | + // When two separate queries produce unnamed series (e.g. |
| 580 | + // candlestick OHLC + volume) and the LLM config provides a |
| 581 | + // series array with names, the names should be applied |
| 582 | + // positionally to the unnamed data series. |
| 583 | + int[] callCount = { 0 }; |
| 584 | + controller.setDataConverter(data -> { |
| 585 | + callCount[0]++; |
| 586 | + if (callCount[0] == 1) { |
| 587 | + DataSeries ohlcSeries = new DataSeries(); |
| 588 | + ohlcSeries.add(new OhlcItem(1704067200000L, 142.5, 148.2, |
| 589 | + 141.0, 147.8)); |
| 590 | + return List.of(ohlcSeries); |
| 591 | + } else { |
| 592 | + DataSeries volumeSeries = new DataSeries(); |
| 593 | + volumeSeries.add(new DataSeriesItem(1704067200000L, 52000)); |
| 594 | + return List.of(volumeSeries); |
| 595 | + } |
| 596 | + }); |
| 597 | + |
| 598 | + updateConfiguration(""" |
| 599 | + {"chart":{"type":"candlestick"}, |
| 600 | + "yAxis":[{"title":{"text":"Price"}}, |
| 601 | + {"title":{"text":"Volume"},"opposite":true}], |
| 602 | + "series":[{"name":"Prices","type":"candlestick"}, |
| 603 | + {"name":"Vol","type":"column","yAxis":1}]} |
| 604 | + """); |
| 605 | + updateData("SELECT 1", "SELECT 2"); |
| 606 | + controller.onRequestCompleted(); |
| 607 | + |
| 608 | + var series = chart.getConfiguration().getSeries(); |
| 609 | + Assertions.assertEquals(2, series.size()); |
| 610 | + Assertions.assertEquals("Prices", series.get(0).getName(), |
| 611 | + "First unnamed series should receive name from config"); |
| 612 | + Assertions.assertEquals("Vol", series.get(1).getName(), |
| 613 | + "Second unnamed series should receive name from config"); |
| 614 | + } |
| 615 | + |
| 616 | + @Test |
| 617 | + void configSeriesNamesOverrideSeriesColumnNames() { |
| 618 | + // When _SERIES column produces named series but the config |
| 619 | + // also provides explicit series names, the config names |
| 620 | + // should win (the user asked for specific legend labels). |
| 621 | + databaseProvider.results = List.of( |
| 622 | + row(ColumnNames.SERIES, "North", "category", "Jan", "value", |
| 623 | + 45000), |
| 624 | + row(ColumnNames.SERIES, "South", "category", "Jan", "value", |
| 625 | + 38000)); |
| 626 | + |
| 627 | + updateConfiguration(""" |
| 628 | + {"chart":{"type":"column"}, |
| 629 | + "series":[{"name":"Revenue","yAxis":0}, |
| 630 | + {"name":"Costs","yAxis":1}]} |
| 631 | + """); |
| 632 | + updateData("SELECT s, c, v FROM t"); |
| 633 | + controller.onRequestCompleted(); |
| 634 | + |
| 635 | + var series = chart.getConfiguration().getSeries(); |
| 636 | + Assertions.assertEquals(2, series.size()); |
| 637 | + Assertions.assertEquals("Revenue", series.get(0).getName(), |
| 638 | + "Config name should override _SERIES name"); |
| 639 | + Assertions.assertEquals("Costs", series.get(1).getName(), |
| 640 | + "Config name should override _SERIES name"); |
| 641 | + } |
| 642 | + |
| 643 | + @Test |
| 644 | + void nameMatchedSeriesNotOverwrittenByPositionalFallback() { |
| 645 | + // When one data series matches a config template by name and |
| 646 | + // another doesn't, the name-matched series must not be |
| 647 | + // re-processed by positional fallback. The name-matched |
| 648 | + // series ("Revenue") is deliberately second in data order so |
| 649 | + // that positional fallback — if it ignored matched tracking — |
| 650 | + // would assign it the wrong template. |
| 651 | + databaseProvider.results = List.of( |
| 652 | + row(ColumnNames.SERIES, "Other", "category", "Jan", "value", |
| 653 | + 38000), |
| 654 | + row(ColumnNames.SERIES, "Revenue", "category", "Jan", |
| 655 | + "value", 45000)); |
| 656 | + |
| 657 | + updateConfiguration("{\"chart\":{\"type\":\"column\"}," |
| 658 | + + "\"series\":[" + "{\"name\":\"Revenue\",\"yAxis\":0}," |
| 659 | + + "{\"name\":\"Costs\",\"yAxis\":1}" + "]}"); |
| 660 | + updateData("SELECT s, c, v FROM t"); |
| 661 | + controller.onRequestCompleted(); |
| 662 | + |
| 663 | + var series = chart.getConfiguration().getSeries(); |
| 664 | + Assertions.assertEquals(2, series.size()); |
| 665 | + // "Other" did not match — positional fallback renames to "Costs" |
| 666 | + Assertions.assertEquals("Costs", series.get(0).getName()); |
| 667 | + Assertions.assertEquals(1, |
| 668 | + ((AbstractSeries) series.get(0)).getyAxis(), |
| 669 | + "Positionally matched series should get template yAxis"); |
| 670 | + // "Revenue" matched by name — must keep its name and yAxis |
| 671 | + Assertions.assertEquals("Revenue", series.get(1).getName()); |
| 672 | + Assertions.assertEquals(0, |
| 673 | + ((AbstractSeries) series.get(1)).getyAxis(), |
| 674 | + "Name-matched series should keep its yAxis"); |
| 675 | + } |
| 676 | + |
| 677 | + @Test |
| 678 | + void positionalMatchAppliesPlotOptionsAndYAxis() { |
| 679 | + // Positional fallback must apply plotOptions and yAxis from |
| 680 | + // the config template, not just the name. |
| 681 | + int[] callCount = { 0 }; |
| 682 | + controller.setDataConverter(data -> { |
| 683 | + callCount[0]++; |
| 684 | + if (callCount[0] == 1) { |
| 685 | + DataSeries s = new DataSeries(); |
| 686 | + s.add(new DataSeriesItem("Jan", 100)); |
| 687 | + return List.of(s); |
| 688 | + } else { |
| 689 | + DataSeries s = new DataSeries(); |
| 690 | + s.add(new DataSeriesItem("Jan", 200)); |
| 691 | + return List.of(s); |
| 692 | + } |
| 693 | + }); |
| 694 | + |
| 695 | + updateConfiguration(""" |
| 696 | + {"chart":{"type":"column"}, |
| 697 | + "series":[{"name":"Revenue","type":"column","yAxis":0}, |
| 698 | + {"name":"Count","type":"line","yAxis":1}]} |
| 699 | + """); |
| 700 | + updateData("SELECT 1", "SELECT 2"); |
| 701 | + controller.onRequestCompleted(); |
| 702 | + |
| 703 | + var series = chart.getConfiguration().getSeries(); |
| 704 | + Assertions.assertEquals(2, series.size()); |
| 705 | + var countSeries = (AbstractSeries) series.get(1); |
| 706 | + Assertions.assertEquals("Count", countSeries.getName()); |
| 707 | + Assertions.assertEquals(1, countSeries.getyAxis(), |
| 708 | + "Positional match should apply yAxis from template"); |
| 709 | + Assertions.assertInstanceOf(PlotOptionsLine.class, |
| 710 | + countSeries.getPlotOptions(), |
| 711 | + "Positional match should apply plotOptions from template"); |
| 712 | + } |
| 713 | + |
| 714 | + @Test |
| 715 | + void dataOrderDifferentFromConfigOrderPreservesDataLabels() { |
| 716 | + // The LLM writes the config series[] array without any |
| 717 | + // guarantee that the SQL data will come back in that order |
| 718 | + // (GROUP BY without ORDER BY, DB-side hash ordering, etc.). |
| 719 | + // When data order differs from config order, matching by |
| 720 | + // position renames data series to the WRONG labels — |
| 721 | + // effectively swapping data points under their legend |
| 722 | + // labels. Matching by name (the prior behavior) avoids this. |
| 723 | + controller.setDataConverter(data -> { |
| 724 | + // Simulate DB returning "South" before "North" |
| 725 | + DataSeries south = new DataSeries("South"); |
| 726 | + south.add(new DataSeriesItem("Jan", 38000)); |
| 727 | + DataSeries north = new DataSeries("North"); |
| 728 | + north.add(new DataSeriesItem("Jan", 45000)); |
| 729 | + return List.of(south, north); |
| 730 | + }); |
| 731 | + |
| 732 | + updateConfiguration(""" |
| 733 | + {"chart":{"type":"column"}, |
| 734 | + "yAxis":[{"title":{"text":"Primary"}}, |
| 735 | + {"title":{"text":"Secondary"},"opposite":true}], |
| 736 | + "series":[{"name":"North","yAxis":0}, |
| 737 | + {"name":"South","yAxis":1}]} |
| 738 | + """); |
| 739 | + updateData("SELECT s, c, v FROM t"); |
| 740 | + controller.onRequestCompleted(); |
| 741 | + |
| 742 | + var series = chart.getConfiguration().getSeries(); |
| 743 | + Assertions.assertEquals(2, series.size()); |
| 744 | + // The first data series is South (value 38000); its label |
| 745 | + // must remain "South", not be overwritten to "North". |
| 746 | + Assertions.assertEquals("South", series.get(0).getName(), |
| 747 | + "Data series labels must not be swapped when data " |
| 748 | + + "order differs from config order"); |
| 749 | + Assertions.assertEquals("North", series.get(1).getName()); |
| 750 | + // Similarly the yAxis assignment should follow the name, |
| 751 | + // so that "South" sits on the Secondary axis (yAxis=1). |
| 752 | + Assertions.assertEquals(1, |
| 753 | + ((AbstractSeries) series.get(0)).getyAxis(), |
| 754 | + "South should end up on Secondary y-axis (yAxis=1), " |
| 755 | + + "not Primary"); |
| 756 | + Assertions.assertEquals(0, |
| 757 | + ((AbstractSeries) series.get(1)).getyAxis(), |
| 758 | + "North should end up on Primary y-axis (yAxis=0)"); |
| 759 | + } |
| 760 | + |
576 | 761 | @Test |
577 | 762 | void multipleSeriesWithSeriesColumnKeepsOriginalNames() { |
578 | 763 | databaseProvider.results = List.of( |
|
0 commit comments