tests/tst_cost_response.qml

import QtQuick
import QtTest
import "../contents/ui/CostResponse.js" as CostResponse
import "../contents/ui/CostPresentation.js" as CostPresentation
import "../contents/ui/ProviderNormalizer.js" as Normalizer
import "../contents/ui/SafeText.js" as SafeText

TestCase {
    name: "CostResponse"

    function parse(payload, days) {
        return CostResponse.response(JSON.stringify(payload), "", days === undefined ? 30 : days, 0);
    }
    function test_structuredHistoryDatesPreserveHealthyProviders_data() {
        var cases = [];
        var fields = ["date", "day", "dayKey"];
        var values = [{toString: null}, {toString: "2020-01-01", valueOf: null}, ["2020-01-01"]];
        for (var i = 0; i < fields.length; i++) {
            for (var j = 0; j < values.length; j++) {
                cases.push({tag: fields[i] + "-" + j, field: fields[i], value: values[j]});
            }
        }
        return cases;
    }
    function test_structuredHistoryDatesPreserveHealthyProviders(data) {
        var invalid = {};
        invalid[data.field] = data.value;
        var valid = {date: "2026-10-01", totalCost: 13, totalTokens: 130};
        var sibling = {provider: "claude", sessionCostUSD: 7, sessionTokens: 70};
        var baseline = parse([{provider: "codex", reportingPeriod: "all",
            updatedAt: "2026-10-13", daily: [valid]}, sibling]);
        var actual = parse([{provider: "codex", reportingPeriod: "all",
            updatedAt: "2026-10-13", daily: [valid, invalid]}, sibling]);
        compare(actual.outcome, "success");
        compare(actual.costs.claude, baseline.costs.claude);
        compare(actual.costs.codex.historyDays, 13);
        compare(actual.costs.codex.labelDays, 13);
        compare(actual.costs.codex.models, baseline.costs.codex.models);
        compare(CostPresentation.averageDailyValue(actual.costs.codex.daily, false),
            CostPresentation.averageDailyValue(baseline.costs.codex.daily, false));
    }

    function test_duplicateDateFallbackKeepsDailyAndModelCostsInsidePeriod() {
        var result = parse([{provider: "codex", updatedAt: "2026-08-29", daily: [
            {date: "2026-08-28", totalCost: 1, modelBreakdowns: [{modelName: "synthetic", cost: 1}]},
            {date: "2026-08-28", totalCost: 2, modelBreakdowns: [{modelName: "synthetic", cost: 2}]},
            {date: "2026-08-30", totalCost: 100, modelBreakdowns: [{modelName: "synthetic", cost: 100}]}
        ]}], 2);
        compare(result.outcome, "success");
        var snapshot = result.costs.codex;
        compare(snapshot.daily.length, 2);
        compare(snapshot.daily[0].label, "2026-08-28");
        compare(snapshot.daily[1].label, "2026-08-28");
        compare(snapshot.daily[0].cost + snapshot.daily[1].cost, 3);
        compare(snapshot.models.length, 1);
        compare(snapshot.models[0].cost, 3);
    }

    function test_failuresNeverSupplyReplacementData_data() {
        return [
            {
                tag: "missing",
                text: undefined,
                outcome: "empty"
            },
            {
                tag: "empty",
                text: " ",
                outcome: "empty"
            },
            {
                tag: "broken",
                text: "{",
                outcome: "invalidJson"
            },
            {
                tag: "null",
                text: "null",
                outcome: "unsupported"
            },
            {
                tag: "object",
                text: "{}",
                outcome: "unsupported"
            },
            {
                tag: "wrong-provider",
                text: '[{"provider":{},"error":{}}]',
                outcome: "unsupported"
            },
            {
                tag: "too-large",
                text: " ".repeat(SafeText.maximumCliJsonLength + 1),
                outcome: "tooLarge"
            }
        ];
    }
    function test_failuresNeverSupplyReplacementData(data) {
        var result = CostResponse.response(data.text, "synthetic failure", 7, 0);
        compare(result.outcome, data.outcome);
        compare(result.costs, undefined);
        verify(result.message.length <= SafeText.maximumCliMessageLength);
    }
    function test_emptySnapshotIsSuccess() {
        compare(parse([]), {
            outcome: "success",
            costs: {},
            failedProviders: [],
            message: "",
            argumentsRejected: false
        });
    }
    function test_errorMessages_data() {
        return [
            {
                tag: "object",
                message: {}
            },
            {
                tag: "array",
                message: ["bad"]
            },
            {
                tag: "shadowed-conversion",
                message: {
                    toString: null
                }
            },
            {
                tag: "null",
                message: null
            },
            {
                tag: "empty",
                message: ""
            }
        ];
    }
    function test_errorMessages(data) {
        var result = parse([
            {
                provider: "codex",
                error: {
                    message: data.message
                }
            },
            {
                provider: "claude",
                totals: {
                    totalCost: 4
                }
            }
        ]);
        compare(result.outcome, "partial");
        compare(result.failedProviders, ["codex"]);
        compare(result.message, "");
        compare(result.costs.codex, undefined);
        compare(result.costs.claude.totals.cost, 4);
    }
    function test_errorsWithoutMessagesStillFail_data() {
        return [
            {
                tag: "object",
                error: {}
            },
            {
                tag: "string",
                error: "failed"
            }
        ];
    }
    function test_errorsWithoutMessagesStillFail(data) {
        var result = parse([
            {
                provider: "codex",
                error: data.error,
                sessionCostUSD: 99
            }
        ]);
        compare(result.outcome, "partial");
        compare(result.failedProviders, ["codex"]);
        compare(result.costs, {});
    }
    function test_falsyErrorFlagsKeepHealthyCosts_data() {
        return [
            {
                tag: "false",
                error: false
            },
            {
                tag: "zero",
                error: 0
            },
            {
                tag: "empty",
                error: ""
            },
            {
                tag: "blank",
                error: "   "
            }
        ];
    }
    function test_falsyErrorFlagsKeepHealthyCosts(data) {
        var result = parse([
            {
                provider: "codex",
                totals: {
                    totalCost: 5,
                    totalTokens: 10
                },
                error: data.error
            }
        ]);
        compare(result.outcome, "success");
        compare(result.failedProviders, []);
        compare(result.costs.codex.totals.cost, 5);
        compare(result.costs.codex.totals.tokens, 10);
    }
    function test_nullErrorIsHealthyAndMessageIsBounded() {
        var result = parse([
            {
                provider: "codex",
                error: null,
                totals: {
                    totalCost: 4
                }
            },
            {
                provider: "claude",
                error: {
                    message: "x".repeat(5000)
                }
            }
        ]);
        compare(result.costs.codex.totals.cost, 4);
        verify(result.message.length <= SafeText.maximumCliMessageLength);
        compare(result.failedProviders, ["claude"]);
    }
    function test_rangeNormalization_data() {
        return [
            {
                tag: "captured",
                emitted: undefined,
                requested: 7,
                expected: 7,
                label: 7
            },
            {
                tag: "emitted",
                emitted: 90,
                requested: 7,
                expected: 90,
                label: 90
            },
            {
                tag: "bounded",
                emitted: 999,
                requested: 7,
                expected: 365,
                label: 999
            },
            {
                tag: "bad-emitted",
                emitted: {},
                requested: 7,
                expected: 7,
                label: 7
            },
            {
                tag: "invalid",
                emitted: -1,
                requested: null,
                expected: 30,
                label: 30
            }
        ];
    }
    function test_rangeNormalization(data) {
        var cost = parse({
            provider: "codex",
            historyDays: data.emitted
        }, data.requested).costs.codex;
        compare(cost.historyDays, data.expected);
        compare(cost.labelDays, data.label);
        compare(cost.historyLabel, null);
        compare(cost.historyCoverageEstablished, true);
    }
    function test_rollingPeriodLabelIsLeftToTheLocalizedWidgetLabel_data() {
        // CLI 0.67.0 labels every record in English, including the ranges
        // the widget requests and titles itself in the locale.
        return [
            { tag: "rolling", period: "rolling:30", label: "Last 30 days", expected: null },
            { tag: "today", period: "rolling:1", label: "Today", expected: null },
            { tag: "month-to-date", period: "month-to-date", label: "Month to date", expected: null },
            { tag: "all", period: "all", label: "All", expected: null },
            { tag: "unknown period", period: "quarter", label: "Quarter", expected: "Quarter" },
            { tag: "malformed period", period: "rolling:", label: "Custom", expected: "Custom" },
            { tag: "non-string period", period: 30, label: "Custom", expected: "Custom" },
            { tag: "no period", period: undefined, label: "Custom", expected: "Custom" }
        ];
    }
    function test_rollingPeriodLabelIsLeftToTheLocalizedWidgetLabel(data) {
        var cost = parse({
            provider: "codex",
            historyDays: 30,
            historyLabel: data.label,
            reportingPeriod: data.period
        }).costs.codex;
        compare(cost.historyLabel, data.expected);
        compare(cost.labelDays, 30);
    }
    function test_malformedPeriodPreservesLegacyLabelsAndHealthyProviders_data() {
        return [
            {tag: "shadowed-conversion", period: {toString: null}},
            {tag: "non-callable-conversion", period: {toString: 0, valueOf: null}},
            {tag: "object", period: {}},
            {tag: "rolling-array", period: ["rolling:30"]},
            {tag: "calendar-array", period: ["all"]},
            {tag: "nested-array", period: [["rolling:30"]]},
            {tag: "boolean", period: true},
            {tag: "null", period: null}
        ];
    }
    function test_malformedPeriodPreservesLegacyLabelsAndHealthyProviders(data) {
        var payload = [{provider: "codex", historyLabel: "Legacy range", historyDays: 7,
            updatedAt: "2026-10-13", sessionCostUSD: 3, sessionTokens: 30,
            daily: [{date: "2026-10-13", totalCost: 3, totalTokens: 30}]},
            {provider: "claude", sessionCostUSD: 7}];
        var baseline = parse(payload, 7);
        payload[0].reportingPeriod = data.period;
        var actual = parse(payload, 7);
        compare(actual.outcome, "success");
        compare(actual.costs.codex.period, "");
        compare(actual.costs.codex.historyLabel, "Legacy range");
        compare(actual.costs, baseline.costs);
    }

    function test_reportingPeriodIsKeptOnlyForCalendarPeriods_data() {
        return [
            { tag: "month-to-date", period: "month-to-date", expected: "month-to-date" },
            { tag: "all", period: "all", expected: "all" },
            { tag: "rolling", period: "rolling:30", expected: "" },
            { tag: "absent", period: undefined, expected: "" },
            { tag: "unknown", period: "quarter", expected: "" },
            { tag: "non-string", period: 7, expected: "" }
        ];
    }
    function test_reportingPeriodIsKeptOnlyForCalendarPeriods(data) {
        var cost = parse({ provider: "codex", historyDays: 26, reportingPeriod: data.period }).costs.codex;
        compare(cost.period, data.expected);
    }
    function test_allHistorySpansTheRecordedDaysWithinTheChartBound_data() {
        // `--period all` reports the days since year 1. The window runs from
        // the oldest recorded day to the scan day instead, within 365 days.
        return [
            { tag: "recorded span", first: "2026-03-02", last: "2026-09-19",
                updatedAt: "2026-09-19T12:00:00Z", expected: 202 },
            { tag: "scan after last day", first: "2026-09-10", last: "2026-09-12",
                updatedAt: "2026-09-19T12:00:00Z", expected: 10 },
            { tag: "no scan time", first: "2026-09-10", last: "2026-09-12",
                updatedAt: undefined, expected: 3 },
            { tag: "longer than the bound", first: "2024-01-01", last: "2026-09-19",
                updatedAt: "2026-09-19T12:00:00Z", expected: 365 },
            { tag: "no recorded day", first: null, last: null,
                updatedAt: "2026-09-19T12:00:00Z", expected: 365 }
        ];
    }
    function test_allHistorySpansTheRecordedDaysWithinTheChartBound(data) {
        var daily = [];
        if (data.first) {
            daily.push({ date: "not a day", totalCost: 1 });
            daily.push({ date: data.last, totalCost: 1 });
            daily.push({ date: data.first, totalCost: 1 });
        }
        var cost = parse({ provider: "codex", historyDays: 739887, reportingPeriod: "all",
            updatedAt: data.updatedAt, daily: daily }).costs.codex;
        compare(cost.period, "all");
        compare(cost.historyDays, data.expected);
    }
    function test_allHistoryScanStaysWithinTheInputBound() {
        // Only the newest scan budget of records sizes the window, like the
        // daily normalization, so an oversized payload cannot stall the UI.
        var daily = [{ date: "2020-01-01", totalCost: 1 }];
        for (var i = 0; i < Normalizer.maximumCostHistoryScanItems; i++)
            daily.push({ date: "2026-09-19", totalCost: 1 });
        var cost = parse({ provider: "codex", historyDays: 739887, reportingPeriod: "all",
            updatedAt: "2026-09-19T12:00:00Z", daily: daily }).costs.codex;
        compare(cost.historyDays, 1);
    }
    function test_futureDatesDoNotExpandAllHistory_data() {
        return [
            {tag: "next day", futureDate: "2026-10-14", futureFirst: false},
            {tag: "distant future", futureDate: "2035-01-01", futureFirst: false},
            {tag: "future first", futureDate: "2026-10-14", futureFirst: true}
        ];
    }
    function test_futureDatesDoNotExpandAllHistory(data) {
        var payload = {provider: "codex", reportingPeriod: "all", historyDays: 739911,
            updatedAt: "2026-10-13", daily: [
                {date: "2026-10-01", totalCost: 13, totalTokens: 130,
                    modelBreakdowns: [{modelName: "observed", cost: 13, totalTokens: 130}]}
            ]};
        var baseline = parse(payload).costs.codex;
        var future = {date: data.futureDate, totalCost: 999, totalTokens: 9990,
            modelBreakdowns: [{modelName: "future", cost: 999, totalTokens: 9990}]};
        if (data.futureFirst)
            payload.daily.unshift(future);
        else
            payload.daily.push(future);
        var actual = parse(payload).costs.codex;
        compare(actual.historyDays, 13);
        compare(actual.labelDays, 13);
        compare(actual.daily, baseline.daily);
        compare(actual.models, baseline.models);
        compare(actual.tokenRanking, baseline.tokenRanking);
        compare(CostPresentation.averageDailyValue(actual.daily, false).value, 1);
        compare(CostPresentation.averageDailyValue(actual.daily, true).value, 10);
    }
    function test_allFutureDatesKeepTheEmptyHistoryFallback() {
        var payload = {provider: "codex", reportingPeriod: "all", historyDays: 739911,
            updatedAt: "2026-10-13", daily: []};
        var baseline = parse(payload).costs.codex;
        payload.daily = [{date: "2026-10-14", totalCost: 13, totalTokens: 130}];
        compare(parse(payload).costs.codex, baseline);
    }
    function test_allHistoryAggregatesCoverMoreThanTheChartedYear() {
        // 400 recorded days: the chart keeps the newest 365, while the model
        // totals and the daily average cover every recorded day.
        var daily = [];
        var first = Date.UTC(2025, 7, 16);
        for (var i = 0; i < 400; i++) {
            var day = new Date(first + i * 86400000).toISOString().slice(0, 10);
            daily.push({ date: day, totalCost: i < 35 ? 3 : 1, totalTokens: 10,
                modelBreakdowns: [{ modelName: "example", cost: i < 35 ? 3 : 1, totalTokens: 10 }] });
        }
        var cost = parse({ provider: "codex", historyDays: 739887, reportingPeriod: "all",
            updatedAt: "2026-09-19T12:00:00Z", daily: daily }).costs.codex;
        compare(cost.historyDays, 365);
        compare(cost.daily.length, 365);
        compare(cost.models.length, 1);
        compare(cost.models[0].cost, 35 * 3 + 365);
        compare(cost.models[0].tokens, 4000);
        compare(cost.averageDaily.cost.value, (35 * 3 + 365) / 400);
        compare(cost.averageDaily.tokens.value, 10);
        daily.push({date: "2026-09-20", totalCost: 999, totalTokens: 9990,
            modelBreakdowns: [{modelName: "future", cost: 999, totalTokens: 9990}]});
        var withFuture = parse({provider: "codex", historyDays: 739887, reportingPeriod: "all",
            updatedAt: "2026-09-19T12:00:00Z", daily: daily}).costs.codex;
        compare(withFuture.labelDays, 400);
        compare(withFuture.averageDaily, cost.averageDaily);
        compare(withFuture.daily, cost.daily);
        compare(withFuture.models, cost.models);
        var rolling = parse({ provider: "codex", historyDays: 30, reportingPeriod: "rolling:30",
            updatedAt: "2026-09-19T12:00:00Z", daily: daily }).costs.codex;
        compare(rolling.averageDaily, null);
        compare(rolling.models[0].cost, 30);
    }
    function test_rejectedArgumentsAreReportedApartFromProviderFailures() {
        // CLI 0.66.0 rejects `--period` with an args error record.
        var rejected = parse([{ provider: "cli", source: "cli",
            error: { kind: "args", code: 1, message: "Unknown option --period" } }]);
        compare(rejected.outcome, "commandFailed");
        compare(rejected.costs, undefined);
        compare(rejected.argumentsRejected, true);
        var failed = parse([{ provider: "codex", error: { kind: "provider", message: "failed" } }]);
        compare(failed.argumentsRejected, false);
        compare(parse([{ provider: "codex", historyDays: 30 }]).argumentsRejected, false);
    }
    function test_snapshotKeepsOnlyBoundedNormalizedFields() {
        var cost = parse({
            provider: "codex",
            historyLabel: "x".repeat(1000),
            historyDays: 7,
            currencyCode: "USD",
            historyCoverageIsEstablished: false,
            sessionCostUSD: -2,
            sessionTokens: -3,
            totals: {
                totalCost: 4,
                totalTokens: 123
            },
            projects: [
                {
                    name: "example",
                    path: "/private/path",
                    totalCost: 4
                }
            ],
            updatedAt: "2026-09-12T10:00:00Z",
            daily: [
                {
                    date: "2026-09-12",
                    totalCost: 4,
                    totalTokens: 123,
                    modelBreakdowns: [
                        {
                            modelName: "Example",
                            cost: 4,
                            totalTokens: 123
                        }
                    ]
                }
            ],
            unknownSecret: "not retained"
        }).costs.codex;
        compare(cost.historyLabel.length, 120);
        compare(cost.historyCoverageEstablished, false);
        compare(cost.sessionCost, -2);
        compare(cost.sessionTokens, -3);
        compare(cost.today.cost, 0);
        compare(cost.today.tokens, 0);
        compare(cost.totals.cost, 4);
        compare(cost.models.length, 1);
        compare(cost.models[0].label, "Example");
        compare(cost.daily.length, 7);
        compare(cost.unknownSecret, undefined);
        verify(JSON.stringify(cost).indexOf("/private/path") < 0);
        compare(cost.title, undefined);
    }
    function test_unavailableCostsAndTrustRemainIndependent() {
        var cost = parse({
            provider: "antigravity",
            sessionCostUSD: 0,
            sessionTokens: 50,
            totals: {
                totalCost: 0,
                totalTokens: 100
            }
        }).costs.antigravity;
        compare(cost.sessionCost, null);
        compare(cost.today.cost, null);
        compare(cost.totals.cost, null);
        compare(cost.totals.tokens, 100);
        var estimated = parse({
            provider: "codex",
            provenance: "listPriceEstimate",
            totals: {
                totalCost: 5
            },
            sessionCostUSD: 2
        }).costs.codex;
        compare(estimated.valueMode, "estimated");
        compare(estimated.sessionCost, 2);
    }
    // Snapshot projects are normalized display rows, never raw CLI records.
    function test_snapshotExposesNormalizedProjects() {
        var cost = parse({
            provider: "codex",
            projects: [
                {
                    name: "example",
                    path: "/private/path",
                    totalCost: 4
                }
            ]
        }).costs.codex;
        compare(cost.projects.rows.length, 1);
        compare(cost.projects.rows[0].label, "example");
        compare(cost.projects.rows[0].cost, 4);
        compare(cost.projects.truncated, false);
        verify(JSON.stringify(cost.projects).indexOf("/private/path") < 0);
    }
    // Snapshot model truncation follows the normalized model summary.
    function test_snapshotPropagatesModelTruncation() {
        var breakdowns = [];
        for (var i = 0; i < 7; i++) {
            breakdowns.push({
                modelName: "model-" + i,
                cost: i + 1,
                totalTokens: 10
            });
        }
        var few = parse({
            provider: "codex",
            daily: [
                {
                    date: "2026-09-12",
                    totalCost: 4,
                    totalTokens: 123,
                    modelBreakdowns: [
                        {
                            modelName: "Example",
                            cost: 4,
                            totalTokens: 123
                        }
                    ]
                }
            ]
        }).costs.codex;
        compare(few.modelsTruncated, false);
        var many = parse({
            provider: "codex",
            daily: [
                {
                    date: "2026-09-12",
                    modelBreakdowns: breakdowns
                }
            ]
        }).costs.codex;
        compare(many.models.length, 6);
        compare(many.modelsTruncated, true);
    }
    // Official 0.60.5 output counts requests the CLI excluded per day and per
    // model breakdown. A model's row sums its days, including a day whose
    // breakdown has no measured amounts; malformed counts mean none reported.
    function test_rowsCarryExcludedRequestCounts() {
        var codex = parse({
            provider: "codex",
            daily: [
                {
                    date: "2026-09-11",
                    totalCost: 1,
                    totalTokens: 10,
                    incompleteRequestCount: 2,
                    modelBreakdowns: [
                        {modelName: "Measured", cost: 1, totalTokens: 10, incompleteRequestCount: 2},
                        {modelName: "OnlyIncomplete", incompleteRequestCount: 7},
                        {modelName: "Clean", cost: 0.5, totalTokens: 4, incompleteRequestCount: -3}
                    ]
                },
                {
                    date: "2026-09-12",
                    totalCost: 2,
                    totalTokens: 20,
                    incompleteRequestCount: "4",
                    modelBreakdowns: [
                        {modelName: "Measured", cost: 2, totalTokens: 20, incompleteRequestCount: 3},
                        {modelName: "Clean", cost: 0.5, totalTokens: 4, incompleteRequestCount: 1.5},
                        {modelName: "Saturated", cost: 0.1, totalTokens: 1, incompleteRequestCount: 1000000000},
                        {modelName: "Saturated", cost: 0.1, totalTokens: 1, incompleteRequestCount: 1000000000}
                    ]
                }
            ]
        }).costs.codex;
        compare(codex.daily.map(function(day) { return day.incompleteRequests; }), [2, 0]);
        var byLabel = ({});
        codex.models.forEach(function(model) { byLabel[model.label] = model.incompleteRequests; });
        compare(byLabel, {Measured: 5, Clean: 0, Saturated: 1000000000});
        compare(codex.daily[0].models.map(function(model) { return model.label + ":" + model.incompleteRequests; }),
            ["Measured:2", "Clean:0"]);
    }
    function test_partialMergeRetainsOnlyExplicitFailures() {
        var old = parse([
            {
                provider: "codex",
                totals: {
                    totalCost: 1
                }
            },
            {
                provider: "claude",
                totals: {
                    totalCost: 2
                }
            },
            {
                provider: "gemini",
                totals: {
                    totalCost: 3
                }
            }
        ]).costs;
        var result = parse([
            {
                provider: "codex",
                error: {}
            },
            {
                provider: "claude",
                totals: {
                    totalCost: 4
                }
            }
        ]);
        var merged = Normalizer.mergeCostSnapshotsAfterPartialFailure(old, result.costs, result.failedProviders);
        compare(merged.codex, old.codex);
        compare(merged.claude.totals.cost, 4);
        compare(merged.gemini, undefined);
    }
}