Skip to content

Commit cf18998

Browse files
#398 Warn when loading saves from newer versions (#1271)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thekingofcity <3353040+thekingofcity@users.noreply.github.com>
1 parent 1b3c7bc commit cf18998

File tree

11 files changed

+227
-22
lines changed

11 files changed

+227
-22
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { render } from '../../test-utils';
4+
import { CURRENT_VERSION } from '../../util/save';
5+
import ConfirmOverwriteDialog from './confirm-overwrite-dialog';
6+
7+
// Mock the translation hook
8+
vi.mock('react-i18next', async () => {
9+
const actual = await vi.importActual('react-i18next');
10+
return {
11+
...actual,
12+
useTranslation: () => ({
13+
t: (key: string, options?: any) => {
14+
if (key === 'header.open.confirmOverwrite.title') return 'Confirm overwrite';
15+
if (key === 'header.open.confirmOverwrite.body')
16+
return 'This action will overwrite your current project. Any unsaved changes will be lost. Are you sure you want to continue?';
17+
if (key === 'header.open.confirmOverwrite.overwrite') return 'Overwrite';
18+
if (key === 'cancel') return 'Cancel';
19+
if (key === 'header.open.confirmOverwrite.newerVersion')
20+
return `Warning: This save file is from a newer version (${options?.saveVersion}) than the current version (${options?.currentVersion}). Loading it may cause errors or undefined behavior.`;
21+
return key;
22+
},
23+
}),
24+
};
25+
});
26+
27+
describe('ConfirmOverwriteDialog', () => {
28+
it('should not show version warning when saveVersion is 0', () => {
29+
render(<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={0} />);
30+
31+
expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
32+
expect(
33+
screen.getByText(
34+
'This action will overwrite your current project. Any unsaved changes will be lost. Are you sure you want to continue?'
35+
)
36+
).toBeInTheDocument();
37+
expect(screen.queryByText(/Warning:/)).not.toBeInTheDocument();
38+
});
39+
40+
it('should not show version warning when saveVersion equals CURRENT_VERSION', () => {
41+
render(
42+
<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={CURRENT_VERSION} />
43+
);
44+
45+
expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
46+
expect(screen.queryByText(/Warning:/)).not.toBeInTheDocument();
47+
});
48+
49+
it('should not show version warning when saveVersion is less than CURRENT_VERSION', () => {
50+
render(
51+
<ConfirmOverwriteDialog
52+
isOpen={true}
53+
onClose={vi.fn()}
54+
onConfirm={vi.fn()}
55+
saveVersion={CURRENT_VERSION - 10}
56+
/>
57+
);
58+
59+
expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
60+
expect(screen.queryByText(/Warning:/)).not.toBeInTheDocument();
61+
});
62+
63+
it('should show version warning when saveVersion is greater than CURRENT_VERSION', () => {
64+
const newerVersion = CURRENT_VERSION + 10;
65+
render(
66+
<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={newerVersion} />
67+
);
68+
69+
expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
70+
expect(
71+
screen.getByText(
72+
`Warning: This save file is from a newer version (${newerVersion}) than the current version (${CURRENT_VERSION}). Loading it may cause errors or undefined behavior.`
73+
)
74+
).toBeInTheDocument();
75+
});
76+
77+
it('should render buttons correctly', () => {
78+
render(<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={0} />);
79+
80+
expect(screen.getByText('Cancel')).toBeInTheDocument();
81+
expect(screen.getByText('Overwrite')).toBeInTheDocument();
82+
});
83+
84+
it('should not render when isOpen is false', () => {
85+
render(<ConfirmOverwriteDialog isOpen={false} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={100} />);
86+
87+
expect(screen.queryByText('Confirm overwrite')).not.toBeInTheDocument();
88+
});
89+
});

src/components/page-header/confirm-overwrite-dialog.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,48 @@ import {
66
AlertDialogHeader,
77
AlertDialogOverlay,
88
Button,
9+
Text,
910
} from '@chakra-ui/react';
1011
import React from 'react';
1112
import { useTranslation } from 'react-i18next';
13+
import { CURRENT_VERSION } from '../../util/save';
1214

