Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ describe("buildCaseArgsContentSignature", () => {
expect(
buildCaseArgsContentSignature("p", "c", [{ ...argument, input: "[1]" }]),
).not.toBe(base);
expect(
buildCaseArgsContentSignature("p", "c", [{ ...argument, label: "root" }]),
).not.toBe(base);
});

it("changes when parentName changes", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";

import { getArgumentDisplayLabel } from "#/entities/argument/lib/getArgumentDisplayLabel";
import { ArgumentType } from "#/entities/argument/model/argumentObject";
import type { ArgumentObject } from "#/entities/argument/model/types";

const makeArg = (
overrides: Partial<ArgumentObject> & Pick<ArgumentObject, "order">,
): ArgumentObject => ({
name: "uuid-key",
type: ArgumentType.ARRAY,
input: "[]",
...overrides,
});

describe("getArgumentDisplayLabel", () => {
it("returns trimmed custom label when set", () => {
expect(
getArgumentDisplayLabel(
makeArg({ order: 0, label: " head " }),
),
).toBe("head");
});

it("falls back to arg-N when label is missing or blank", () => {
expect(getArgumentDisplayLabel(makeArg({ order: 0 }))).toBe("arg-1");
expect(getArgumentDisplayLabel(makeArg({ order: 2 }))).toBe("arg-3");
expect(getArgumentDisplayLabel(makeArg({ order: 0, label: "" }))).toBe(
"arg-1",
);
expect(getArgumentDisplayLabel(makeArg({ order: 0, label: " " }))).toBe(
"arg-1",
);
});

it("allows duplicate labels across arguments (store keys stay unique)", () => {
const first = makeArg({ order: 0, name: "a", label: "nums" });
const second = makeArg({ order: 1, name: "b", label: "nums" });
expect(getArgumentDisplayLabel(first)).toBe("nums");
expect(getArgumentDisplayLabel(second)).toBe("nums");
expect(first.name).not.toBe(second.name);
});
});
1 change: 1 addition & 0 deletions src/entities/argument/lib/argumentObjectValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ArgumentType } from "../model/argumentObject";

