mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
fix(model-pricing): keep saved tier rules as rules when another rule is edited
A saved rule keyed like a usage field (keys ["input"]) was moved into the tier condition or renamed to usage.input on any edit, which blocked the save. Only a newly added usage threshold may now take the free condition slot.
This commit is contained in:
@@ -17,6 +17,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { DimensionCondition } from "@/services/downtimes";
|
||||
import {
|
||||
appendNode,
|
||||
insertNode,
|
||||
makeCondition,
|
||||
removeNode,
|
||||
replaceNode,
|
||||
@@ -864,6 +865,44 @@ describe("model pricing codec", () => {
|
||||
expect(out.rules?.[3]).toEqual({ keys: ["usage.input"], op: "lte", value: 10 });
|
||||
});
|
||||
|
||||
describe("a tier with no condition and a rule keyed like a usage field", () => {
|
||||
const tier = {
|
||||
rules: [
|
||||
{ keys: ["input"], op: "gt", value: 5 },
|
||||
{ keys: ["tier"], op: "in", values: ["x"] },
|
||||
],
|
||||
};
|
||||
|
||||
it("keeps the untouched rule as it was when another rule is edited", () => {
|
||||
const tree = codec.parse(clone(tier), usage);
|
||||
const out = codec.serialize(edit(tree, 1, { values: ["x", "y"] }), usage);
|
||||
expect(out.condition).toBeNull();
|
||||
expect(out.rules).toEqual([tier.rules[0], { keys: ["tier"], op: "in", values: ["x", "y"] }]);
|
||||
});
|
||||
|
||||
it("keeps an edited rule a rule with its original keys", () => {
|
||||
const tree = codec.parse(clone(tier), usage);
|
||||
const out = codec.serialize(edit(tree, 0, { values: ["7"] }), usage);
|
||||
expect(out.condition).toBeNull();
|
||||
expect(out.rules).toEqual([{ keys: ["input"], op: "gt", value: 7 }, tier.rules[1]]);
|
||||
});
|
||||
|
||||
it("still lets a newly added usage threshold become the tier condition", () => {
|
||||
const tree = codec.parse(clone(tier), usage);
|
||||
const out = codec.serialize(appendNode(tree, makeCondition("input", "lte", ["10"])), usage);
|
||||
expect(out.condition).toEqual({ usage_key: "input", operator: "lte", value: 10 });
|
||||
expect(out.rules).toEqual(tier.rules);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the saved condition when a usage threshold is inserted before it", () => {
|
||||
const tree = codec.parse(clone(saved), usage);
|
||||
const inserted = insertNode(tree, makeCondition("input", "lte", ["10"]), null, 0);
|
||||
const out = codec.serialize(inserted, usage);
|
||||
expect(out.condition).toEqual(saved.condition);
|
||||
expect(out.rules?.[0]).toEqual({ keys: ["usage.input"], op: "lte", value: 10 });
|
||||
});
|
||||
|
||||
it("leaves an unfinished threshold rule without a value instead of writing 0 or null", () => {
|
||||
let tree = codec.parse(clone(saved), usage);
|
||||
tree = appendNode(tree, makeCondition("service_tier", "gt", []));
|
||||
|
||||
@@ -142,11 +142,21 @@ function parseRule(rule: TierRule): FilterNode {
|
||||
return stamp(node, rule);
|
||||
}
|
||||
|
||||
const savedRuleOf = (node: FilterNode): TierRule | undefined => {
|
||||
const raw = rawOf<TierRule>(node);
|
||||
return node.id !== TIER_CONDITION_ID && Array.isArray(raw?.keys) ? raw : undefined;
|
||||
};
|
||||
|
||||
function writeRule(node: FilterCondition, usageKey: boolean): TierRule {
|
||||
const raw = rawOf<TierRule>(node);
|
||||
if (isPristine(node) && raw) return raw;
|
||||
const saved = savedRuleOf(node);
|
||||
const op = RULE_WIRE[node.operator] ?? node.operator;
|
||||
const keys = keysOf(node.field).map((key) => (usageKey ? `usage.${key}` : key));
|
||||
// A saved rule keeps its keys while its field is unchanged; `input` there is an attribute, not usage.
|
||||
const keys =
|
||||
saved && node.field === saved.keys.join(", ")
|
||||
? [...saved.keys]
|
||||
: keysOf(node.field).map((key) => (usageKey ? `usage.${key}` : key));
|
||||
const out: TierRule = { keys, op };
|
||||
const threshold = (node.values[0] ?? "").trim();
|
||||
if (node.operator === "in" || node.operator === "not_in") out.values = [...node.values];
|
||||
@@ -156,7 +166,7 @@ function writeRule(node: FilterCondition, usageKey: boolean): TierRule {
|
||||
}
|
||||
|
||||
function writeCondition(node: FilterCondition): TierCondition {
|
||||
const raw = rawOf<TierCondition>(node);
|
||||
const raw = node.id === TIER_CONDITION_ID ? rawOf<TierCondition>(node) : undefined;
|
||||
if (isPristine(node) && raw) return raw;
|
||||
return {
|
||||
...raw,
|
||||
@@ -180,19 +190,31 @@ export function modelPricingCodec(): FilterCodec<ModelPricingFilter> {
|
||||
if (isTreePristine(tree) && raw) return raw;
|
||||
let condition: TierCondition | null = null;
|
||||
const rules: TierRule[] = [];
|
||||
for (const node of tree.items.flatMap((item) =>
|
||||
const nodes = tree.items.flatMap((item) =>
|
||||
item.kind === "group" ? leafConditions(item) : [item],
|
||||
)) {
|
||||
);
|
||||
const representable = (node: FilterNode) =>
|
||||
node.kind === "raw" ||
|
||||
(node.kind === "condition" && Object.hasOwn(CONDITION_OPERATORS, node.operator));
|
||||
// The saved condition keeps its slot wherever a new line was inserted.
|
||||
const keepsCondition = nodes.some(
|
||||
(node) => node.id === TIER_CONDITION_ID && representable(node),
|
||||
);
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "raw") {
|
||||
if (node.id === TIER_CONDITION_ID) condition = rawOf<TierCondition>(node)!;
|
||||
else rules.push(rawOf<TierRule>(node)!);
|
||||
continue;
|
||||
}
|
||||
const usage = isUsageField(context, node.field);
|
||||
// Only a new line may claim the free slot by its field; a saved rule moves only for = / !=, which no rule holds.
|
||||
const claims = savedRuleOf(node)
|
||||
? TIER_CONDITION_ONLY.includes(node.operator)
|
||||
: usage || TIER_CONDITION_ONLY.includes(node.operator);
|
||||
const isCondition =
|
||||
!condition &&
|
||||
(node.id === TIER_CONDITION_ID || usage || TIER_CONDITION_ONLY.includes(node.operator)) &&
|
||||
Object.hasOwn(CONDITION_OPERATORS, node.operator);
|
||||
(node.id === TIER_CONDITION_ID || (!keepsCondition && claims)) &&
|
||||
representable(node);
|
||||
if (isCondition) condition = writeCondition(node);
|
||||
else rules.push(writeRule(node, usage && !node.field.startsWith("usage.")));
|
||||
}
|
||||
|
||||
@@ -1230,6 +1230,39 @@ describe("ModelPricingEditor.vue", () => {
|
||||
"regex",
|
||||
);
|
||||
});
|
||||
it("keeps a usage-keyed rule a rule when another rule's values are edited in the builder", async () => {
|
||||
const kept = { keys: ["input"], op: "gt", value: 5 };
|
||||
const wrapper = await loadTiers([
|
||||
{ name: "Default", prices: { input: 0.000001 } },
|
||||
{
|
||||
name: "Tagged",
|
||||
rules: [kept, { keys: ["tier"], op: "in", values: ["x"] }],
|
||||
prices: { input: 0.000002 },
|
||||
},
|
||||
]);
|
||||
const dt = "model-pricing-tier-filter-builder-1";
|
||||
await wrapper.find(`[data-test="${dt}-chip-tier-value"]`).trigger("click");
|
||||
await flushPromises();
|
||||
const search = document.body.querySelector<HTMLInputElement>(`[data-test="${dt}-search"]`)!;
|
||||
search.value = "y";
|
||||
search.dispatchEvent(new Event("input"));
|
||||
await flushPromises();
|
||||
search.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await flushPromises();
|
||||
document.body
|
||||
.querySelector<HTMLElement>(`[data-test="${dt}-apply"]`)!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="model-pricing-tier-filter-errors-1"]').exists()).toBe(false);
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
const call = mockService.update.mock.calls[0];
|
||||
const saved = call[call.length - 1].tiers[1];
|
||||
expect(saved.condition ?? null).toBeNull();
|
||||
expect(saved.rules).toEqual([kept, { keys: ["tier"], op: "in", values: ["x", "y"] }]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("round-trips a priority on the default tier", async () => {
|
||||
const wrapper = await loadTiers([
|
||||
{ name: "Default", priority: 3, prices: { input: 0.000001 } },
|
||||
|
||||
Reference in New Issue
Block a user