diff --git a/bin/alsdiff.ml b/bin/alsdiff.ml index 103d88a..3d7eed4 100644 --- a/bin/alsdiff.ml +++ b/bin/alsdiff.ml @@ -1,6 +1,7 @@ open Alsdiff_base open Alsdiff_live open Alsdiff_output +open Config open Eio.Std open View_model open Cmdliner @@ -17,22 +18,12 @@ let create_views (change : (Liveset.t, Liveset.Patch.t) Diff.structured_change) let item = View_model.create_liveset_item change in [View_model.Item item] -let render_views config (views : View_model.view list) : string = - let buffer = Buffer.create 4096 in - let ppf = Format.formatter_of_buffer buffer in - Fmt.set_style_renderer ppf `None; - - List.iter (fun view -> - Text_renderer.pp config ppf view; - Fmt.pf ppf "@."; - ) views; - - Format.pp_print_flush ppf (); - Buffer.contents buffer +type output_mode = Tree | Stats type config = { positional_args: string list; git_mode: bool; + output_mode: output_mode; config_file: string option; preset: [ `Compact | `Composer | `Full | `Inline | `Mixing | `Quiet | `Verbose ] option; dump_preset: [ `Compact | `Composer | `Full | `Inline | `Mixing | `Quiet | `Verbose ] option; @@ -121,75 +112,94 @@ let load_and_report_config config_path = Fmt.pr "Loading configuration from %s@." config_path; load_config_from_json config_path -let diff_cmd ~config ~domain_mgr : int = - let file1, file2, reference_path = - if config.git_mode then - match parse_git_args config.positional_args with - | Error msg -> failwith msg - | Ok git_args -> (git_args.old_file, git_args.new_file, git_args.path) - else - match config.positional_args with - | [f1; f2] -> (f1, f2, f2) - | _ -> failwith "FILE1.als and FILE2.als are required for diff" - in - - let base_renderer_config = - match config.config_file with - | Some config_path -> - load_config_from_json config_path +let build_base_renderer_config ~default_config ~reference_path config = + match config.config_file with + | Some config_path -> + load_config_from_json config_path + | None -> + match config.preset with + | Some preset -> + let base = match preset with + | `Compact -> Text_renderer.compact + | `Composer -> Text_renderer.composer + | `Full -> Text_renderer.full + | `Inline -> Text_renderer.inline + | `Mixing -> Text_renderer.mixing + | `Quiet -> Text_renderer.quiet + | `Verbose -> Text_renderer.verbose + in base | None -> - match config.preset with - | Some preset -> - let base = match preset with - | `Compact -> Text_renderer.compact - | `Composer -> Text_renderer.composer - | `Full -> Text_renderer.full - | `Inline -> Text_renderer.inline - | `Mixing -> Text_renderer.mixing - | `Quiet -> Text_renderer.quiet - | `Verbose -> Text_renderer.verbose - in base - | None -> - (* Auto-discover .alsdiff.json when neither --config nor --preset specified *) - match discover_config_file ~reference_path with - | Some auto_config -> load_and_report_config auto_config - | None -> Text_renderer.quiet - in + match discover_config_file ~reference_path with + | Some auto_config -> load_and_report_config auto_config + | None -> default_config + +let stats_incompatible_flags_provided config = + config.prefix_added <> None + || config.prefix_removed <> None + || config.prefix_modified <> None + || config.prefix_unchanged <> None + || config.note_name_style <> None + || config.max_collection_items <> None - let renderer_config = { base_renderer_config with - prefix_added = (match config.prefix_added with Some s -> s | None -> base_renderer_config.prefix_added); - prefix_removed = (match config.prefix_removed with Some s -> s | None -> base_renderer_config.prefix_removed); - prefix_modified = (match config.prefix_modified with Some s -> s | None -> base_renderer_config.prefix_modified); - prefix_unchanged = (match config.prefix_unchanged with Some s -> s | None -> base_renderer_config.prefix_unchanged); - note_name_style = (match config.note_name_style with Some s -> s | None -> base_renderer_config.note_name_style); - max_collection_items = (match config.max_collection_items with Some n -> Some n | None -> base_renderer_config.max_collection_items); - } in - - let liveset1, liveset2 = Fiber.pair - (fun () -> load_liveset ~domain_mgr file1) - (fun () -> load_liveset ~domain_mgr file2) - in +let diff_cmd ~config ~domain_mgr : int = + if config.output_mode = Stats && stats_incompatible_flags_provided config then begin + Fmt.epr "Error: --mode stats is incompatible with --prefix-*, \ + --note-name-style, and --max-collection-items@."; + 1 + end else begin + let file1, file2, reference_path = + if config.git_mode then + match parse_git_args config.positional_args with + | Error msg -> failwith msg + | Ok git_args -> (git_args.old_file, git_args.new_file, git_args.path) + else + match config.positional_args with + | [f1; f2] -> (f1, f2, f2) + | _ -> failwith "FILE1.als and FILE2.als are required for diff" + in - let liveset_patch = Liveset.diff liveset1 liveset2 in - let has_changes = not (Liveset.Patch.is_empty liveset_patch) in + let liveset1, liveset2 = Fiber.pair + (fun () -> load_liveset ~domain_mgr file1) + (fun () -> load_liveset ~domain_mgr file2) + in - let liveset_change = - if has_changes then - `Modified liveset_patch - else - `Unchanged - in + let liveset_patch = Liveset.diff liveset1 liveset2 in + let has_changes = not (Liveset.Patch.is_empty liveset_patch) in - let views = create_views liveset_change in + let liveset_change = + if has_changes then + `Modified liveset_patch + else + `Unchanged + in - let output = render_views renderer_config views in - Fmt.pr "%s@." output; + let views = create_views liveset_change in + + let output = match config.output_mode with + | Stats -> + let base_renderer_config = build_base_renderer_config ~default_config:stats_default ~reference_path config in + (* Stats mode doesn't use prefix/note_style/max_items, so no merging needed *) + Stats_renderer.render base_renderer_config views + | Tree -> + let base_renderer_config = build_base_renderer_config ~default_config:Text_renderer.quiet ~reference_path config in + let renderer_config = { base_renderer_config with + prefix_added = (match config.prefix_added with Some s -> s | None -> base_renderer_config.prefix_added); + prefix_removed = (match config.prefix_removed with Some s -> s | None -> base_renderer_config.prefix_removed); + prefix_modified = (match config.prefix_modified with Some s -> s | None -> base_renderer_config.prefix_modified); + prefix_unchanged = (match config.prefix_unchanged with Some s -> s | None -> base_renderer_config.prefix_unchanged); + note_name_style = (match config.note_name_style with Some s -> s | None -> base_renderer_config.note_name_style); + max_collection_items = (match config.max_collection_items with Some n -> Some n | None -> base_renderer_config.max_collection_items); + } in + Text_renderer.render renderer_config views + in - (* Exit code: git mode uses trustExitCode semantics *) - if config.git_mode then - if has_changes then 1 else 0 - else - 0 + Fmt.pr "%s@." output; + + if config.git_mode then + if has_changes then 1 else 0 + else + 0 + end let positional_args = let doc = "Positional arguments. Normal mode: FILE1.als FILE2.als. \ @@ -239,6 +249,11 @@ let max_collection_items = let doc = "Maximum number of items to show in collections (default from preset: None/10/50 depending on preset)" in Arg.(value & opt (some int) None & info ["max-collection-items"] ~docv:"N" ~doc) +let output_mode = + let doc = "Output mode. $(b,tree)=hierarchical tree view (default), $(b,stats)=summary statistics of changes by type. \ + Stats mode now supports --config and --preset for customizing which types appear in statistics. Incompatible with --prefix-*, --note-name-style, and --max-collection-items." in + Arg.(value & opt (enum ["tree", Tree; "stats", Stats]) Tree & info ["mode"] ~docv:"MODE" ~doc) + let dump_schema = let doc = "Dump JSON schema for configuration to stdout and exit. Does not require FILE1.als or FILE2.als." in Arg.(value & flag & info ["dump-schema"] ~doc) @@ -259,6 +274,8 @@ let cmd = `S Manpage.s_examples; `P "Compare two files with default quiet preset:"; `Pre "$(cmd) v1.als v2.als"; + `P "Show change statistics summary:"; + `Pre "$(cmd) v1.als v2.als --mode stats"; `P "Compare with compact output:"; `Pre "$(cmd) v1.als v2.als --preset compact"; `P "Compare with full details:"; @@ -312,6 +329,7 @@ let cmd = `P "Git mode with config file:"; `Pre "$(cmd) --config myconfig.json --git path old-file old-hex old-mode new-file new-hex new-mode"; `S Manpage.s_options; + `P "$(b,--mode MODE) selects the output mode. $(b,tree) (default) shows a hierarchical tree view of changes. $(b,stats) shows a flat summary of change counts by type (e.g., Tracks: 1 Added, 3 Modified). Stats mode is incompatible with --preset, --config, and other tree-specific options."; `P "$(b,--config FILE) loads configuration from JSON file. Takes precedence over auto-discovery. The --preset option is ignored when --config is specified. Individual CLI options override values from config file."; `P "$(b,--preset PRESET) sets the output detail preset. Available presets: $(b,compact), $(b,composer), $(b,full), $(b,mixing), $(b,quiet) (default), $(b,verbose). Takes precedence over auto-discovery but ignored when --config is specified."; `P "$(b,--prefix-added PREFIX) overrides prefix for added items from config file."; @@ -336,8 +354,8 @@ let cmd = Cmd.make (Cmd.info "alsdiff" ~version:(match version () with | None -> "dev" | Some v -> Version.to_string v) ~doc ~man ~exits) @@ - let+ positional_args and+ git_mode and+ config_file and+ preset and+ dump_preset and+ prefix_added and+ prefix_removed and+ prefix_modified and+ prefix_unchanged and+ note_name_style and+ max_collection_items and+ dump_schema and+ validate_config in - let cfg = { positional_args; git_mode; config_file; preset; dump_preset; prefix_added; prefix_removed; prefix_modified; prefix_unchanged; note_name_style; max_collection_items; dump_schema; validate_config } in + let+ positional_args and+ git_mode and+ config_file and+ preset and+ dump_preset and+ prefix_added and+ prefix_removed and+ prefix_modified and+ prefix_unchanged and+ note_name_style and+ max_collection_items and+ output_mode and+ dump_schema and+ validate_config in + let cfg = { positional_args; git_mode; output_mode; config_file; preset; dump_preset; prefix_added; prefix_removed; prefix_modified; prefix_unchanged; note_name_style; max_collection_items; dump_schema; validate_config } in config_ref := Some cfg; () diff --git a/lib/output/config.ml b/lib/output/config.ml index 093009c..0a1002a 100644 --- a/lib/output/config.ml +++ b/lib/output/config.ml @@ -443,6 +443,58 @@ let quiet = { indent_width = 2; } +(* stats_default preset: types shown in statistics output by default. + Shows the 7 core types that were historically in stats_renderer. + All other types set to Ignore to exclude from statistics. *) +let stats_default = { + (* Base: Summary to show counts only *) + added = Summary; + removed = Summary; + modified = Summary; + unchanged = Ignore; + + (* Show the 7 historically tracked types *) + type_overrides = [ + { domain_type = DTTrack; override = uniform_override Summary; }; + { domain_type = DTDevice; override = uniform_override Summary; }; + { domain_type = DTClip; override = uniform_override Summary; }; + { domain_type = DTNote; override = uniform_override Summary; }; + { domain_type = DTAutomation; override = uniform_override Summary; }; + { domain_type = DTSend; override = uniform_override Summary; }; + { domain_type = DTParam; override = uniform_override Summary; }; + { domain_type = DTLocator; override = uniform_override Summary; }; + + (* Explicitly ignore all other types for stats mode *) + { domain_type = DTLiveset; override = uniform_override Ignore; }; + { domain_type = DTMixer; override = uniform_override Ignore; }; + { domain_type = DTRouting; override = uniform_override Ignore; }; + { domain_type = DTEvent; override = uniform_override Ignore; }; + { domain_type = DTPreset; override = uniform_override Ignore; }; + { domain_type = DTMacro; override = uniform_override Ignore; }; + { domain_type = DTSnapshot; override = uniform_override Ignore; }; + { domain_type = DTLoop; override = uniform_override Ignore; }; + { domain_type = DTSignature; override = uniform_override Ignore; }; + { domain_type = DTSampleRef; override = uniform_override Ignore; }; + { domain_type = DTVersion; override = uniform_override Ignore; }; + { domain_type = DTOther; override = uniform_override Ignore; }; + ]; + + (* No limit needed for stats - just counting *) + max_collection_items = None; + + (* Standard prefixes *) + prefix_added = "+"; + prefix_removed = "-"; + prefix_modified = "*"; + prefix_unchanged = "="; + + (* Sharp note names *) + note_name_style = Sharp; + + (* Indent width *) + indent_width = 2; +} + (* Verbose preset: show everything including unchanged items. Useful for debugging, auditing, or understanding complete file structure. No limits - displays all items and all fields. *) diff --git a/lib/output/dune b/lib/output/dune index 5991876..a12f50c 100644 --- a/lib/output/dune +++ b/lib/output/dune @@ -2,9 +2,10 @@ (name alsdiff_output) (public_name alsdiff.output) (modules - view_model - config - text_renderer) + view_model + config + text_renderer + stats_renderer) (libraries alsdiff_base alsdiff_live diff --git a/lib/output/stats_renderer.ml b/lib/output/stats_renderer.ml new file mode 100644 index 0000000..73d72f0 --- /dev/null +++ b/lib/output/stats_renderer.ml @@ -0,0 +1,84 @@ +open View_model +open Config + +let display_name (dt : domain_type) : string = + match dt with + | DTTrack -> "Tracks" + | DTDevice -> "Devices" + | DTClip -> "Clips" + | DTNote -> "Notes" + | DTAutomation -> "Automations" + | DTSend -> "Sends" + | DTParam -> "Parameters" + | DTLocator -> "Locators" + | _ -> domain_type_to_string dt + +(* Build the initial stats list from config - include types not set to Ignore *) +let stats_from_config (cfg : detail_config) : (domain_type * change_breakdown) list = + (* All domain types we might track - check if they're not Ignored *) + let all_types = [ + DTLiveset; DTTrack; DTDevice; DTClip; DTNote; + DTAutomation; DTMixer; DTRouting; DTLocator; + DTParam; DTEvent; DTSend; DTPreset; DTMacro; + DTSnapshot; DTLoop; DTSignature; DTSampleRef; + DTVersion; DTOther; + ] in + (* Filter to types that should be tracked (not Ignore for their change type) *) + List.filter_map (fun dt -> + (* Check if any change type for this domain type is not Ignore *) + let trackable = + (get_effective_detail cfg Added dt <> Ignore) || + (get_effective_detail cfg Removed dt <> Ignore) || + (get_effective_detail cfg Modified dt <> Ignore) || + (get_effective_detail cfg Unchanged dt <> Ignore) + in + if trackable then Some (dt, { added = 0; removed = 0; modified = 0 }) + else None + ) all_types + +let increment_stats stats dt ct = + List.map + (fun (d, b) -> if d = dt then (d, increment_breakdown b ct) else (d, b)) + stats + +let rec collect_view (cfg : detail_config) (stats : (domain_type * change_breakdown) list) (view : view) = + match view with + | Field _ -> stats + | Item item -> + let stats = + (* Use get_effective_detail to check if type should be tracked *) + let level = get_effective_detail cfg item.change item.domain_type in + if should_render_level level && item.change <> Unchanged then + increment_stats stats item.domain_type item.change + else stats + in + List.fold_left (collect_view cfg) stats item.children + | Collection col -> List.fold_left (collect_view cfg) stats col.items + +let collect (cfg : detail_config) (views : view list) : (domain_type * change_breakdown) list = + List.fold_left (collect_view cfg) (stats_from_config cfg) views + +let render_line (dt : domain_type) (b : change_breakdown) : string option = + if total_breakdown b = 0 then None + else + let parts = + List.filter_map Fun.id + [ + (if b.added > 0 then Some (Printf.sprintf "%d Added" b.added) + else None); + (if b.removed > 0 then Some (Printf.sprintf "%d Removed" b.removed) + else None); + (if b.modified > 0 then Some (Printf.sprintf "%d Modified" b.modified) + else None); + ] + in + Some (Printf.sprintf "%s: %s" (display_name dt) (String.concat ", " parts)) + +let render (cfg : detail_config) (views : view list) : string = + let stats = collect cfg views in + let lines = + List.filter_map (fun (dt, b) -> render_line dt b) stats + in + match lines with + | [] -> "No changes." + | _ -> String.concat "\n" lines diff --git a/lib/output/text_renderer.ml b/lib/output/text_renderer.ml index 32c929e..c22ed63 100644 --- a/lib/output/text_renderer.ml +++ b/lib/output/text_renderer.ml @@ -231,6 +231,19 @@ let pp cfg fmt view = let render_to_string cfg view = Fmt.str "%a" (pp_view cfg) view +let render config (views : View_model.view list) : string = + let buffer = Buffer.create 4096 in + let ppf = Format.formatter_of_buffer buffer in + Fmt.set_style_renderer ppf `None; + + List.iter (fun view -> + pp config ppf view; + Fmt.pf ppf "@."; + ) views; + + Format.pp_print_flush ppf (); + Buffer.contents buffer + (* ==================== Backward Compatibility Re-exports ==================== *) (* Include all Config types and functions for backward compatibility. This ensures that code referencing Text_renderer.Ignore, Text_renderer.detail_config_of_yojson, diff --git a/scripts/setup-git.sh b/scripts/setup-git.sh index e40617d..ab9c80f 100755 --- a/scripts/setup-git.sh +++ b/scripts/setup-git.sh @@ -70,6 +70,115 @@ fi git config --global diff.alsdiff.command "$ALSDIFF_CMD" echo "✅ Configured git to use: $ALSDIFF_CMD" +# --- prepare-commit-msg hook --- +echo "" +echo "🔧 Optional: Install a prepare-commit-msg hook?" +echo "" +echo "This hook auto-generates commit message summaries from .als file changes" +echo "using 'alsdiff --mode stats'. When you commit staged .als files, the" +echo "commit message will be pre-filled with a change summary like:" +echo "" +echo " MyProject.als:" +echo " Tracks: 1 Added, 3 Modified" +echo " Devices: 2 Added, 5 Removed" +echo "" +read -p "Install prepare-commit-msg hook? (y/N): " install_hook + +if [[ "$install_hook" =~ ^[Yy]$ ]]; then + HOOK_FILE=".git/hooks/prepare-commit-msg" + MARKER_BEGIN="# alsdiff:begin" + MARKER_END="# alsdiff:end" + + # Check if our hook block is already installed + if [ -f "$HOOK_FILE" ] && grep -q "$MARKER_BEGIN" "$HOOK_FILE"; then + echo "✅ prepare-commit-msg hook already contains alsdiff block" + else + # Define the hook block + HOOK_BLOCK=' +'"$MARKER_BEGIN"' +# Auto-generate commit message from .als file changes using alsdiff --mode stats +# Installed by: scripts/setup-git.sh +alsdiff_prepare_commit_msg() { + COMMIT_MSG_FILE="$1" + COMMIT_SOURCE="${2:-}" + + # Skip for merges, squashes, amends, and -m messages + if [ -n "$COMMIT_SOURCE" ]; then + return + fi + + # Check if alsdiff is available + if ! command -v alsdiff &> /dev/null; then + return + fi + + # Find staged .als files + ALS_STATUS=$(git diff --cached --name-status -- "*.als" 2>/dev/null) + if [ -z "$ALS_STATUS" ]; then + return + fi + + # Create temp directory for blob extraction + TMPDIR_ALS=$(mktemp -d) + trap "rm -rf \"$TMPDIR_ALS\"" RETURN + + STATS_OUTPUT="" + + while IFS=$'"'"'\t'"'"' read -r status filepath; do + filename=$(basename "$filepath") + case "$status" in + M) + # Modified: diff HEAD vs staged + OLD_FILE="$TMPDIR_ALS/old_${filename}" + NEW_FILE="$TMPDIR_ALS/new_${filename}" + git show "HEAD:${filepath}" > "$OLD_FILE" 2>/dev/null || continue + git show ":${filepath}" > "$NEW_FILE" 2>/dev/null || continue + DIFF_STATS=$(alsdiff --mode stats "$OLD_FILE" "$NEW_FILE" 2>/dev/null) || true + if [ -n "$DIFF_STATS" ] && [ "$DIFF_STATS" != "No changes." ]; then + # Indent each line of stats output + INDENTED=$(echo "$DIFF_STATS" | sed "s/^/ /") + STATS_OUTPUT="${STATS_OUTPUT}${filepath}:\n${INDENTED}\n" + fi + ;; + A) + STATS_OUTPUT="${STATS_OUTPUT}${filepath}: New file\n" + ;; + D) + STATS_OUTPUT="${STATS_OUTPUT}${filepath}: Deleted\n" + ;; + esac + done <<< "$ALS_STATUS" + + if [ -n "$STATS_OUTPUT" ]; then + # Prepend stats to commit message (before any # comment lines) + TMPFILE="$TMPDIR_ALS/commit_msg" + printf "%b\n" "$STATS_OUTPUT" > "$TMPFILE" + cat "$COMMIT_MSG_FILE" >> "$TMPFILE" + mv "$TMPFILE" "$COMMIT_MSG_FILE" + fi +} +alsdiff_prepare_commit_msg "$@" +'"$MARKER_END" + + if [ -f "$HOOK_FILE" ]; then + # Append to existing hook + echo "$HOOK_BLOCK" >> "$HOOK_FILE" + echo "✅ Appended alsdiff block to existing prepare-commit-msg hook" + else + # Create new hook + echo '#!/bin/bash' > "$HOOK_FILE" + echo "$HOOK_BLOCK" >> "$HOOK_FILE" + chmod +x "$HOOK_FILE" + echo "✅ Created prepare-commit-msg hook" + fi + fi +else + echo "⏭️ Skipping prepare-commit-msg hook" +fi + echo "" echo "🎉 Done! Git configured with:" -echo " $ALSDIFF_CMD" +echo " Diff driver: $ALSDIFF_CMD" +if [[ "$install_hook" =~ ^[Yy]$ ]]; then + echo " Commit hook: .git/hooks/prepare-commit-msg (alsdiff --mode stats)" +fi diff --git a/test/dune b/test/dune index cfb0aa1..1f3115c 100644 --- a/test/dune +++ b/test/dune @@ -159,6 +159,11 @@ (modules test_view_model utils) (libraries alsdiff_base alsdiff_live alsdiff_output alcotest)) +(test + (name test_stats_renderer) + (modules test_stats_renderer) + (libraries alsdiff_output alcotest)) + ;; Auto indent files with `dune build @fmt` (subdir run diff --git a/test/test_stats_renderer.ml b/test/test_stats_renderer.ml new file mode 100644 index 0000000..9394765 --- /dev/null +++ b/test/test_stats_renderer.ml @@ -0,0 +1,165 @@ +open Alsdiff_output.View_model +open Alsdiff_output.Stats_renderer +open Alsdiff_output.Config + +let mk_field name change = + Field { name; change; domain_type = DTOther; oldval = None; newval = None } + +let mk_item name change domain_type children = + Item { name; change; domain_type; children } + +let mk_collection name change domain_type items = + Collection { name; change; domain_type; items } + +let test_empty_no_changes () = + let views = [mk_item "LiveSet" Unchanged DTLiveset []] in + let output = render stats_default views in + Alcotest.(check string) "no changes" "No changes." output + +let test_single_added_track () = + let views = + [ mk_item "LiveSet" Modified DTLiveset + [ mk_collection "Tracks" Modified DTTrack + [ mk_item "Track 1" Added DTTrack [mk_field "Name" Added] ] + ] + ] + in + let output = render stats_default views in + Alcotest.(check string) "single added track" "Tracks: 1 Added" output + +let test_mixed_changes () = + let views = + [ mk_item "LiveSet" Modified DTLiveset + [ mk_collection "Tracks" Modified DTTrack + [ mk_item "Track 1" Added DTTrack + [ mk_collection "Devices" Modified DTDevice + [ mk_item "Compressor" Added DTDevice []; + mk_item "EQ8" Removed DTDevice []; + mk_item "Reverb" Modified DTDevice [] ] + ; + mk_collection "Clips" Modified DTClip + [ mk_item "Clip A" Modified DTClip + [ mk_collection "Notes" Modified DTNote + [ mk_item "C4" Added DTNote []; + mk_item "E4" Modified DTNote []; + mk_item "G4" Modified DTNote [] ] + ] + ] + ] + ; + mk_item "Track 2" Modified DTTrack + [ mk_collection "Clips" Modified DTClip + [ mk_item "Clip 1" Removed DTClip [] ] + ] + ; + mk_item "Track 3" Removed DTTrack [] + ] + ] + ] + in + let output = render stats_default views in + let lines = String.split_on_char '\n' output in + Alcotest.(check int) "line count" 4 (List.length lines); + Alcotest.(check string) "tracks line" "Tracks: 1 Added, 1 Removed, 1 Modified" + (List.nth lines 0); + Alcotest.(check string) "devices line" "Devices: 1 Added, 1 Removed, 1 Modified" + (List.nth lines 1); + Alcotest.(check string) "clips line" "Clips: 1 Removed, 1 Modified" + (List.nth lines 2); + Alcotest.(check string) "notes line" "Notes: 1 Added, 2 Modified" + (List.nth lines 3) + +let test_non_reportable_types_excluded () = + let views = + [ mk_item "LiveSet" Modified DTLiveset + [ mk_item "Mixer" Modified DTMixer [mk_field "Volume" Modified]; + mk_item "Loop" Added DTLoop []; + mk_item "Version" Modified DTVersion []; + mk_item "SampleRef" Added DTSampleRef []; + ] + ] + in + let output = render stats_default views in + Alcotest.(check string) "non-reportable excluded" "No changes." output + +let test_unchanged_items_omitted () = + let views = + [ mk_item "LiveSet" Modified DTLiveset + [ mk_collection "Tracks" Modified DTTrack + [ mk_item "Track 1" Unchanged DTTrack []; + mk_item "Track 2" Added DTTrack [] + ] + ] + ] + in + let output = render stats_default views in + Alcotest.(check string) "unchanged omitted" "Tracks: 1 Added" output + +let test_ordering () = + let views = + [ mk_item "LiveSet" Modified DTLiveset + [ mk_item "Locator 1" Added DTLocator []; + mk_collection "Tracks" Modified DTTrack + [ mk_item "Track 1" Modified DTTrack + [ mk_collection "Notes" Modified DTNote + [ mk_item "C4" Added DTNote [] ] + ] + ] + ] + ] + in + let output = render stats_default views in + let lines = String.split_on_char '\n' output in + Alcotest.(check int) "line count" 3 (List.length lines); + Alcotest.(check string) "tracks first" "Tracks: 1 Modified" (List.nth lines 0); + Alcotest.(check string) "notes second" "Notes: 1 Added" (List.nth lines 1); + Alcotest.(check string) "locators third" "Locators: 1 Added" (List.nth lines 2) + +let test_deeply_nested () = + let views = + [ mk_item "LiveSet" Modified DTLiveset + [ mk_collection "Tracks" Modified DTTrack + [ mk_item "Track 1" Modified DTTrack + [ mk_collection "Devices" Modified DTDevice + [ mk_item "Group" Modified DTDevice + [ mk_collection "Devices" Modified DTDevice + [ mk_item "Inner" Added DTDevice [] ] + ] + ] + ] + ] + ] + ] + in + let output = render stats_default views in + let lines = String.split_on_char '\n' output in + Alcotest.(check int) "line count" 2 (List.length lines); + Alcotest.(check string) "tracks" "Tracks: 1 Modified" (List.nth lines 0); + Alcotest.(check string) "devices counted" "Devices: 1 Added, 1 Modified" (List.nth lines 1) + +let test_zero_count_change_types_omitted () = + let views = + [ mk_item "LiveSet" Modified DTLiveset + [ mk_collection "Tracks" Modified DTTrack + [ mk_item "Track 1" Added DTTrack []; + mk_item "Track 2" Added DTTrack [] + ] + ] + ] + in + let output = render stats_default views in + Alcotest.(check string) "only added shown" "Tracks: 2 Added" output + +let tests = + [ + "empty no changes", `Quick, test_empty_no_changes; + "single added track", `Quick, test_single_added_track; + "mixed changes", `Quick, test_mixed_changes; + "non-reportable types excluded", `Quick, test_non_reportable_types_excluded; + "unchanged items omitted", `Quick, test_unchanged_items_omitted; + "ordering follows fixed order", `Quick, test_ordering; + "deeply nested counting", `Quick, test_deeply_nested; + "zero count change types omitted", `Quick, test_zero_count_change_types_omitted; + ] + +let () = Alcotest.run "Stats_renderer" [ "stats", tests ]