1315
interface ConfirmOverwriteDialogProps {
1416
isOpen: boolean;
1517
onClose: () => void;
1618
onConfirm: () => void;
19+
saveVersion: number;
1720
}
1821

1922
/**
2023
* A shared dialog component for confirming overwrite of current project data.
2124
* Used when importing data from various sources that would replace the current graph.
2225
*/
2326
export default function ConfirmOverwriteDialog(props: ConfirmOverwriteDialogProps) {
24-
const { isOpen, onClose, onConfirm } = props;
27+
const { isOpen, onClose, onConfirm, saveVersion } = props;
2528
const { t } = useTranslation();
2629
const cancelRef = React.useRef<HTMLButtonElement | null>(null);
2730

31+
const isNewerVersion = saveVersion > CURRENT_VERSION;
32+
2833
return (
2934
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
3035
<AlertDialogOverlay>
3136
<AlertDialogContent>
3237
<AlertDialogHeader fontSize="lg" fontWeight="bold">
3338
{t('header.open.confirmOverwrite.title')}
3439
</AlertDialogHeader>
35-
<AlertDialogBody>{t('header.open.confirmOverwrite.body')}</AlertDialogBody>
40+
<AlertDialogBody>
41+
{t('header.open.confirmOverwrite.body')}
42+
{isNewerVersion && (
43+
<Text mt={4} color="orange.500" fontWeight="semibold">
44+
{t('header.open.confirmOverwrite.newerVersion', {
45+
saveVersion,
46+
currentVersion: CURRENT_VERSION,
47+
})}
48+
</Text>
49+
)}
50+
</AlertDialogBody>
3651
<AlertDialogFooter>
3752
<Button ref={cancelRef} onClick={onClose}>
3853
{t('cancel')}

src/components/page-header/open-actions.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getCanvasSize } from '../../util/helpers';
1111
import { useWindowSize } from '../../util/hooks';
1212
import { pullServerImages, saveImagesFromParam } from '../../util/image';
1313
import { saveManagerChannel, SaveManagerEvent, SaveManagerEventType } from '../../util/rmt-save';
14-
import { getInitialParam, RMPSave, upgrade } from '../../util/save';
14+
import { getInitialParam, parseVersionFromSave, RMPSave, upgrade } from '../../util/save';
1515
import ConfirmOverwriteDialog from './confirm-overwrite-dialog';
1616
import RmgParamAppClip from './rmg-param-app-clip';
1717
import RmpGalleryAppClip from './rmp-gallery-app-clip';
@@ -21,6 +21,7 @@ export default function OpenActions() {
2121
const { t } = useTranslation();
2222
const { isOpen: isConfirmOpen, onOpen: onConfirmOpen, onClose: onConfirmClose } = useDisclosure();
2323
const [paramToLoad, setParamToLoad] = React.useState<string | null>(null);
24+
const [versionToLoad, setVersionToLoad] = React.useState<number>(0);
2425

2526
const size = useWindowSize();
2627
const { height } = getCanvasSize(size);
@@ -103,6 +104,8 @@ export default function OpenActions() {
103104
try {
104105
const paramStr = await readFileAsText(file);
105106
setParamToLoad(paramStr);
107+
const version = parseVersionFromSave(paramStr);
108+
setVersionToLoad(version);
106109
onConfirmOpen();
107110
} catch (err) {
108111
dispatch(setGlobalAlert({ status: 'error', message: t('header.open.unknownError') }));
@@ -120,6 +123,7 @@ export default function OpenActions() {
120123
const handleLoadTutorial = async () => {
121124
const initialParam = await getInitialParam();
122125
setParamToLoad(initialParam);
126+
setVersionToLoad(parseVersionFromSave(initialParam));
123127
onConfirmOpen();
124128
};
125129

@@ -185,7 +189,12 @@ export default function OpenActions() {
185189
<RmpGalleryAppClip isOpen={isOpenGallery} onClose={() => setIsOpenGallery(false)} />
186190
</Menu>
187191

188-
<ConfirmOverwriteDialog isOpen={isConfirmOpen} onClose={onConfirmClose} onConfirm={handleConfirmLoad} />
192+
<ConfirmOverwriteDialog
193+
isOpen={isConfirmOpen}
194+
onClose={onConfirmClose}
195+
onConfirm={handleConfirmLoad}
196+
saveVersion={versionToLoad}
197+
/>
189198
</>
190199
);
191200
}

src/components/page-header/rmp-gallery-app-clip.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
4242
const dispatch = useRootDispatch();
4343
const { isOpen: isConfirmOpen, onOpen: onConfirmOpen, onClose: onConfirmClose } = useDisclosure();
4444
const [workToLoad, setWorkToLoad] = React.useState<RMPSave | null>(null);
45+
const [versionToLoad, setVersionToLoad] = React.useState<number>(0);
4546

4647
const {
4748
telemetry: { project: isAllowProjectTelemetry },
@@ -56,8 +57,8 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
5657
dispatch(refreshEdgesThunk());
5758
}, [dispatch, refreshNodesThunk, refreshEdgesThunk, saveGraph, graph]);
5859

59-
const handleOpenTemplate = async (rmpSave: RMPSave) => {
60-
// templates may be obsolete and require upgrades
60+
const handleOpenWork = async (rmpSave: RMPSave) => {
61+
// works may be obsolete and require upgrades
6162
const { version, images, ...save } = JSON.parse(await upgrade(JSON.stringify(rmpSave))) as RMPSave;
6263

6364
// details panel will complain about unknown nodes or edges if the last selected is not cleared
@@ -86,15 +87,15 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
8687

8788
const handleConfirmOpen = async () => {
8889
if (workToLoad) {
89-
await handleOpenTemplate(workToLoad);
90+
await handleOpenWork(workToLoad);
9091
}
9192
onConfirmClose();
9293
setWorkToLoad(null);
9394
};
9495

95-
const fetchAndApplyTemplate = async (id: string, host?: string) => {
96+
const fetchAndApplyWork = async (id: string, host?: string) => {
9697
const urlPrefix = host ? `https://${host}` : '';
97-
const template = (await (
98+
const work = (await (
9899
(
99100
await Promise.allSettled([
100101
fetch(`${urlPrefix}/rmp-gallery/resources/real_world/${id}.json`),
@@ -104,8 +105,9 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
104105
)
105106
.find(rep => rep.value.status === 200)
106107
?.value.json()) as RMPSave | undefined;
107-
if (template) {
108-
setWorkToLoad(template);
108+
if (work) {
109+
setWorkToLoad(work);
110+
setVersionToLoad(work.version);
109111
onConfirmOpen();
110112

111113
if (isAllowAppTelemetry) {
@@ -135,8 +137,9 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
135137
if (rep.status !== 200) {
136138
throw new Error(t('header.open.importFailContent'));
137139
}
138-
const work = await rep.json();
139-
setWorkToLoad(work as RMPSave);
140+
const work = (await rep.json()) as RMPSave;
141+
setWorkToLoad(work);
142+
setVersionToLoad(work.version);
140143
onConfirmOpen();
141144

142145
if (isAllowAppTelemetry) {
@@ -162,7 +165,7 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
162165
//
163166
// Since rmt will pass all params in `searchParams` here,
164167
// e.g. https://railmapgen.github.io/?app=rmp&searchParams=id.hostname
165-
// we will split id and host name from it and `fetchAndApplyTemplate`.
168+
// we will split id and host name from it and `fetchAndApplyWork`.
166169
//
167170
// It's really ugly to have multiple search params in searchParams after `encodeURIComponent`,
168171
// so we are joining id and host by '.'.
@@ -176,7 +179,7 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
176179
let host: string | undefined = undefined;
177180
if (firstDotIndex !== -1) host = params.substring(firstDotIndex + 1);
178181
if (host === 'org') fetchAndApplyShare(id);
179-
else fetchAndApplyTemplate(id, host);
182+
else fetchAndApplyWork(id, host);
180183
// clear the search params in rmt, otherwise it will be preserved and re-imported every time
181184
rmgRuntime.updateAppMetadata({ search: '' });
182185
}
@@ -186,7 +189,7 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
186189
CHN.onmessage = e => {
187190
const { event, data: id } = e.data;
188191
if (event === RMP_GALLERY_CHANNEL_EVENT) {
189-
fetchAndApplyTemplate(id);
192+
fetchAndApplyWork(id);
190193
onClose();
191194
}
192195
};
@@ -199,7 +202,12 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
199202
<iframe src="/rmp-gallery/" loading="lazy" />
200203
<CloseButton onClick={onClose} position="fixed" top="5px" right="15px" />
201204
</RmgAppClip>
202-
<ConfirmOverwriteDialog isOpen={isConfirmOpen} onClose={onConfirmClose} onConfirm={handleConfirmOpen} />
205+
<ConfirmOverwriteDialog
206+
isOpen={isConfirmOpen}
207+
onClose={onConfirmClose}
208+
onConfirm={handleConfirmOpen}
209+
saveVersion={versionToLoad}
210+
/>
203211
</>
204212
);
205213
}

src/i18n/translations/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,8 @@
691691
"confirmOverwrite": {
692692
"title": "Confirm overwrite",
693693
"body": "This action will overwrite your current project. Any unsaved changes will be lost. Are you sure you want to continue?",
694-
"overwrite": "Overwrite"
694+
"overwrite": "Overwrite",
695+
"newerVersion": "Warning: This save file is from a newer version ({{saveVersion}}) than the current version ({{currentVersion}}). Loading it may cause errors or undefined behavior."
695696
}
696697
},
697698
"download": {

src/i18n/translations/ja.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,8 @@
693693
"confirmOverwrite": {
694694
"title": "上書きの確認",
695695
"body": "この操作により現在のプロジェクトが上書きされます。未保存の変更はすべて失われます。続行してもよろしいですか?",
696-
"overwrite": "上書き"
696+
"overwrite": "上書き",
697+
"newerVersion": "警告:この作品は新しいバージョン({{saveVersion}})からのもので、現在のバージョン({{currentVersion}})より新しいです。読み込むと失敗や未定義の動作が発生する可能性があります。"
697698
}
698699
},
699700
"download": {

src/i18n/translations/ko.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,8 @@
690690
"confirmOverwrite": {
691691
"title": "확인 덮어쓰기",
692692
"body": "이 작업은 현재 프로젝트를 덮어씁니다. 저장하지 않은 모든 변경 사항이 손실됩니다. 계속하시겠습니까?",
693-
"overwrite": "덮어쓰기"
693+
"overwrite": "덮어쓰기",
694+
"newerVersion": "경고: 이 저장 파일은 현재 버전({{currentVersion}})보다 최신 버전({{saveVersion}})에서 가져온 것입니다. 로드하면 오류나 정의되지 않은 동작이 발생할 수 있습니다."
694695
}
695696
},
696697
"download": {

src/i18n/translations/zh-Hans.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,8 @@
690690
"confirmOverwrite": {
691691
"title": "确认覆盖",
692692
"body": "此操作将覆盖当前项目。所有未保存的更改将丢失。确定要继续吗?",
693-
"overwrite": "覆盖"
693+
"overwrite": "覆盖",
694+
"newerVersion": "警告:此存档文件来自更新的版本({{saveVersion}}),高于当前版本({{currentVersion}})。加载它可能会导致错误或未定义的行为。"
694695
}
695696
},
696697
"download": {

src/i18n/translations/zh-Hant.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,8 @@
690690
"confirmOverwrite": {
691691
"title": "確認覆蓋",
692692
"body": "此操作將覆蓋當前項目。所有未保存的更改將丟失。確定要繼續嗎?",
693-
"overwrite": "覆蓋"
693+
"overwrite": "覆蓋",
694+
"newerVersion": "警告:此存檔檔案來自更新的版本({{saveVersion}}),高於當前版本({{currentVersion}})。載入它可能會導致錯誤或未定義的行為。"
694695
}
695696
},
696697
"download": {

0 commit comments

Comments
 (0)