Skip to content
Merged
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
43 changes: 1 addition & 42 deletions src/lib/schema/fileOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { ComponentFile, ComponentType, BlockContent, SubsystemContent } fro
import type { Position } from '$lib/types';
import { ALL_COMPONENT_EXTENSIONS, COMPONENT_VERSION } from '$lib/types/component';
import { cleanNodeForExport } from './cleanParams';
import { migrateGraphFile } from './migrate';
import { graphStore, cloneNodeForPaste } from '$lib/stores/graph';
import { eventStore } from '$lib/stores/events';
import { settingsStore } from '$lib/stores/settings';
Expand Down Expand Up @@ -165,48 +166,6 @@ export function createGraphFile(name?: string): GraphFile {
};
}

/**
* Migrate old node type format to new format
* Old: 'pathsim.sources.StepSource' -> New: 'StepSource'
*/
function migrateNodeType(type: string): string {
if (type.startsWith('pathsim.')) {
// Extract the last part after the final dot
const parts = type.split('.');
return parts[parts.length - 1];
}
return type;
}

/**
* Migrate a GraphFile from old format to new format
*/
function migrateGraphFile(file: GraphFile): GraphFile {
const migrateNodes = (nodes: NodeInstance[]): NodeInstance[] => {
return nodes.map(node => {
const migratedNode = { ...node, type: migrateNodeType(node.type) };
// Recursively migrate subsystem graphs (preserving connections, annotations, and events)
if (migratedNode.graph) {
migratedNode.graph = {
nodes: migrateNodes(migratedNode.graph.nodes || []),
connections: migratedNode.graph.connections || [],
annotations: migratedNode.graph.annotations,
events: migratedNode.graph.events
};
}
return migratedNode;
});
};

return {
...file,
graph: file.graph ? {
...file.graph,
nodes: migrateNodes(file.graph.nodes || [])
} : file.graph
};
}

/**
* Install missing runtime toolboxes referenced by a file's
* `requiredToolboxes`. Asks the user once, then installs in order.
Expand Down
43 changes: 43 additions & 0 deletions src/lib/schema/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { migrateGraphFile } from './migrate';
import type { GraphFile, NodeInstance } from '$lib/nodes/types';
import type { ToolboxRequirement } from '$lib/types/schema';

const chem = (installedVersion: string | null): ToolboxRequirement => ({
id: 'pathsim-chem',
displayName: 'pathsim-chem',
source: { type: 'pypi', pkg: 'pathsim-chem' },
importPath: 'pathsim_chem',
installedVersion
});

const polynomial = (coeffs: string): NodeInstance =>
({ id: 'p', type: 'Polynomial', name: 'Polynomial', params: { coeffs } }) as unknown as NodeInstance;

const file = (nodes: NodeInstance[], requiredToolboxes?: ToolboxRequirement[]): GraphFile =>
({ version: '1.0.0', graph: { nodes, connections: [] }, requiredToolboxes }) as unknown as GraphFile;

const coeffsOf = (f: GraphFile) => f.graph.nodes[0].params?.coeffs;

describe('migrateGraphFile', () => {
it('drops the old pathsim module prefix of node types', () => {
const node = { ...polynomial('[1]'), type: 'pathsim.sources.StepSource' };
expect(migrateGraphFile(file([node])).graph.nodes[0].type).toBe('StepSource');
});

it('reverses the coefficients of the Polynomial of pathsim-chem before 0.4', () => {
expect(coeffsOf(migrateGraphFile(file([polynomial('[1, 2, 3]')], [chem('0.3.0')])))).toBe('([1, 2, 3])[::-1]');
expect(coeffsOf(migrateGraphFile(file([polynomial('c')], [chem(null)])))).toBe('(c)[::-1]');
});

it('reverses the coefficients inside subsystems', () => {
const sub = { id: 's', type: 'Subsystem', name: 'S', params: {}, graph: { nodes: [polynomial('[1, 2]')], connections: [] } };
const migrated = migrateGraphFile(file([sub as unknown as NodeInstance], [chem('0.2.8')]));
expect(migrated.graph.nodes[0].graph?.nodes[0].params?.coeffs).toBe('([1, 2])[::-1]');
});

it('keeps the core Polynomial', () => {
expect(coeffsOf(migrateGraphFile(file([polynomial('[1, 2, 3]')])))).toBe('[1, 2, 3]');
expect(coeffsOf(migrateGraphFile(file([polynomial('[1, 2, 3]')], [chem('0.4.0')])))).toBe('[1, 2, 3]');
});
});
82 changes: 82 additions & 0 deletions src/lib/schema/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Migrations of saved graph files to the current format, applied on load
*/

import type { NodeInstance, GraphFile } from '$lib/nodes/types';
import type { ToolboxRequirement } from '$lib/types/schema';

/**
* pathsim-chem up to 0.3 had its own `Polynomial` with the coefficients in
* ascending order, which replaced the core block of the same name. It was
* removed in 0.4, so these nodes now load as `pathsim.blocks.Polynomial`,
* which takes the coefficients in descending order (numpy.polyval).
*/
const CHEM_POLYNOMIAL_REMOVED = [0, 4];

/**
* Migrate old node type format to new format
* Old: 'pathsim.sources.StepSource' -> New: 'StepSource'
*/
function migrateNodeType(type: string): string {
if (type.startsWith('pathsim.')) {
// Extract the last part after the final dot
const parts = type.split('.');
return parts[parts.length - 1];
}
return type;
}

/** Whether a version string is older than [major, minor], unknown versions count as older */
function olderThan(version: string | null | undefined, [major, minor]: number[]): boolean {
const match = version?.match(/^(\d+)\.(\d+)/);
if (!match) return true;
const [maj, min] = [Number(match[1]), Number(match[2])];
return maj < major || (maj === major && min < minor);
}

/** Whether the file was saved with a pathsim-chem that still had its own Polynomial */
function hasChemPolynomial(requirements: ToolboxRequirement[] | undefined): boolean {
const chem = requirements?.find((r) => r.importPath === 'pathsim_chem');
return !!chem && olderThan(chem.installedVersion, CHEM_POLYNOMIAL_REMOVED);
}

/** Reverse the coefficients of a chem Polynomial for the core block */
function migrateChemPolynomial(node: NodeInstance): NodeInstance {
const coeffs = node.params?.coeffs;
if (typeof coeffs !== 'string' || !coeffs.trim()) return node;
return { ...node, params: { ...node.params, coeffs: `(${coeffs})[::-1]` } };
}

/**
* Migrate a GraphFile from old format to new format
*/
export function migrateGraphFile(file: GraphFile): GraphFile {
const chemPolynomial = hasChemPolynomial(file.requiredToolboxes);

const migrateNodes = (nodes: NodeInstance[]): NodeInstance[] => {
return nodes.map(node => {
let migratedNode = { ...node, type: migrateNodeType(node.type) };
if (chemPolynomial && migratedNode.type === 'Polynomial') {
migratedNode = migrateChemPolynomial(migratedNode);
}
// Recursively migrate subsystem graphs (preserving connections, annotations, and events)
if (migratedNode.graph) {
migratedNode.graph = {
nodes: migrateNodes(migratedNode.graph.nodes || []),
connections: migratedNode.graph.connections || [],
annotations: migratedNode.graph.annotations,
events: migratedNode.graph.events
};
}
return migratedNode;
});
};

return {
...file,
graph: file.graph ? {
...file.graph,
nodes: migrateNodes(file.graph.nodes || [])
} : file.graph
};
}
Loading