diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index 969e7e82..26e14c9c 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -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'; @@ -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. diff --git a/src/lib/schema/migrate.test.ts b/src/lib/schema/migrate.test.ts new file mode 100644 index 00000000..15a026b0 --- /dev/null +++ b/src/lib/schema/migrate.test.ts @@ -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]'); + }); +}); diff --git a/src/lib/schema/migrate.ts b/src/lib/schema/migrate.ts new file mode 100644 index 00000000..30f00cd5 --- /dev/null +++ b/src/lib/schema/migrate.ts @@ -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 + }; +}