@@ -457,4 +457,148 @@ mod tests {
457457 let result = reader. execute ( query) ;
458458 assert ! ( result. is_err( ) ) ;
459459 }
460+
461+ #[ test]
462+ fn test_binned_fill_legend_renders_threshold_scale ( ) {
463+ // End-to-end test for binned fill scale rendering to Vega-Lite
464+ // Verifies that binned non-positional aesthetics use threshold scale type
465+ let reader = DuckDBReader :: from_connection_string ( "duckdb://memory" ) . unwrap ( ) ;
466+
467+ // Create data with values that span the binned range
468+ // Binned scales use FROM [min, max] for range and SETTING breaks => [...] for explicit breaks
469+ let query = r#"
470+ SELECT * FROM (VALUES
471+ (1, 10, 15.0),
472+ (2, 20, 35.0),
473+ (3, 30, 55.0),
474+ (4, 40, 85.0)
475+ ) AS t(x, y, value)
476+ VISUALISE
477+ DRAW tile MAPPING x AS x, y AS y, value AS fill
478+ SCALE BINNED fill FROM [0, 100] TO viridis SETTING breaks => [0, 25, 50, 75, 100]
479+ "# ;
480+
481+ let spec = reader. execute ( query) . unwrap ( ) ;
482+
483+ // Verify spec structure
484+ assert_eq ! ( spec. plot( ) . layers. len( ) , 1 ) ;
485+ // Note: scales may include auto-generated x/y scales plus the explicit fill scale
486+ assert ! (
487+ spec. plot( ) . find_scale( "fill" ) . is_some( ) ,
488+ "Should have a fill scale"
489+ ) ;
490+
491+ // Render to Vega-Lite
492+ let writer = VegaLiteWriter :: new ( ) ;
493+ let result = writer. render ( & spec) . unwrap ( ) ;
494+ let vl: serde_json:: Value = serde_json:: from_str ( & result) . unwrap ( ) ;
495+
496+ // Verify threshold scale type for fill
497+ let fill_scale = & vl[ "layer" ] [ 0 ] [ "encoding" ] [ "fill" ] [ "scale" ] ;
498+ assert_eq ! (
499+ fill_scale[ "type" ] ,
500+ "threshold" ,
501+ "Binned fill should use threshold scale type. Got: {}" ,
502+ serde_json:: to_string_pretty( & vl[ "layer" ] [ 0 ] [ "encoding" ] [ "fill" ] ) . unwrap( )
503+ ) ;
504+
505+ // Verify internal breaks as domain (excludes first and last terminals)
506+ // breaks = [0, 25, 50, 75, 100] → domain = [25, 50, 75]
507+ let domain = fill_scale[ "domain" ] . as_array ( ) . unwrap ( ) ;
508+ assert_eq ! (
509+ domain. len( ) ,
510+ 3 ,
511+ "Threshold domain should have internal breaks only. Got: {:?}" ,
512+ domain
513+ ) ;
514+ assert_eq ! ( domain[ 0 ] , 25.0 ) ;
515+ assert_eq ! ( domain[ 1 ] , 50.0 ) ;
516+ assert_eq ! ( domain[ 2 ] , 75.0 ) ;
517+
518+ // Verify color output - viridis palette gets expanded to an explicit range array
519+ // for threshold scales (Vega-Lite needs explicit colors for threshold domain)
520+ assert ! (
521+ fill_scale[ "range" ] . is_array( ) || fill_scale[ "scheme" ] == "viridis" ,
522+ "Should have color range or scheme. Got scale: {}" ,
523+ serde_json:: to_string_pretty( fill_scale) . unwrap( )
524+ ) ;
525+
526+ // Verify legend values
527+ // For `fill` alone (single binned legend scale), uses gradient legend with all 5 break values
528+ // For symbol legends (multiple binned scales or non-gradient aesthetics), would have N-1 values
529+ let legend_values = & vl[ "layer" ] [ 0 ] [ "encoding" ] [ "fill" ] [ "legend" ] [ "values" ] ;
530+ assert ! (
531+ legend_values. is_array( ) ,
532+ "Legend should have values array. Got: {}" ,
533+ serde_json:: to_string_pretty( & vl[ "layer" ] [ 0 ] [ "encoding" ] [ "fill" ] [ "legend" ] ) . unwrap( )
534+ ) ;
535+ let values = legend_values. as_array ( ) . unwrap ( ) ;
536+ assert_eq ! (
537+ values. len( ) ,
538+ 5 ,
539+ "Gradient legend should have all 5 break values. Got: {:?}" ,
540+ values
541+ ) ;
542+ }
543+
544+ #[ test]
545+ fn test_binned_color_legend_with_label_mapping ( ) {
546+ // Test binned color scale with custom labels renders correctly
547+ let reader = DuckDBReader :: from_connection_string ( "duckdb://memory" ) . unwrap ( ) ;
548+
549+ let query = r#"
550+ SELECT * FROM (VALUES
551+ (1, 10, 20.0),
552+ (2, 20, 60.0),
553+ (3, 30, 90.0)
554+ ) AS t(x, y, score)
555+ VISUALISE
556+ DRAW point MAPPING x AS x, y AS y, score AS color
557+ SCALE BINNED color FROM [0, 100] TO ['blue', 'yellow', 'red'] SETTING breaks => [0, 50, 100]
558+ RENAMING 0 => 'Low', 50 => 'High'
559+ "# ;
560+
561+ let spec = reader. execute ( query) . unwrap ( ) ;
562+
563+ let writer = VegaLiteWriter :: new ( ) ;
564+ let result = writer. render ( & spec) . unwrap ( ) ;
565+ let vl: serde_json:: Value = serde_json:: from_str ( & result) . unwrap ( ) ;
566+
567+ // Verify threshold scale
568+ // Note: "color" aesthetic is mapped to "stroke" for point geom (not fill)
569+ let encoding = if vl[ "layer" ] . is_array ( ) {
570+ & vl[ "layer" ] [ 0 ] [ "encoding" ]
571+ } else {
572+ & vl[ "encoding" ]
573+ } ;
574+ // Find the stroke or fill encoding (color maps to one of these)
575+ let color_encoding = if encoding[ "stroke" ] . is_object ( ) {
576+ & encoding[ "stroke" ]
577+ } else {
578+ & encoding[ "fill" ]
579+ } ;
580+ assert_eq ! (
581+ color_encoding[ "scale" ] [ "type" ] ,
582+ "threshold" ,
583+ "Binned color should use threshold scale. Got encoding: {}" ,
584+ serde_json:: to_string_pretty( color_encoding) . unwrap( )
585+ ) ;
586+
587+ // Verify labelExpr exists for custom labels
588+ let legend = & color_encoding[ "legend" ] ;
589+ assert ! (
590+ legend[ "labelExpr" ] . is_string( ) ,
591+ "Legend should have labelExpr for custom labels. Got legend: {}" ,
592+ serde_json:: to_string_pretty( legend) . unwrap( )
593+ ) ;
594+
595+ let label_expr = legend[ "labelExpr" ] . as_str ( ) . unwrap_or ( "" ) ;
596+ // For symbol legends, VL generates range-style labels like "0 – 50"
597+ // Our labelExpr should map these to custom range formats
598+ assert ! (
599+ label_expr. contains( "Low" ) || label_expr. contains( "High" ) ,
600+ "labelExpr should contain custom labels, got: {}" ,
601+ label_expr
602+ ) ;
603+ }
460604}
0 commit comments