Skip to content

Commit b04b93e

Browse files
authored
Merge pull request #798 from goplus/dev
Release v1.4.1
2 parents 816929e + 736a0bb commit b04b93e

File tree

17 files changed

+280
-90
lines changed

17 files changed

+280
-90
lines changed

spx-gui/index.html

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
1-
<!DOCTYPE html>
1+
<!doctype html>
22
<html lang="en">
3-
43
<head>
5-
<meta charset="UTF-8">
6-
<link rel="icon" href="/logo.svg">
7-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
7+
<link rel="dns-prefetch" href="https://builder-static.goplus.org" />
8+
<link rel="dns-prefetch" href="https://aigc-static.goplus.org" />
9+
<link rel="dns-prefetch" href="https://upload-na0.qiniup.com" />
10+
<link rel="preconnect" href="https://builder-static.goplus.org" crossorigin />
11+
812
<title>Go+ Builder</title>
13+
<meta name="description" content="Go+ Builder is a tool for building games. We create it to help children to learn abilities to build." />
14+
<meta name="keywords" content="Go+ Builder, game development for kids, educational coding tools, STEM education, creative game building, children's programming, learn to code for kids" />
15+
16+
<link rel="icon" href="/logo.svg" />
17+
18+
<style>
19+
html,
20+
body,
21+
#app {
22+
width: 100%;
23+
height: 100%;
24+
}
25+
</style>
926
</head>
1027

1128
<body>
1229
<div id="app"></div>
1330
<script type="module" src="/src/main.ts"></script>
1431
</body>
15-
16-
<style>
17-
html, body, #app {
18-
width: 100%;
19-
height: 100%;
20-
}
21-
</style>
2232
</html>

spx-gui/src/components/asset/library/AssetLibraryModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ import {
107107
UIDivider
108108
} from '@/components/ui'
109109
import { listAsset, AssetType, type AssetData, IsPublic } from '@/apis/asset'
110-
import { debounce } from '@/utils/utils'
110+
import { debounce } from 'lodash'
111111
import { useMessageHandle, useQuery } from '@/utils/exception'
112112
import { type Category, categories as categoriesWithoutAll, categoryAll } from './category'
113113
import { type Project } from '@/models/project'

