mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-29 01:00:41 +01:00
Compare commits
3 Commits
refactor/q
...
nv/delta-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecb5e419f8 | ||
|
|
c91a0dbaba | ||
|
|
60c927faa2 |
@@ -1,5 +1,8 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
@@ -389,11 +392,17 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
|
||||
export function toAttributeType(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
if (!metricType) {
|
||||
return '';
|
||||
}
|
||||
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
|
||||
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
|
||||
if (
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative
|
||||
) {
|
||||
return ATTRIBUTE_TYPES.GAUGE;
|
||||
}
|
||||
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
|
||||
|
||||
@@ -33,6 +33,7 @@ function AllAttributes({
|
||||
metricName,
|
||||
metricType,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
@@ -71,6 +72,7 @@ function AllAttributes({
|
||||
groupBy,
|
||||
limit,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -89,7 +91,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const goToMetricsExploreWithAppliedAttribute = useCallback(
|
||||
@@ -101,6 +103,7 @@ function AllAttributes({
|
||||
undefined,
|
||||
undefined,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -120,7 +123,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeValue]: value,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const handleKeyMenuItemClick = useCallback(
|
||||
|
||||
@@ -86,6 +86,7 @@ function MetricDetails({
|
||||
undefined,
|
||||
undefined,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -108,6 +109,7 @@ function MetricDetails({
|
||||
handleExplorerTabChange,
|
||||
metadata?.type,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,6 +198,7 @@ function MetricDetails({
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
isMonotonic={metadata?.isMonotonic}
|
||||
temporality={metadata?.temporality}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
|
||||
@@ -147,6 +147,44 @@ describe('MetricDetails utils', () => {
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('treats a cumulative non-monotonic Sum as a Gauge', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.cumulative,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
|
||||
});
|
||||
|
||||
it('treats a delta non-monotonic Sum as a Sum', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.delta,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.SUM,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('should create correct query for GAUGE metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface AllAttributesProps {
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
isMonotonic?: boolean;
|
||||
temporality?: MetrictypesTemporalityDTO;
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,16 @@ export function getMetricDetailsQuery(
|
||||
groupBy?: string,
|
||||
limit?: number,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): Query {
|
||||
let timeAggregation;
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
|
||||
const isNonMonotonicSum =
|
||||
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative;
|
||||
|
||||
switch (metricType) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
@@ -131,7 +135,7 @@ export function getMetricDetailsQuery(
|
||||
break;
|
||||
}
|
||||
|
||||
const attributeType = toAttributeType(metricType, isMonotonic);
|
||||
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
|
||||
|
||||
return {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
|
||||
@@ -393,12 +393,13 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic Sum metric is treated as Gauge', () => {
|
||||
it('cumulative non-monotonic Sum metric is treated as Gauge', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'active_connections',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: 'cumulative' as never,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -427,6 +428,36 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('delta non-monotonic Sum metric is treated as Sum', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'queue_depth_delta',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: 'delta' as never,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'queue_depth_delta' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toStrictEqual([
|
||||
'Rate',
|
||||
'Increase',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toStrictEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Histogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
|
||||
@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
|
||||
function getAttributeType(
|
||||
metric: MetricsexplorertypesListMetricDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
return toAttributeType(metric.type, metric.isMonotonic);
|
||||
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
|
||||
}
|
||||
|
||||
function createAutocompleteData(
|
||||
|
||||
@@ -825,6 +825,7 @@ func (m *module) validateAndNormalizeMetricType(req *metricsexplorertypes.Update
|
||||
if req.Temporality != metrictypes.Delta && req.Temporality != metrictypes.Cumulative {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid value for temporality")
|
||||
}
|
||||
// Cumulative gate already present here; metadata.go was aligned to match.
|
||||
// Special case: if Sum is not monotonic and cumulative, convert to Gauge
|
||||
if !req.IsMonotonic && req.Temporality == metrictypes.Cumulative {
|
||||
req.Type = metrictypes.GaugeType
|
||||
|
||||
@@ -3016,6 +3016,7 @@ func (r *ClickHouseReader) GetMetricAggregateAttributes(ctx context.Context, org
|
||||
temporality := string(metadata.Temporality)
|
||||
isMonotonic := metadata.IsMonotonic
|
||||
|
||||
// Cumulative gate already present here; metadata.go was aligned to match.
|
||||
// Non-monotonic cumulative sums are treated as gauges
|
||||
if typ == "Sum" && !isMonotonic && temporality == string(v3.Cumulative) {
|
||||
typ = "Gauge"
|
||||
@@ -3074,6 +3075,7 @@ func (r *ClickHouseReader) GetMeterAggregateAttributes(ctx context.Context, orgI
|
||||
return nil, fmt.Errorf("error while scanning meter name: %s", err.Error())
|
||||
}
|
||||
|
||||
// Cumulative gate already present here; metadata.go was aligned to match.
|
||||
// Non-monotonic cumulative sums are treated as gauges
|
||||
if typ == "Sum" && !isMonotonic && temporality == string(v3.Cumulative) {
|
||||
typ = "Gauge"
|
||||
|
||||
@@ -2323,6 +2323,15 @@ func unionTemporalities(existing, additional []metrictypes.Temporality) []metric
|
||||
return existing
|
||||
}
|
||||
|
||||
// resolveMetricType applies the non-monotonic-cumulative-sum-as-gauge rule.
|
||||
// Monotonicity is only meaningful for cumulative sums; delta sums always stay Sum.
|
||||
func resolveMetricType(metricType metrictypes.Type, isMonotonic bool, temporality metrictypes.Temporality) metrictypes.Type {
|
||||
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
|
||||
return metrictypes.GaugeType
|
||||
}
|
||||
return metricType
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, tableName string, adjustedStartTs, adjustedEndTs uint64, metricNames []string, extraConds ...string) (map[string][]metrictypes.Temporality, map[string]metrictypes.Type, error) {
|
||||
temporalities := make(map[string][]metrictypes.Temporality)
|
||||
types := make(map[string]metrictypes.Type)
|
||||
@@ -2359,9 +2368,7 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
|
||||
if temporality != metrictypes.Unknown {
|
||||
temporalities[metricName] = append(temporalities[metricName], temporality)
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
metricType = resolveMetricType(metricType, isMonotonic, temporality)
|
||||
types[metricName] = metricType
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -2412,9 +2419,7 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
|
||||
if err := rows.Scan(&metricName, &temporality, &metricType, &isMonotonic); err != nil {
|
||||
return nil, nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to scan temporality result")
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
metricType = resolveMetricType(metricType, isMonotonic, temporality)
|
||||
temporalities[metricName] = temporality
|
||||
types[metricName] = metricType
|
||||
}
|
||||
|
||||
64
pkg/telemetrymetadata/metric_type_test.go
Normal file
64
pkg/telemetrymetadata/metric_type_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package telemetrymetadata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveMetricType(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
inputMetricType metrictypes.Type
|
||||
inputIsMonotonic bool
|
||||
inputTemporality metrictypes.Temporality
|
||||
expectedMetricType metrictypes.Type
|
||||
}{
|
||||
{
|
||||
description: "delta non-monotonic sum stays a sum",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: false,
|
||||
inputTemporality: metrictypes.Delta,
|
||||
expectedMetricType: metrictypes.SumType,
|
||||
},
|
||||
{
|
||||
description: "cumulative non-monotonic sum becomes a gauge",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: false,
|
||||
inputTemporality: metrictypes.Cumulative,
|
||||
expectedMetricType: metrictypes.GaugeType,
|
||||
},
|
||||
{
|
||||
description: "cumulative monotonic sum stays a sum",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: true,
|
||||
inputTemporality: metrictypes.Cumulative,
|
||||
expectedMetricType: metrictypes.SumType,
|
||||
},
|
||||
{
|
||||
description: "delta monotonic sum stays a sum",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: true,
|
||||
inputTemporality: metrictypes.Delta,
|
||||
expectedMetricType: metrictypes.SumType,
|
||||
},
|
||||
{
|
||||
description: "gauge is unaffected by monotonicity",
|
||||
inputMetricType: metrictypes.GaugeType,
|
||||
inputIsMonotonic: false,
|
||||
inputTemporality: metrictypes.Unspecified,
|
||||
expectedMetricType: metrictypes.GaugeType,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
testCase.expectedMetricType,
|
||||
resolveMetricType(testCase.inputMetricType, testCase.inputIsMonotonic, testCase.inputTemporality),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import (
|
||||
MetricsReducedSampleLast60s,
|
||||
MetricsReducedSampleSum60s,
|
||||
MetricsReducedTimeSeries,
|
||||
)
|
||||
from fixtures.querier import aligned_epoch, query_metric_values
|
||||
|
||||
|
||||
# A delta, non-monotonic Sum must be treated as a Sum (read from the sum_60s
|
||||
# reduced table), not downgraded to a Gauge (last_60s). The type is resolved
|
||||
# server-side from the reduced time series, so the query carries no explicit
|
||||
# type. sum_60s holds the true counter values; last_60s holds decoy values that
|
||||
# a gauge misclassification would surface instead.
|
||||
def test_reduced_delta_nonmonotonic_sum_is_treated_as_sum(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_reduced_metrics: Callable[..., None],
|
||||
) -> None:
|
||||
metric_name = "test_reduction_delta_nonmonotonic_sum"
|
||||
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
|
||||
|
||||
time_series = MetricsReducedTimeSeries(
|
||||
metric_name=metric_name,
|
||||
kept_labels={"service": "a"},
|
||||
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
|
||||
temporality="Delta",
|
||||
type_="Sum",
|
||||
is_monotonic=False,
|
||||
)
|
||||
assert time_series.temporality == "Delta"
|
||||
|
||||
insert_reduced_metrics(
|
||||
[time_series],
|
||||
sum_samples=[
|
||||
MetricsReducedSampleSum60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=time_series.fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
sum_value=30.0,
|
||||
count_series=1,
|
||||
count_samples=1,
|
||||
temporality="Delta",
|
||||
)
|
||||
for minute in range(20)
|
||||
],
|
||||
last_samples=[
|
||||
MetricsReducedSampleLast60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=time_series.fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
sum_last=999.0,
|
||||
min_value=999.0,
|
||||
max_value=999.0,
|
||||
sum_values=999.0,
|
||||
count_series=1,
|
||||
count_samples=1,
|
||||
temporality="Delta",
|
||||
)
|
||||
for minute in range(20)
|
||||
],
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
values = query_metric_values(
|
||||
signoz,
|
||||
token,
|
||||
metric_name,
|
||||
base_epoch,
|
||||
base_epoch + 20 * 60,
|
||||
"increase",
|
||||
"sum",
|
||||
step_interval=300,
|
||||
)
|
||||
|
||||
# 5 one-minute buckets x 30.0 per 300s step, read from sum_60s.
|
||||
# A gauge misclassification would read last_60s and never produce 150.0.
|
||||
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
|
||||
assert [v["value"] for v in values] == [150.0] * 4
|
||||
@@ -0,0 +1,62 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import build_builder_query, get_series_values, make_query_request
|
||||
|
||||
|
||||
# A delta, non-monotonic Sum queried without an explicit type must be treated as
|
||||
# a Sum: the server resolves the type and the delta rate/increase values must be
|
||||
# correct. Non-reduced delta values are temporality-driven, so this is a
|
||||
# forward-looking guard against a future change routing the delta path by type
|
||||
# (e.g. gauge -> avg/last).
|
||||
@pytest.mark.parametrize(
|
||||
"time_aggregation, expected",
|
||||
[
|
||||
("rate", 1.0), # 60 per 60s bucket / 60s
|
||||
("increase", 60.0),
|
||||
],
|
||||
)
|
||||
def test_delta_nonmonotonic_sum_is_treated_as_sum(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
time_aggregation: str,
|
||||
expected: float,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=6)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = f"test_delta_nonmonotonic_sum_{time_aggregation}"
|
||||
|
||||
metrics = [
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "a"},
|
||||
timestamp=now - timedelta(minutes=minute),
|
||||
value=60.0,
|
||||
temporality="Delta",
|
||||
type_="Sum",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute in range(1, 6)
|
||||
]
|
||||
insert_metrics(metrics)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
# No type and no temporality: the server resolves both from the seeded series.
|
||||
query = build_builder_query("A", metric_name, time_aggregation, "sum")
|
||||
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
values = get_series_values(response.json(), "A")
|
||||
assert len(values) == 5, f"Expected 5 buckets, got {values}"
|
||||
for value in values:
|
||||
assert value["value"] == expected, f"Expected {expected}, got {value['value']}"
|
||||
Reference in New Issue
Block a user