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:
ktx-vaidehi
2026-11-04 13:24:12 +05:30
parent 73f748bed9
commit fb4df51bb6
3 changed files with 100 additions and 6 deletions
@@ -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 } },