spx-gui/src/components/editor/panels/common/CommonPanel.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ provide(panelColorKey, props.color)
5353
.common-panel {
5454
transition: 0.3s;
5555
flex: 0 0 auto;
56+
overflow: hidden;
5657
5758
&.expanded {
5859
flex: 1 1 0;

spx-gui/src/components/editor/panels/sprite/config/SpriteBasicConfig.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ import {
126126
UIButtonGroupItem
127127
} from '@/components/ui'
128128
import { useMessageHandle } from '@/utils/exception'
129-
import { debounce, round } from '@/utils/utils'
129+
import { round } from '@/utils/utils'
130+
import { debounce } from 'lodash'
130131
import {
131132
RotationStyle,
132133
LeftRight,

spx-gui/src/components/editor/sprite/AnimationEditor.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</UIButton>
1111
</template>
1212
</UIEmpty>
13-
<EditorList v-else color="sprite" :add-text="$t({ en: 'Add costume', zh: '添加造型' })">
13+
<EditorList v-else color="sprite" :add-text="$t({ en: 'Add animation', zh: '添加动画' })">
1414
<AnimationItem
1515
v-for="animation in sprite.animations"
1616
:key="animation.name"

spx-gui/src/components/editor/stage/widget/detail/MonitorDetail.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ import {
6767
UIButtonGroupItem,
6868
UIIcon
6969
} from '@/components/ui'
70-
import { debounce, round } from '@/utils/utils'
70+
import { round } from '@/utils/utils'
71+
import { debounce } from 'lodash'
7172
import { useMessageHandle } from '@/utils/exception'
7273
import type { Monitor } from '@/models/widget/monitor'
7374
import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'

spx-gui/src/components/project/ProjectCreateModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const handleSubmit = useMessageHandle(
9999
project.addSprite(sprite)
100100
await sprite.autoFit()
101101
// upload project content & call API addProject, TODO: maybe this should be extracted to `@/models`?
102-
const files = project.export()[1]
102+
const [, files] = await project.export()
103103
const { fileCollection } = await saveFiles(files)
104104
const projectData = await addProject({
105105
name: form.value.name,

spx-gui/src/components/ui/UINumberInput.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<template>
22
<NInputNumber
33
class="ui-number-input"
4+
:placeholder="placeholder || ''"
45
:show-button="false"
56
:value="value"
67
:disabled="disabled"
@@ -26,6 +27,7 @@ defineProps<{
2627
disabled?: boolean
2728
min?: number
2829
max?: number
30+
placeholder?: string
2931
}>()
3032
3133
const emit = defineEmits<{

spx-gui/src/components/ui/UITextInput.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<template>
22
<NInput
33
class="ui-text-input"
4+
:placeholder="placeholder || ''"
45
:value="value"
56
:disabled="disabled"
67
@update:value="(v) => emit('update:value', v)"
@@ -36,6 +37,7 @@ defineProps<{
3637
value: string
3738
clearable?: boolean
3839
disabled?: boolean
40+
placeholder?: string
3941
}>()
4042
4143
const emit = defineEmits<{

spx-gui/src/components/ui/form/UIFormItem.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
<script setup lang="ts">
2121
import { useSlots, computed } from 'vue'
2222
import { NFormItem } from 'naive-ui'
23-
import { debounce } from '@/utils/utils'
23+
import { debounce } from 'lodash'
2424
import UIFormItemInternal from './UIFormItemInternal.vue'
2525
import { useForm } from './UIForm.vue'
2626

spx-gui/src/models/animation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ describe('Animation', () => {
8585
const project = makeProject()
8686
project.sprites[0].animations[0].setSound(project.sounds[0].name)
8787

88-
const [metadata, files] = project.export()
88+
const [metadata, files] = await project.export()
8989
const delayedFiles: Files = Object.fromEntries(
9090
Object.entries(files).map(([path, file]) => [path, delayFile(file!, 50)])
9191
)

spx-gui/src/models/project/history.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import { shallowReactive } from 'vue'
77
import type { LocaleMessage } from '@/utils/i18n'
8-
import Mutex from '@/utils/mutex'
98
import type { Files } from '../common/file'
109
import type { Project } from '.'
1110

@@ -27,8 +26,6 @@ export type State = {
2726
}
2827

2928
export class History {
30-
private mutex = new Mutex()
31-
3229
constructor(
3330
private project: Project,
3431
/**
@@ -71,7 +68,7 @@ export class History {
7168
}
7269

7370
redo() {
74-
return this.mutex.runExclusive(() => this.goto(this.index + 1))
71+
return this.project.historyMutex.runExclusive(() => this.goto(this.index + 1))
7572
}
7673

7774
getUndoAction() {
@@ -80,11 +77,11 @@ export class History {
8077
}
8178

8279
undo() {
83-
return this.mutex.runExclusive(() => this.goto(this.index - 1))
80+
return this.project.historyMutex.runExclusive(() => this.goto(this.index - 1))
8481
}
8582

8683
doAction<T>(action: Action, fn: () => T | Promise<T>): Promise<T> {
87-
return this.mutex.runExclusive(async () => {
84+
return this.project.historyMutex.runExclusive(async () => {
8885
// history after current state (for redo) will be discarded on any action
8986
this.states.splice(this.index)
9087

spx-gui/src/models/project/index.test.ts

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { flushPromises } from '@vue/test-utils'
23
import { Sprite } from '../sprite'
34
import { Animation } from '../animation'
45
import { Sound } from '../sound'
56
import { Costume } from '../costume'
67
import { fromText, type Files } from '../common/file'
7-
import { Project } from '.'
8+
import { AutoSaveMode, AutoSaveToCloudState, Project } from '.'
9+
import * as cloudHelper from '../common/cloud'
10+
import * as localHelper from '../common/local'
811

912
function mockFile(name = 'mocked') {
1013
return fromText(name, Math.random() + '')
@@ -73,4 +76,168 @@ describe('Project', () => {
7376
expect(project.sprites.map((s) => s.name)).toEqual(['Sprite1', 'Sprite3', 'Sprite2'])
7477
expect(project.sounds.map((s) => s.name)).toEqual(['sound1', 'sound3', 'sound2'])
7578
})
79+
80+
it('should select correctly after sound removed', async () => {
81+
const project = makeProject()
82+
const sprite2 = new Sprite('Sprite2')
83+
project.addSprite(sprite2)
84+
const sound2 = new Sound('sound2', mockFile())
85+
project.addSound(sound2)
86+
const sound3 = new Sound('sound3', mockFile())
87+
project.addSound(sound3)
88+
89+
project.select({ type: 'stage' })
90+
project.removeSound('sound3')
91+
expect(project.selected).toEqual({ type: 'stage' })
92+
93+
project.select({ type: 'sound', name: 'sound' })
94+
95+
project.removeSound('sound')
96+
expect(project.selected).toEqual({
97+
type: 'sound',
98+
name: 'sound2'
99+
})
100+
101+
project.removeSound('sound2')
102+
expect(project.selected).toEqual({
103+
type: 'sprite',
104+
name: 'Sprite'
105+
})
106+
})
107+
108+
it('should select correctly after sprite removed', async () => {
109+
const project = makeProject()
110+
const sprite2 = new Sprite('Sprite2')
111+
project.addSprite(sprite2)
112+
const sprite3 = new Sprite('Sprite3')
113+
project.addSprite(sprite3)
114+
115+
project.select({ type: 'stage' })
116+
project.removeSprite('Sprite3')
117+
expect(project.selected).toEqual({ type: 'stage' })
118+
119+
project.select({ type: 'sprite', name: 'Sprite' })
120+
121+
project.removeSprite('Sprite')
122+
expect(project.selected).toEqual({
123+
type: 'sprite',
124+
name: 'Sprite2'
125+
})
126+
127+
project.removeSprite('Sprite2')
128+
expect(project.selected).toBeNull()
129+
})
130+
131+
it('should throw an error when saving a disposed project', async () => {
132+
const project = makeProject()
133+
const saveToLocalCacheMethod = vi.spyOn(project, 'saveToLocalCache' as any)
134+
135+
project.dispose()
136+
137+
await expect(project.saveToCloud()).rejects.toThrow('disposed')
138+
139+
await expect((project as any).saveToLocalCache('key')).rejects.toThrow('disposed')
140+
expect(saveToLocalCacheMethod).toHaveBeenCalledWith('key')
141+
})
142+
})
143+
144+
describe('ProjectAutoSave', () => {
145+
beforeEach(() => {
146+
vi.useFakeTimers()
147+
})
148+
149+
afterEach(() => {
150+
vi.useRealTimers()
151+
vi.restoreAllMocks()
152+
})
153+
154+
// https://github.com/goplus/builder/pull/794#discussion_r1728120369
155+
it('should handle failed auto-save correctly', async () => {
156+
const project = makeProject()
157+
158+
const cloudSaveMock = vi.spyOn(cloudHelper, 'save').mockRejectedValue(new Error('save failed'))
159+
const localSaveMock = vi.spyOn(localHelper, 'save').mockResolvedValue(undefined)
160+
const localClearMock = vi.spyOn(localHelper, 'clear').mockResolvedValue(undefined)
161+
162+
await project.startEditing('localCacheKey')
163+
project.setAutoSaveMode(AutoSaveMode.Cloud)
164+
165+
const newSprite = new Sprite('newSprite')
166+
project.addSprite(newSprite)
167+
await flushPromises()
168+
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
169+
await flushPromises()
170+
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Pending)
171+
expect(project.hasUnsyncedChanges).toBe(true)
172+
173+
await vi.advanceTimersByTimeAsync(1500) // wait for auto-save to trigger
174+
await flushPromises()
175+
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Failed)
176+
expect(project.hasUnsyncedChanges).toBe(true)
177+
expect(cloudSaveMock).toHaveBeenCalledTimes(1)
178+
expect(localSaveMock).toHaveBeenCalledTimes(1)
179+
180+
project.removeSprite(newSprite.name)
181+
await flushPromises()
182+
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
183+
await flushPromises()
184+
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Failed)
185+
expect(project.hasUnsyncedChanges).toBe(false)
186+
187+
await vi.advanceTimersByTimeAsync(5000) // wait for auto-retry to trigger
188+
await flushPromises()
189+
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Saved)
190+
expect(project.hasUnsyncedChanges).toBe(false)
191+
expect(cloudSaveMock).toHaveBeenCalledTimes(1)
192+
expect(localSaveMock).toHaveBeenCalledTimes(1)
193+
expect(localClearMock).toHaveBeenCalledTimes(1)
194+
})
195+
196+
it('should cancel pending auto-save-to-cloud when project is disposed', async () => {
197+
const project = makeProject()
198+
199+
const cloudSaveMock = vi.spyOn(cloudHelper, 'save').mockRejectedValue(undefined)
200+
201+
await project.startEditing('localCacheKey')
202+
project.setAutoSaveMode(AutoSaveMode.Cloud)
203+
204+
const newSprite = new Sprite('newSprite')
205+
project.addSprite(newSprite)
206+
await flushPromises()
207+
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
208+
await flushPromises()
209+
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Pending)
210+
expect(project.hasUnsyncedChanges).toBe(true)
211+
212+
project.dispose()
213+
214+
await vi.advanceTimersByTimeAsync(1500 * 2) // wait longer to ensure auto-save does not trigger
215+
await flushPromises()
216+
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Pending)
217+
expect(project.hasUnsyncedChanges).toBe(true)
218+
expect(cloudSaveMock).toHaveBeenCalledTimes(0)
219+
})
220+
221+
it('should cancel pending auto-save-to-local-cache when project is disposed', async () => {
222+
const project = makeProject()
223+
224+
const localSaveMock = vi.spyOn(localHelper, 'save').mockResolvedValue(undefined)
225+
226+
await project.startEditing('localCacheKey')
227+
project.setAutoSaveMode(AutoSaveMode.LocalCache)
228+
229+
const newSprite = new Sprite('newSprite')
230+
project.addSprite(newSprite)
231+
await flushPromises()
232+
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
233+
await flushPromises()
234+
expect(project.hasUnsyncedChanges).toBe(true)
235+
236+
project.dispose()
237+
238+
await vi.advanceTimersByTimeAsync(1000 * 2) // wait longer to ensure auto-save does not trigger
239+
await flushPromises()
240+
expect(project.hasUnsyncedChanges).toBe(true)
241+
expect(localSaveMock).toHaveBeenCalledTimes(0)
242+
})
76243
})

0 commit comments

Comments
 (0)