Skip to content

Commit

Permalink
Merge pull request #798 from goplus/dev
Browse files Browse the repository at this point in the history
Release v1.4.1
  • Loading branch information
nighca authored Aug 23, 2024
2 parents 816929e + 736a0bb commit b04b93e
Show file tree
Hide file tree
Showing 17 changed files with 280 additions and 90 deletions.
34 changes: 22 additions & 12 deletions spx-gui/index.html
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">

<head>
<meta charset="UTF-8">
<link rel="icon" href="/logo.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

<link rel="dns-prefetch" href="https://builder-static.goplus.org" />
<link rel="dns-prefetch" href="https://aigc-static.goplus.org" />
<link rel="dns-prefetch" href="https://upload-na0.qiniup.com" />
<link rel="preconnect" href="https://builder-static.goplus.org" crossorigin />

<title>Go+ Builder</title>
<meta name="description" content="Go+ Builder is a tool for building games. We create it to help children to learn abilities to build." />
<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" />

<link rel="icon" href="/logo.svg" />

<style>
html,
body,
#app {
width: 100%;
height: 100%;
}
</style>
</head>

<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

<style>
html, body, #app {
width: 100%;
height: 100%;
}
</style>
</html>
2 changes: 1 addition & 1 deletion spx-gui/src/components/asset/library/AssetLibraryModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ import {
UIDivider
} from '@/components/ui'
import { listAsset, AssetType, type AssetData, IsPublic } from '@/apis/asset'
import { debounce } from '@/utils/utils'
import { debounce } from 'lodash'
import { useMessageHandle, useQuery } from '@/utils/exception'
import { type Category, categories as categoriesWithoutAll, categoryAll } from './category'
import { type Project } from '@/models/project'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ provide(panelColorKey, props.color)
.common-panel {
transition: 0.3s;
flex: 0 0 auto;
overflow: hidden;

&.expanded {
flex: 1 1 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ import {
UIButtonGroupItem
} from '@/components/ui'
import { useMessageHandle } from '@/utils/exception'
import { debounce, round } from '@/utils/utils'
import { round } from '@/utils/utils'
import { debounce } from 'lodash'
import {
RotationStyle,
LeftRight,
Expand Down
2 changes: 1 addition & 1 deletion spx-gui/src/components/editor/sprite/AnimationEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</UIButton>
</template>
</UIEmpty>
<EditorList v-else color="sprite" :add-text="$t({ en: 'Add costume', zh: '添加造型' })">
<EditorList v-else color="sprite" :add-text="$t({ en: 'Add animation', zh: '添加动画' })">
<AnimationItem
v-for="animation in sprite.animations"
:key="animation.name"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ import {
UIButtonGroupItem,
UIIcon
} from '@/components/ui'
import { debounce, round } from '@/utils/utils'
import { round } from '@/utils/utils'
import { debounce } from 'lodash'
import { useMessageHandle } from '@/utils/exception'
import type { Monitor } from '@/models/widget/monitor'
import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
Expand Down
2 changes: 1 addition & 1 deletion spx-gui/src/components/project/ProjectCreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const handleSubmit = useMessageHandle(
project.addSprite(sprite)
await sprite.autoFit()
// upload project content & call API addProject, TODO: maybe this should be extracted to `@/models`?
const files = project.export()[1]
const [, files] = await project.export()
const { fileCollection } = await saveFiles(files)
const projectData = await addProject({
name: form.value.name,
Expand Down
2 changes: 2 additions & 0 deletions spx-gui/src/components/ui/UINumberInput.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<NInputNumber
class="ui-number-input"
:placeholder="placeholder || ''"
:show-button="false"
:value="value"
:disabled="disabled"
Expand All @@ -26,6 +27,7 @@ defineProps<{
disabled?: boolean
min?: number
max?: number
placeholder?: string
}>()
const emit = defineEmits<{
Expand Down
2 changes: 2 additions & 0 deletions spx-gui/src/components/ui/UITextInput.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<NInput
class="ui-text-input"
:placeholder="placeholder || ''"
:value="value"
:disabled="disabled"
@update:value="(v) => emit('update:value', v)"
Expand Down Expand Up @@ -36,6 +37,7 @@ defineProps<{
value: string
clearable?: boolean
disabled?: boolean
placeholder?: string
}>()
const emit = defineEmits<{
Expand Down
2 changes: 1 addition & 1 deletion spx-gui/src/components/ui/form/UIFormItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<script setup lang="ts">
import { useSlots, computed } from 'vue'
import { NFormItem } from 'naive-ui'
import { debounce } from '@/utils/utils'
import { debounce } from 'lodash'
import UIFormItemInternal from './UIFormItemInternal.vue'
import { useForm } from './UIForm.vue'
Expand Down
2 changes: 1 addition & 1 deletion spx-gui/src/models/animation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('Animation', () => {
const project = makeProject()
project.sprites[0].animations[0].setSound(project.sounds[0].name)

const [metadata, files] = project.export()
const [metadata, files] = await project.export()
const delayedFiles: Files = Object.fromEntries(
Object.entries(files).map(([path, file]) => [path, delayFile(file!, 50)])
)
Expand Down
9 changes: 3 additions & 6 deletions spx-gui/src/models/project/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import { shallowReactive } from 'vue'
import type { LocaleMessage } from '@/utils/i18n'
import Mutex from '@/utils/mutex'
import type { Files } from '../common/file'
import type { Project } from '.'

Expand All @@ -27,8 +26,6 @@ export type State = {
}

export class History {
private mutex = new Mutex()

constructor(
private project: Project,
/**
Expand Down Expand Up @@ -71,7 +68,7 @@ export class History {
}

redo() {
return this.mutex.runExclusive(() => this.goto(this.index + 1))
return this.project.historyMutex.runExclusive(() => this.goto(this.index + 1))
}

getUndoAction() {
Expand All @@ -80,11 +77,11 @@ export class History {
}

undo() {
return this.mutex.runExclusive(() => this.goto(this.index - 1))
return this.project.historyMutex.runExclusive(() => this.goto(this.index - 1))
}

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

Expand Down
171 changes: 169 additions & 2 deletions spx-gui/src/models/project/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { Sprite } from '../sprite'
import { Animation } from '../animation'
import { Sound } from '../sound'
import { Costume } from '../costume'
import { fromText, type Files } from '../common/file'
import { Project } from '.'
import { AutoSaveMode, AutoSaveToCloudState, Project } from '.'
import * as cloudHelper from '../common/cloud'
import * as localHelper from '../common/local'

function mockFile(name = 'mocked') {
return fromText(name, Math.random() + '')
Expand Down Expand Up @@ -73,4 +76,168 @@ describe('Project', () => {
expect(project.sprites.map((s) => s.name)).toEqual(['Sprite1', 'Sprite3', 'Sprite2'])
expect(project.sounds.map((s) => s.name)).toEqual(['sound1', 'sound3', 'sound2'])
})

it('should select correctly after sound removed', async () => {
const project = makeProject()
const sprite2 = new Sprite('Sprite2')
project.addSprite(sprite2)
const sound2 = new Sound('sound2', mockFile())
project.addSound(sound2)
const sound3 = new Sound('sound3', mockFile())
project.addSound(sound3)

project.select({ type: 'stage' })
project.removeSound('sound3')
expect(project.selected).toEqual({ type: 'stage' })

project.select({ type: 'sound', name: 'sound' })

project.removeSound('sound')
expect(project.selected).toEqual({
type: 'sound',
name: 'sound2'
})

project.removeSound('sound2')
expect(project.selected).toEqual({
type: 'sprite',
name: 'Sprite'
})
})

it('should select correctly after sprite removed', async () => {
const project = makeProject()
const sprite2 = new Sprite('Sprite2')
project.addSprite(sprite2)
const sprite3 = new Sprite('Sprite3')
project.addSprite(sprite3)

project.select({ type: 'stage' })
project.removeSprite('Sprite3')
expect(project.selected).toEqual({ type: 'stage' })

project.select({ type: 'sprite', name: 'Sprite' })

project.removeSprite('Sprite')
expect(project.selected).toEqual({
type: 'sprite',
name: 'Sprite2'
})

project.removeSprite('Sprite2')
expect(project.selected).toBeNull()
})

it('should throw an error when saving a disposed project', async () => {
const project = makeProject()
const saveToLocalCacheMethod = vi.spyOn(project, 'saveToLocalCache' as any)

project.dispose()

await expect(project.saveToCloud()).rejects.toThrow('disposed')

await expect((project as any).saveToLocalCache('key')).rejects.toThrow('disposed')
expect(saveToLocalCacheMethod).toHaveBeenCalledWith('key')
})
})

describe('ProjectAutoSave', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

// https://github.com/goplus/builder/pull/794#discussion_r1728120369
it('should handle failed auto-save correctly', async () => {
const project = makeProject()

const cloudSaveMock = vi.spyOn(cloudHelper, 'save').mockRejectedValue(new Error('save failed'))
const localSaveMock = vi.spyOn(localHelper, 'save').mockResolvedValue(undefined)
const localClearMock = vi.spyOn(localHelper, 'clear').mockResolvedValue(undefined)

await project.startEditing('localCacheKey')
project.setAutoSaveMode(AutoSaveMode.Cloud)

const newSprite = new Sprite('newSprite')
project.addSprite(newSprite)
await flushPromises()
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
await flushPromises()
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Pending)
expect(project.hasUnsyncedChanges).toBe(true)

await vi.advanceTimersByTimeAsync(1500) // wait for auto-save to trigger
await flushPromises()
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Failed)
expect(project.hasUnsyncedChanges).toBe(true)
expect(cloudSaveMock).toHaveBeenCalledTimes(1)
expect(localSaveMock).toHaveBeenCalledTimes(1)

project.removeSprite(newSprite.name)
await flushPromises()
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
await flushPromises()
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Failed)
expect(project.hasUnsyncedChanges).toBe(false)

await vi.advanceTimersByTimeAsync(5000) // wait for auto-retry to trigger
await flushPromises()
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Saved)
expect(project.hasUnsyncedChanges).toBe(false)
expect(cloudSaveMock).toHaveBeenCalledTimes(1)
expect(localSaveMock).toHaveBeenCalledTimes(1)
expect(localClearMock).toHaveBeenCalledTimes(1)
})

it('should cancel pending auto-save-to-cloud when project is disposed', async () => {
const project = makeProject()

const cloudSaveMock = vi.spyOn(cloudHelper, 'save').mockRejectedValue(undefined)

await project.startEditing('localCacheKey')
project.setAutoSaveMode(AutoSaveMode.Cloud)

const newSprite = new Sprite('newSprite')
project.addSprite(newSprite)
await flushPromises()
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
await flushPromises()
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Pending)
expect(project.hasUnsyncedChanges).toBe(true)

project.dispose()

await vi.advanceTimersByTimeAsync(1500 * 2) // wait longer to ensure auto-save does not trigger
await flushPromises()
expect(project.autoSaveToCloudState).toBe(AutoSaveToCloudState.Pending)
expect(project.hasUnsyncedChanges).toBe(true)
expect(cloudSaveMock).toHaveBeenCalledTimes(0)
})

it('should cancel pending auto-save-to-local-cache when project is disposed', async () => {
const project = makeProject()

const localSaveMock = vi.spyOn(localHelper, 'save').mockResolvedValue(undefined)

await project.startEditing('localCacheKey')
project.setAutoSaveMode(AutoSaveMode.LocalCache)

const newSprite = new Sprite('newSprite')
project.addSprite(newSprite)
await flushPromises()
await vi.advanceTimersByTimeAsync(1000) // wait for changes to be picked up
await flushPromises()
expect(project.hasUnsyncedChanges).toBe(true)

project.dispose()

await vi.advanceTimersByTimeAsync(1000 * 2) // wait longer to ensure auto-save does not trigger
await flushPromises()
expect(project.hasUnsyncedChanges).toBe(true)
expect(localSaveMock).toHaveBeenCalledTimes(0)
})
})
Loading

0 comments on commit b04b93e

Please sign in to comment.