using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class ParameterizedMarketSceneBuildersTests
{
    [Fact]
    public void Builder_keys_are_the_closed_scene_codes()
    {
        Assert.Equal("s5001", new S5001SceneMutationBuilder().BuilderKey);
        Assert.Equal("s5032", new S5032SceneMutationBuilder().BuilderKey);
        Assert.Equal("s8018", new S8018SceneMutationBuilder().BuilderKey);
        Assert.Equal("s8001", new S8001SceneMutationBuilder().BuilderKey);
    }

    [Fact]
    public void S5001_all_query_branches_are_explicit_and_emit_the_original_value_group()
    {
        S5001SceneData[] cases =
        [
            new S5001BalticDryIndexSceneData("BDI 지수", ScenePriceDirection.Up, 1_900, 12, 0.64m),
            new S5001CommoditySceneData(S5001CommodityTarget.Copper, "구리", ScenePriceDirection.Down,
                4.2500m, -0.1250m, -2.86m),
            new S5001DomesticIndexSceneData(S5001DomesticIndexTarget.Kospi, S5001IndexMode.Current,
                "코스피", ScenePriceDirection.Flat, 2_800.1m, 0, 0),
            new S5001ForeignIndexSceneData(S5001ForeignIndexTarget.Nasdaq, "나스닥",
                ScenePriceDirection.Up, 20_100.25m, 120.50m, 0.60m),
            new S5001ExchangeRateSceneData(S5001ExchangeRateTarget.WonDollar, "원달러",
                ScenePriceDirection.Down, 1_380.2m, -2.4m, 0.17m),
            new S5001DomesticIndustrySceneData(LegacyDomesticEquityMarket.Kosdaq, "반도체",
                ScenePriceDirection.Up, 1_234.5m, 5.5m, 0.45m),
            new S5001ForeignIndustrySceneData("필라델피아 반도체", ScenePriceDirection.Flat,
                5_000m, 0, 0),
            new S5001ForeignStockSceneData("NVIDIA", ScenePriceDirection.Down,
                190.25m, -2.15m, 1.12m),
            new S5001DomesticStockSceneData(LegacyDomesticEquityMarket.Kospi,
                S5001DomesticStockMode.Current, S5001NxtBadge.None, "삼성전자",
                ScenePriceDirection.LimitDown, 70_000, -30_000, -30m)
        ];

        var builder = new S5001SceneMutationBuilder();
        foreach (var data in cases)
        {
            var actual = builder.Build(data).Select(Describe).ToArray();
            Assert.Equal("S:mart=0", actual[0]);
            Assert.Contains(actual, item => item.StartsWith("V:title=", StringComparison.Ordinal));
            Assert.Contains(actual, item => item.StartsWith("V:price=", StringComparison.Ordinal));
            Assert.Contains(actual, item => item.StartsWith("V:unitText=", StringComparison.Ordinal));
            Assert.Contains(actual, item => item.StartsWith("A:pattern", StringComparison.Ordinal));
        }
    }

    [Fact]
    public void S5001_commodity_preserves_raw_decimal_scale_and_negative_magnitude_rules()
    {
        var actual = new S5001SceneMutationBuilder().Build(new S5001CommoditySceneData(
            S5001CommodityTarget.Copper,
            "구리",
            ScenePriceDirection.Down,
            4.2500m,
            -0.1250m,
            -2.86m)).Take(7).Select(Describe);

        Assert.Equal(
        [
            "S:mart=0",
            "V:title=구리",
            "V:price=4.2500",
            "V:changePrice3_=0.1250",
            "V:rate3_=-2.86",
            "V:wonText3_=",
            "V:unitText=단위 : 달러"
        ], actual);
    }

    [Fact]
    public void S5001_domestic_stock_modes_preserve_title_badge_and_duplicate_unit_order()
    {
        var builder = new S5001SceneMutationBuilder();
        var expected = builder.Build(new S5001DomesticStockSceneData(
            LegacyDomesticEquityMarket.Kospi,
            S5001DomesticStockMode.ExpectedOpening,
            S5001NxtBadge.None,
            "삼성전자",
            ScenePriceDirection.Down,
            71_000,
            -900,
            1.25m)).Take(8).Select(Describe);
        Assert.Equal(
        [
            "S:mart=0",
            "V:unitText=단위 : 원",
            "V:title=삼성전자 예상체결가",
            "V:price=71,000",
            "V:changePrice3_=900",
            "V:rate3_=-1.25",
            "V:wonText3_=",
            "V:unitText=단위 : 원"
        ], expected);

        var afterHours = builder.Build(new S5001DomesticStockSceneData(
            LegacyDomesticEquityMarket.Kosdaq,
            S5001DomesticStockMode.AfterHoursSinglePrice,
            S5001NxtBadge.None,
            "테스트",
            ScenePriceDirection.Flat,
            10_000,
            0,
            0)).Take(5).Select(Describe);
        Assert.Equal(
        [
            "S:mart=0",
            "V:unitText=단위 : 원",
            "V:title=테스트",
            "V:title=테스트 시간외단일가",
            "V:price=10,000"
        ], afterHours);

        var nxt = builder.Build(new S5001DomesticStockSceneData(
            LegacyDomesticEquityMarket.NxtKospi,
            S5001DomesticStockMode.Current,
            S5001NxtBadge.PreMarket,
            "삼성전자(NXT)",
            ScenePriceDirection.Up,
            72_000,
            1_000,
            1.41m)).Select(Describe).ToArray();
        Assert.Equal("V:title=삼성전자", nxt[2]);
        Assert.Equal(@"A:mart=Images\프리마켓.png", nxt[8]);
        Assert.Equal("S:mart=1", nxt[9]);
        AssertRelativeAssetsOnly(builder.Build(new S5001DomesticStockSceneData(
            LegacyDomesticEquityMarket.NxtKosdaq,
            S5001DomesticStockMode.Current,
            S5001NxtBadge.AfterMarket,
            "테스트(NXT)",
            ScenePriceDirection.Up,
            1,
            1,
            1)));
    }

    [Fact]
    public void S5001_direction_signals_have_golden_active_objects_and_value_variants()
    {
        var cases = new[]
        {
            (ScenePriceDirection.Up, "V:changePrice1_=10", "S:up=1"),
            (ScenePriceDirection.Flat, "V:changePrice2_=10", "S:flat=1"),
            (ScenePriceDirection.Down, "V:changePrice3_=10", "S:down=1"),
            (ScenePriceDirection.LimitUp, "V:changePrice1_=10", "S:upup=1"),
            (ScenePriceDirection.LimitDown, "V:changePrice3_=10", "S:downdown=1")
        };
        var builder = new S5001SceneMutationBuilder();
        foreach (var (direction, value, visible) in cases)
        {
            var actual = builder.Build(new S5001BalticDryIndexSceneData(
                "BDI", direction, 100, 10, 1)).Select(Describe).ToArray();
            Assert.Contains(value, actual);
            Assert.Contains(visible, actual);
        }
    }

    [Fact]
    public void S5032_index_branch_matches_primary_then_direction_then_companion_order()
    {
        var actual = new S5032SceneMutationBuilder().Build(new S5032IndexAndFuturesSceneData(
            LegacyMarketQuoteTarget.Kospi,
            LegacyMarketQuoteTarget.Futures,
            new S5032PrimaryQuote("코스피", 2_812.34m, -12.30m, 0.44m, ScenePriceDirection.Down),
            new S5032CompanionQuote("KOSPI200 선물", 381.25m, -1.20m,
                S5032CompanionSignal.Negative, 123_456m, 98_765_432m, 456_789m)))
            .Select(Describe).ToArray();

        Assert.Equal(
        [
            "V:title=코스피",
            "V:price=2,812.34",
            "V:changePrice=12.30",
            "V:rate=-0.44",
            "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0",
            "S:down=1"
        ], actual.Take(10));
        var companionStart = Array.IndexOf(actual, "V:title1=KOSPI200 선물");
        Assert.True(companionStart > 20);
        Assert.Equal(
        [
            "V:title1=KOSPI200 선물",
            "V:price1=381.25",
            "V:changePrice1=-1.20",
            "S:up1=0",
            "S:upup1=0",
            "S:down1=1",
            "S:downdown1=0",
            "S:flat1=0",
            "V:r_value1=123,456",
            "V:r_value2=99",
            "V:r_value3=456,789"
        ], actual.Skip(companionStart));
    }

    [Fact]
    public void S5032_all_branches_and_companion_signals_are_closed()
    {
        var builder = new S5032SceneMutationBuilder();
        foreach (var signal in Enum.GetValues<S5032CompanionSignal>())
        {
            var actual = builder.Build(new S5032DomesticIndustryPairSceneData(
                LegacyDomesticEquityMarket.Kospi,
                new S5032PrimaryQuote("전기전자", 100, 1, 1, ScenePriceDirection.Up),
                new S5032CompanionQuote("반도체", 200, 0, signal, 1, 2_000_000, 3)))
                .Select(Describe).ToArray();
            Assert.Equal("V:title=전기전자", actual[0]);
            Assert.Contains("V:title1=반도체", actual);
            Assert.Contains(signal switch
            {
                S5032CompanionSignal.Positive => "S:up1=1",
                S5032CompanionSignal.Negative => "S:down1=1",
                _ => "S:flat1=1"
            }, actual);
        }

        var stocks = builder.Build(new S5032DomesticStockPairSceneData(
            LegacyDomesticEquityMarket.Kospi,
            LegacyDomesticEquityMarket.Kosdaq,
            new S5032StockQuote("A", 1_000, 100, 10, ScenePriceDirection.LimitUp),
            new S5032StockQuote("B", 2_000, -200, -10, ScenePriceDirection.LimitDown)))
            .Select(Describe).ToArray();
        Assert.Equal(
        [
            "V:title1=A", "V:title2=B", "V:price1=1,000", "V:price2=2,000",
            "V:changePrice1=100", "V:changePrice2=-200", "V:rate1=10.00", "V:rate2=-10.00",
            "V:wonText1=", "V:wonText2=", "V:unitText1=단위 : 원", "V:unitText2=단위 : 원"
        ], stocks.Take(12));

        var limitUpPattern = Array.IndexOf(stocks, @"A:pattern1=Images\그림_빨강.png");
        var limitUpUnit = Array.IndexOf(stocks, "S:unit1_1=1");
        var limitDownPattern = Array.IndexOf(stocks, @"A:pattern2=Images\그림_파랑.png");
        var limitDownUnit = Array.IndexOf(stocks, "S:unit3_2=1");
        Assert.True(limitUpPattern < limitUpUnit);
        Assert.True(limitDownUnit < limitDownPattern);
    }

    [Fact]
    public void S8018_market_and_industry_pairs_repeat_three_values_in_original_side_order()
    {
        var builder = new S8018SceneMutationBuilder();
        var market = builder.Build(new S8018MarketPairSceneData(
            LegacyMarketQuoteTarget.WonDollar,
            LegacyMarketQuoteTarget.Nasdaq,
            new S8018MarketQuote("원달러", 1_381.2m, -2.3m, 0.17m, ScenePriceDirection.Down),
            new S8018MarketQuote("나스닥", 20_100.5m, 100m, 0.50m, ScenePriceDirection.Up)))
            .Select(Describe).ToArray();

        Assert.Equal(
        [
            "S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0",
            "V:title1=원달러", "V:title2=나스닥",
            "V:price1=1,381.20", "V:price2=20,100.50",
            "V:changePrice1_1=2.30", "V:changePrice2_1=2.30", "V:changePrice3_1=2.30",
            "V:changePrice1_2=100.00", "V:changePrice2_2=100.00", "V:changePrice3_2=100.00",
            "V:rate1_1=-0.17", "V:rate2_1=-0.17", "V:rate3_1=-0.17",
            "V:rate1_2=0.50", "V:rate2_2=0.50", "V:rate3_2=0.50",
            "V:wonText1=", "V:wonText2=", "V:unitText1=단위 : p", "V:unitText2=단위 : p"
        ], market.Take(24));

        var industry = builder.Build(new S8018DomesticIndustryPairSceneData(
            LegacyDomesticEquityMarket.Kosdaq,
            new S8018MarketQuote("반도체", 1, 0, 0, ScenePriceDirection.Flat),
            new S8018MarketQuote("IT부품", 2, 0, 0, ScenePriceDirection.Flat)))
            .Select(Describe).ToArray();
        Assert.Equal("V:title1=코스닥 - 반도체", industry[4]);
        Assert.Equal("V:title2=코스닥 - IT부품", industry[5]);
    }

    [Fact]
    public void S8018_mixed_pair_preserves_partial_DataView_then_SetDataText_order_on_both_sides()
    {
        var builder = new S8018SceneMutationBuilder();
        var stockFirst = builder.Build(new S8018MixedMarketAndStockSceneData(
            S8018PairSide.First,
            LegacyMarketQuoteTarget.Kospi,
            new S8018MarketQuote("코스피", 2_800m, 10m, 0.36m, ScenePriceDirection.Up),
            new S8018MixedStockQuote(LegacyDomesticEquityMarket.Kospi, "삼성전자",
                70_000, -500, -0.71m, ScenePriceDirection.Down)))
            .Select(Describe).ToArray();
        Assert.Equal("V:title2=코스피", stockFirst[4]);
        Assert.True(Array.IndexOf(stockFirst, "V:title1=삼성전자") >
                    Array.IndexOf(stockFirst, @"A:pattern2=Images\그림_빨강.png"));
        Assert.Contains("V:unitText2=단위 : p", stockFirst);
        Assert.Contains("V:unitText1=단위 : 원", stockFirst);

        var stockSecond = builder.Build(new S8018MixedMarketAndStockSceneData(
            S8018PairSide.Second,
            LegacyMarketQuoteTarget.Kosdaq,
            new S8018MarketQuote("코스닥", 900m, 0m, 0m, ScenePriceDirection.Flat),
            new S8018MixedStockQuote(LegacyDomesticEquityMarket.Kosdaq, "테스트",
                10_000, 0, 0, ScenePriceDirection.Flat)))
            .Select(Describe).ToArray();
        Assert.Equal("V:title1=코스닥", stockSecond[4]);
        Assert.DoesNotContain("V:unitText1=단위 : p", stockSecond);
        Assert.Contains("V:unitText2=단위 : 원", stockSecond);
    }

    [Fact]
    public void S8018_expected_pair_interleaves_each_side_and_uses_limit_signals()
    {
        var actual = new S8018SceneMutationBuilder().Build(new S8018ExpectedStockPairSceneData(
            new S8018ExpectedStockQuote(LegacyDomesticEquityMarket.Kospi, "A",
                10_000, -3_000, 30m, ScenePriceDirection.LimitUp),
            new S8018ExpectedStockQuote(LegacyDomesticEquityMarket.Kosdaq, "B",
                20_000, -6_000, -30m, ScenePriceDirection.LimitDown)))
            .Select(Describe).ToArray();

        Assert.Equal(
        [
            "S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0",
            "V:title1=A 예상체결", "V:title2=B 예상체결",
            "V:price1=10,000", "V:price2=20,000",
            "V:changePrice1_1=3,000", "V:changePrice1_2=6,000",
            "V:changePrice2_1=3,000", "V:changePrice2_2=6,000",
            "V:changePrice3_1=3,000", "V:changePrice3_2=6,000"
        ], actual.Take(14));
        Assert.Contains("S:upup1=1", actual);
        Assert.Contains("S:downdown2=1", actual);
    }

    [Fact]
    public void S8018_current_pair_matches_units_badges_raw_scale_and_both_krx_override()
    {
        var builder = new S8018SceneMutationBuilder();
        var mixedVenue = builder.Build(new S8018CurrentStockPairSceneData(
            S8018SessionPhase.AfterKrxClose,
            new S8018CurrentStockQuote(S8018StockMarket.NxtKospi, "NXT 종목",
                70_000m, 500m, 0.72m, ScenePriceDirection.Up),
            new S8018CurrentStockQuote(S8018StockMarket.World, "NVIDIA",
                190.250m, -2.150m, -1.12m, ScenePriceDirection.Down)))
            .Select(Describe).ToArray();
        Assert.Equal("V:unitText1=단위 : 원", mixedVenue[4]);
        Assert.Equal("V:unitText2=단위 : 달러", mixedVenue[5]);
        Assert.Equal(@"A:mart1=Images\NXT.png", mixedVenue[8]);
        Assert.Equal("S:mart1=1", mixedVenue[9]);
        Assert.Equal("S:mart2=0", mixedVenue[10]);
        Assert.Equal("S:end2=0", mixedVenue[11]);
        Assert.Equal("V:price1=70,000", mixedVenue[12]);
        Assert.Equal("V:price2=190.250", mixedVenue[13]);
        AssertRelativeAssetsOnly(builder.Build(new S8018CurrentStockPairSceneData(
            S8018SessionPhase.AfterKrxClose,
            new S8018CurrentStockQuote(S8018StockMarket.Kospi, "A", 1, 1, 1, ScenePriceDirection.Up),
            new S8018CurrentStockQuote(S8018StockMarket.NxtKosdaq, "B", 2, 1, 1, ScenePriceDirection.Up))));

        var bothKrx = builder.Build(new S8018CurrentStockPairSceneData(
            S8018SessionPhase.AfterKrxClose,
            new S8018CurrentStockQuote(S8018StockMarket.Kospi, "A", 1, 1, 1, ScenePriceDirection.Up),
            new S8018CurrentStockQuote(S8018StockMarket.Kosdaq, "B", 2, -1, -1, ScenePriceDirection.Down)))
            .Select(Describe).ToArray();
        var secondEndShown = Array.IndexOf(bothKrx, "S:end2=1");
        Assert.True(secondEndShown >= 0);
        Assert.Equal(
        ["S:mart1=0", "S:mart2=0", "S:end1=0", "S:end2=0"],
            bothKrx.Skip(secondEndShown + 1).Take(4));
    }

    [Fact]
    public void S8001_uses_only_fixed_sector_targets_and_original_threshold_colors()
    {
        var actual = new S8001SceneMutationBuilder().Build(new S8001SceneData(
            S8001Market.Kospi,
            [
                new S8001SectorQuote(S8001Sector.Communications, S8001SectorSignal.Positive, 0.99m),
                new S8001SectorQuote(S8001Sector.Finance, S8001SectorSignal.Positive, 1.00m),
                new S8001SectorQuote(S8001Sector.Construction, S8001SectorSignal.Positive, 3.00m),
                new S8001SectorQuote(S8001Sector.Machinery, S8001SectorSignal.Positive, 5.00m),
                new S8001SectorQuote(S8001Sector.Distribution, S8001SectorSignal.Positive, 10.00m),
                new S8001SectorQuote(S8001Sector.TransportationEquipment, S8001SectorSignal.Negative, 0.99m),
                new S8001SectorQuote(S8001Sector.TransportationWarehouse, S8001SectorSignal.Negative, 1.00m),
                new S8001SectorQuote(S8001Sector.FoodAndBeverage, S8001SectorSignal.Negative, 3.00m),
                new S8001SectorQuote(S8001Sector.SteelAndMetal, S8001SectorSignal.Negative, 5.00m),
                new S8001SectorQuote(S8001Sector.MedicalPrecision, S8001SectorSignal.Negative, 10.00m),
                new S8001SectorQuote(S8001Sector.TextilesAndApparel, S8001SectorSignal.Flat, 0m),
                new S8001SectorQuote(S8001Sector.Chemicals, S8001SectorSignal.Positive, 1m),
                new S8001SectorQuote(S8001Sector.Pharmaceuticals, S8001SectorSignal.Negative, 1m)
            ])).Select(Describe).ToArray();

        Assert.Contains("V:통신_등락=0.99", actual);
        Assert.Contains("C:통신_큐브=206,61,59,255", actual);
        Assert.Contains("C:금융_큐브=181,28,39,255", actual);
        Assert.Contains("C:건설_큐브=154,28,39,255", actual);
        Assert.Contains("C:기계_큐브=131,28,39,255", actual);
        Assert.Contains("C:유통_큐브=174,0,0,255", actual);
        Assert.Contains("V:운수장비_등락=-0.99", actual);
        Assert.Contains("C:운수장비_큐브=66,128,185,255", actual);
        Assert.Contains("C:운수창고_큐브=52,117,157,255", actual);
        Assert.Contains("C:음식료_큐브=52,78,126,255", actual);
        Assert.Contains("C:철강금속_큐브=42,61,124,255", actual);
        Assert.Contains("C:의료정밀_큐브=42,47,124,255", actual);
        Assert.Contains("V:섬유의복_등락=0", actual);
        Assert.DoesNotContain(actual, value => value.StartsWith("C:섬유의복_큐브=", StringComparison.Ordinal));
        Assert.Contains("V:화학_등락=1", actual);
        Assert.Contains("V:의약품_등락=-1", actual);
    }

    [Fact]
    public void S8001_sector_enum_maps_every_cut_object_for_both_markets()
    {
        var kospi = Enum.GetValues<S8001Sector>()
            .Where(value => value is not S8001Sector.MachineryAndEquipment and
                not S8001Sector.ItComponents and not S8001Sector.Transportation and
                not S8001Sector.Semiconductor and not S8001Sector.Internet)
            .Select(value => new S8001SectorQuote(value, S8001SectorSignal.Flat, 0m))
            .ToArray();
        var kosdaq = new[]
        {
            S8001Sector.MachineryAndEquipment,
            S8001Sector.ItComponents,
            S8001Sector.Transportation,
            S8001Sector.PaperAndWood,
            S8001Sector.Semiconductor,
            S8001Sector.Internet
        }.Select(value => new S8001SectorQuote(value, S8001SectorSignal.Flat, 0m)).ToArray();

        Assert.Equal(15, kospi.Length);
        Assert.Equal(15, new S8001SceneMutationBuilder().Build(new S8001SceneData(S8001Market.Kospi, kospi)).Count);
        Assert.Equal(6, new S8001SceneMutationBuilder().Build(new S8001SceneData(S8001Market.Kosdaq, kosdaq)).Count);
    }

    [Fact]
    public void Parameterized_DTOs_do_not_expose_raw_object_method_or_path_fields()
    {
        var dtoTypes = typeof(S5001SceneData).Assembly.GetTypes()
            .Where(type => type.Namespace == typeof(S5001SceneData).Namespace)
            .Where(type => typeof(ILegacySceneData).IsAssignableFrom(type) ||
                           type.Name.StartsWith("S5001", StringComparison.Ordinal) ||
                           type.Name.StartsWith("S5032", StringComparison.Ordinal) ||
                           type.Name.StartsWith("S8018", StringComparison.Ordinal) ||
                           type.Name.StartsWith("S8001", StringComparison.Ordinal))
            .Where(type => type.Name.Contains("Data", StringComparison.Ordinal) ||
                           type.Name.Contains("Quote", StringComparison.Ordinal));

        foreach (var property in dtoTypes.SelectMany(type => type.GetProperties()))
        {
            Assert.NotEqual(typeof(object), property.PropertyType);
            Assert.DoesNotContain("Object", property.Name, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain("Method", property.Name, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain("Path", property.Name, StringComparison.OrdinalIgnoreCase);
        }
    }

    [Fact]
    public void Invalid_closed_enum_combinations_fail_before_emitting_unsafe_names()
    {
        Assert.Throws<LegacySceneDataException>(() => new S8001SceneMutationBuilder().Build(
            new S8001SceneData(S8001Market.Kosdaq,
            [new S8001SectorQuote(S8001Sector.Communications, S8001SectorSignal.Positive, 1m)])));
        Assert.Throws<LegacySceneDataException>(() => new S5001SceneMutationBuilder().Build(
            new S5001DomesticStockSceneData(LegacyDomesticEquityMarket.Kospi,
                S5001DomesticStockMode.Current, S5001NxtBadge.PreMarket, "A",
                ScenePriceDirection.Up, 1, 1, 1)));
        Assert.Throws<LegacySceneDataException>(() => new S5032SceneMutationBuilder().Build(
            new S5032IndexAndFuturesSceneData((LegacyMarketQuoteTarget)999,
                LegacyMarketQuoteTarget.Futures,
                new S5032PrimaryQuote("A", 1, 1, 1, ScenePriceDirection.Up),
                new S5032CompanionQuote("B", 1, 1, S5032CompanionSignal.Flat, 1, 1, 1))));
    }

    private static void AssertRelativeAssetsOnly(IReadOnlyList<PlayoutMutation> mutations)
    {
        var assets = mutations.OfType<PlayoutSetAssetValue>().Select(value => value.AssetPath).ToArray();
        Assert.NotEmpty(assets);
        Assert.All(assets, asset =>
        {
            Assert.False(Path.IsPathRooted(asset));
            Assert.DoesNotContain(":", asset, StringComparison.Ordinal);
            Assert.DoesNotContain("MBN_STOCK_N", asset, StringComparison.OrdinalIgnoreCase);
        });
    }

    private static string Describe(PlayoutMutation mutation) => mutation switch
    {
        PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
        PlayoutSetAssetValue asset => $"A:{asset.ObjectName}={asset.AssetPath}",
        PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
        PlayoutSetFaceColor color =>
            $"C:{color.ObjectName}={color.Red},{color.Green},{color.Blue},{color.Alpha}",
        _ => mutation.ToString() ?? mutation.GetType().Name
    };
}