export const argumentObjectValidator = z.object({
name: z.string(),
label: z.string().optional(),
type: z.nativeEnum(ArgumentType),
order: z.number(),
input: z.string(),
Expand Down
1 change: 1 addition & 0 deletions src/entities/argument/lib/caseArgsContentSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const buildCaseArgsContentSignature = (
const normalizedArguments = [...argumentsList]
.map((argument) => ({
name: argument.name,
label: argument.label ?? null,
parentName: argument.parentName ?? null,
order: argument.order,
type: argument.type,
Expand Down
11 changes: 11 additions & 0 deletions src/entities/argument/lib/getArgumentDisplayLabel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ArgumentObject } from "#/entities/argument/model/types";

/**
* User-visible argument name. Multiple arguments may share the same label;
* `ArgumentObject.name` remains the unique store key.
*/
export const getArgumentDisplayLabel = (arg: ArgumentObject): string => {
const trimmed = arg.label?.trim();
if (trimmed) return trimmed;
return `arg-${arg.order + 1}`;
};
1 change: 1 addition & 0 deletions src/entities/argument/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { buildCaseArgsContentSignature } from "./caseArgsContentSignature";
export { isArgumentArrayType } from "./isArgumentArrayType";
export { isArgumentTreeType } from "./isArgumentTreeType";
export { isArgumentObjectValid } from "./isArgumentObjectValid";
export { getArgumentDisplayLabel } from "./getArgumentDisplayLabel";
37 changes: 37 additions & 0 deletions src/entities/argument/model/__tests__/caseSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ArgumentType } from "#/entities/argument/model/argumentObject";
import {
caseSlice,
selectCaseArguments,
selectCaseIsEdited,
} from "#/entities/argument/model/caseSlice";
import { callstackSlice } from "#/features/callstack/model/callstackSlice";
import { rootReducer } from "#/store/rootReducer";
Expand Down Expand Up @@ -76,4 +77,40 @@ describe("caseSlice selectors", () => {
expect(secondArgs).not.toBe(firstArgs);
expect(secondArgs).toEqual(firstArgs);
});

it("updateArgumentLabel trims and clears blank labels", () => {
const store = configureStore({ reducer: rootReducer });

store.dispatch(
caseSlice.actions.setArguments({
projectId: "project-1",
caseId: "case-1",
data: [
{
name: "arg-id",
order: 0,
type: ArgumentType.ARRAY,
input: "[1]",
},
],
}),
);

store.dispatch(
caseSlice.actions.updateArgumentLabel({
name: "arg-id",
label: " nums ",
}),
);
expect(selectCaseArguments(store.getState())[0]?.label).toBe("nums");

store.dispatch(
caseSlice.actions.updateArgumentLabel({
name: "arg-id",
label: " ",
}),
);
expect(selectCaseArguments(store.getState())[0]?.label).toBeUndefined();
expect(selectCaseIsEdited(store.getState())).toBe(true);
});
});
14 changes: 14 additions & 0 deletions src/entities/argument/model/caseSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ export const caseSlice = createSlice({
isParsed: false,
};
},
updateArgumentLabel: (
state,
action: PayloadAction<{ name: string; label: string | undefined }>,
) => {
const { name, label } = action.payload;
if (!state.args.entities[name]) return;
const nextLabel =
label === undefined || label.trim() === "" ? undefined : label.trim();
argumentAdapter.updateOne(state.args, {
id: name,
changes: { label: nextLabel },
});
state.isEdited = true;
},
updateNodeData: (
state,
action: PayloadAction<ArgumentObject<ArgumentTreeType>>,
Expand Down
3 changes: 3 additions & 0 deletions src/entities/argument/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export type ArgumentArrayType =
| ArgumentType.OBJECT;

type BaseArgumentData = {
/** Stable unique id (Redux entity id, structure store key). */
name: string;
/** Optional display name; duplicates allowed. Falls back to `arg-${order + 1}`. */
label?: string;
parentName?: string;
order: number;
input: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";

import { ArgumentType } from "#/entities/argument/model/argumentObject";
import { arrayStructureSlice } from "#/entities/dataStructures/array/model/arraySlice";

describe("arrayStructureSlice.create displayLabel", () => {
it("persists displayLabel from runtime addArray options", () => {
const state = arrayStructureSlice.reducer(
{},
arrayStructureSlice.actions.create({
name: "runtime-array",
data: {
argType: ArgumentType.ARRAY,
options: { displayLabel: "array" },
},
}),
);

expect(state["runtime-array"]?.displayLabel).toBe("array");
expect(state["runtime-array"]?.isRuntime).toBe(true);
});

it("omits displayLabel when options are absent", () => {
const state = arrayStructureSlice.reducer(
{},
arrayStructureSlice.actions.create({
name: "runtime-array",
data: { argType: ArgumentType.ARRAY },
}),
);

expect(state["runtime-array"]?.displayLabel).toBeUndefined();
});
});
22 changes: 19 additions & 3 deletions src/entities/dataStructures/array/model/arraySlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type ArrayData = BaseStructureItem<ArrayItemData> & {
childNames?: string[];
colHeaders?: string[];
rowHeaders?: string[];
/** Inferred from user code (e.g. `const nums = [...]`); shown above the structure in the viewer. */
displayLabel?: string;
};

export type ArrayDataState = BaseStructureState<ArrayData>;
Expand All @@ -44,12 +46,14 @@ const getInitialData = (
argType: ArgumentArrayType,
parentName?: string,
childNames?: string[],
displayLabel?: string,
): ArrayData => ({
...getInitialDataBase(arrayDataAdapter),
order,
argType,
parentName,
childNames,
displayLabel,
});

const initialState: ArrayDataState = {};
Expand All @@ -74,10 +78,18 @@ export const arrayStructureSlice = createSlice({
childNames?: string[];
order: number;
argType: ArgumentArrayType;
displayLabel?: string;
}>,
) => {
const { name, order, argType, parentName, childNames } = action.payload;
state[name] = getInitialData(order, argType, parentName, childNames);
const { name, order, argType, parentName, childNames, displayLabel } =
action.payload;
state[name] = getInitialData(
order,
argType,
parentName,
childNames,
displayLabel,
);
},
create: (
state,
Expand All @@ -93,7 +105,7 @@ export const arrayStructureSlice = createSlice({
data: { argType, nodes, options },
},
} = action;
const { colorMap } = options ?? {};
const { colorMap, displayLabel } = options ?? {};
const treeState = {
...getInitialData(999, argType),
isRuntime: true,
Expand All @@ -106,6 +118,10 @@ export const arrayStructureSlice = createSlice({
treeState.colorMap = colorMap;
}

if (displayLabel !== undefined) {
treeState.displayLabel = displayLabel;
}

state[name] = treeState;
},
delete: (state, action: NamedPayload<void>) => {
Expand Down
67 changes: 58 additions & 9 deletions src/entities/dataStructures/array/model/arrayStructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ const ArrayBase = makeArrayBaseClass(Array);
export type ControlledArrayRuntimeOptions = {
length?: number;
colorMap?: Record<string, string>;
/** User source variable name (inferred by AST); shown as a viewer label, not the internal tree id. */
displayLabel?: string;
};

const RUNTIME_DISPLAY_LABEL_KEY = "__dstructRuntimeDisplayLabel";

const getRuntimeDisplayLabel = (
array: ArrayBaseType,
): string | undefined => {
const label = Reflect.get(array, RUNTIME_DISPLAY_LABEL_KEY);
return typeof label === "string" ? label : undefined;
};

export function initControlledArray<T extends ArrayBaseType>(
Expand Down Expand Up @@ -51,6 +62,14 @@ export function initControlledArray<T extends ArrayBaseType>(
value: callstack,
enumerable: false,
},
...(options?.displayLabel !== undefined
? {
[RUNTIME_DISPLAY_LABEL_KEY]: {
value: options.displayLabel,
enumerable: false,
},
}
: {}),
});

if (addToCallstack) {
Expand Down Expand Up @@ -184,13 +203,21 @@ export class ControlledArray<T> extends ArrayBase<T> {
new Array(this.length),
undefined,
);
const inheritedDisplayLabel = getRuntimeDisplayLabel(this);
const mapOptions: ControlledArrayRuntimeOptions | undefined =
inheritedDisplayLabel !== undefined || options !== undefined
? {
...options,
displayLabel: options?.displayLabel ?? inheritedDisplayLabel,
}
: undefined;
const newArray = new ControlledArray(
array as U[],
id,
data,
this.callstack,
true,
options,
mapOptions,
);
for (let i = 0; i < this.length; i++) {
newArray[i] = callback(this[i]!, i, this);
Expand Down Expand Up @@ -269,22 +296,44 @@ export const getRuntimeArrayClass = (callstack: CallstackHelper) =>
class ArrayProxy<T extends string | number> extends ControlledArray<T> {
constructor(arrayLength: number);
constructor(...items: Array<T>) {
if (items.length === 1 && typeof items[0] === "number") {
const arrayLength = items[0];
items = new Array(arrayLength);
let runtimeOptions: ControlledArrayRuntimeOptions | undefined;
let elementItems = items as Array<T | number>;
const lastItem = elementItems.at(-1);
if (
elementItems.length >= 2 &&
lastItem !== null &&
lastItem !== undefined &&
typeof lastItem === "object" &&
!Array.isArray(lastItem) &&
"displayLabel" in lastItem
) {
runtimeOptions = lastItem as ControlledArrayRuntimeOptions;
elementItems = elementItems.slice(0, -1) as Array<T | number>;
}

if (elementItems.length === 1 && typeof elementItems[0] === "number") {
const arrayLength = elementItems[0];
elementItems = new Array(arrayLength) as Array<T | number>;
}

if (
items[0] &&
typeof items[0] !== "number" &&
typeof items[0] !== "string"
elementItems[0] &&
typeof elementItems[0] !== "number" &&
typeof elementItems[0] !== "string"
) {
throw new Error("ArrayProxy can only contain numbers or strings");
}

const data = generateArrayData(items);
const data = generateArrayData(elementItems as T[]);

super(items, generate(), data, callstack, true);
super(
elementItems as T[],
generate(),
data,
callstack,
true,
runtimeOptions,
);
}

static override from(
Expand Down
Loading
Loading