Adding modding folder with a hacked build.

This commit is contained in:
ml
2026-09-26 08:53:08 +02:00
parent cdb960dc71
commit c91080aba7
755 changed files with 50424 additions and 0 deletions
@@ -0,0 +1,91 @@
package undoable
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
// AugmentedTextService provides read/write functionality with undo capability.
type AugmentedTextService struct {
wrapped edit.AugmentedTextService
commander cmd.Commander
}
// NewAugmentedTextService returns a new instance of a service.
func NewAugmentedTextService(wrapped edit.AugmentedTextService, commander cmd.Commander) AugmentedTextService {
return AugmentedTextService{
wrapped: wrapped,
commander: commander,
}
}
// WithAudio returns true if the identified resource is one with an audio component.
func (service AugmentedTextService) WithAudio(key resource.Key) bool {
return service.wrapped.WithAudio(key)
}
// Text returns the textual value of the identified text resource.
func (service AugmentedTextService) Text(key resource.Key) string {
return service.wrapped.Text(key)
}
// RequestSetText queues the change to update the text.
func (service AugmentedTextService) RequestSetText(key resource.Key, value string, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.SetText(setter, key, value)
},
service.wrapped.RestoreTextFunc(key),
restoreFunc)
}
// Sound returns the audio value of the identified text resource.
// In case the text resource has no audio, an empty sound will be returned.
func (service AugmentedTextService) Sound(key resource.Key) audio.L8 {
return service.wrapped.Sound(key)
}
// RequestSetSound queues the change to update the sound.
func (service AugmentedTextService) RequestSetSound(key resource.Key, sound audio.L8, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.SetSound(setter, key, sound)
},
service.wrapped.RestoreSoundFunc(key),
restoreFunc)
}
// RequestClear queues the change to set both the text and the sound empty.
func (service AugmentedTextService) RequestClear(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.Clear(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestRemove queues the change to remove both the text and the sound from the storage.
func (service AugmentedTextService) RequestRemove(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter edit.AugmentedTextBlockSetter) {
service.wrapped.Remove(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
func (service AugmentedTextService) requestCommand(
forward func(modder edit.AugmentedTextBlockSetter),
reverse func(modder edit.AugmentedTextBlockSetter),
restore func()) {
c := command{
forward: func(modder world.Modder) { forward(modder) },
reverse: func(modder world.Modder) { reverse(modder) },
restore: restore,
}
service.commander.Queue(c)
}
@@ -0,0 +1,25 @@
package undoable
import (
"github.com/inkyblackness/hacked/ss1/world"
)
type command struct {
forward func(world.Modder)
reverse func(world.Modder)
restore func()
}
func (c command) Do(modder world.Modder) error {
return c.perform(modder, c.forward)
}
func (c command) Undo(modder world.Modder) error {
return c.perform(modder, c.reverse)
}
func (c command) perform(modder world.Modder, callback func(setter world.Modder)) error {
callback(modder)
c.restore()
return nil
}
@@ -0,0 +1,131 @@
package undoable
import (
"time"
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
// MovieService provides read/write functionality with undo capability.
type MovieService struct {
wrapped edit.MovieService
commander cmd.Commander
}
// NewMovieService returns a new instance of a service.
func NewMovieService(wrapped edit.MovieService, commander cmd.Commander) MovieService {
return MovieService{
wrapped: wrapped,
commander: commander,
}
}
// SizeWarning returns true if the given movie is larger than the underlying archive would support.
func (service MovieService) SizeWarning(key resource.Key) bool {
return service.wrapped.SizeWarning(key)
}
// Video returns the video component of identified movie.
func (service MovieService) Video(key resource.Key) []movie.Scene {
return service.wrapped.Video(key)
}
// RequestMoveSceneEarlier queues to move the identified scene earlier.
func (service MovieService) RequestMoveSceneEarlier(key resource.Key, scene int, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.MoveSceneEarlier(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestMoveSceneLater queues to move the identified scene later.
func (service MovieService) RequestMoveSceneLater(key resource.Key, scene int, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.MoveSceneLater(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestAddScene queues to add the given scene at the end of the movie.
func (service MovieService) RequestAddScene(key resource.Key, scene movie.HighResScene, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.AddScene(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestRemoveScene queues to remove the identified scene.
func (service MovieService) RequestRemoveScene(key resource.Key, scene int, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.RemoveScene(setter, key, scene)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestSetSceneFramesDisplayTime requests to set the display time for each frame.
func (service MovieService) RequestSetSceneFramesDisplayTime(key resource.Key,
scene int, displayTime time.Duration, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.SetSceneFramesDisplayTime(setter, key, scene, displayTime)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// Audio returns the audio component of identified movie.
func (service MovieService) Audio(key resource.Key) audio.L8 {
return service.wrapped.Audio(key)
}
// RequestSetAudio queues the change to update the audio track.
func (service MovieService) RequestSetAudio(key resource.Key, soundData audio.L8, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.SetAudio(setter, key, soundData)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// Subtitles returns the subtitles associated with the given key.
func (service MovieService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList {
return service.wrapped.Subtitles(key, language)
}
// RequestSetSubtitles queues the change to update subtitles.
func (service MovieService) RequestSetSubtitles(key resource.Key,
language resource.Language, subtitles movie.SubtitleList, restoreFunc func()) {
service.requestCommand(
func(setter media.MovieBlockSetter) {
service.wrapped.SetSubtitles(setter, key, language, subtitles)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
func (service MovieService) requestCommand(
forward func(modder media.MovieBlockSetter),
reverse func(modder media.MovieBlockSetter),
restore func()) {
c := command{
forward: func(modder world.Modder) { forward(modder) },
reverse: func(modder world.Modder) { reverse(modder) },
restore: restore,
}
service.commander.Queue(c)
}
@@ -0,0 +1,76 @@
package undoable
import (
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
// SoundEffectService provides read/write functionality with undo capability.
type SoundEffectService struct {
wrapped edit.SoundEffectService
commander cmd.Commander
}
// NewSoundEffectService returns a new instance of a service.
func NewSoundEffectService(wrapped edit.SoundEffectService, commander cmd.Commander) SoundEffectService {
return SoundEffectService{
wrapped: wrapped,
commander: commander,
}
}
// Modified returns true if the identified sound resource is marked as modified.
func (service SoundEffectService) Modified(key resource.Key) bool {
return service.wrapped.Modified(key)
}
// RequestRemove queues to erase the sound from the resources.
func (service SoundEffectService) RequestRemove(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter media.SoundEffectBlockSetter) {
service.wrapped.Remove(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// RequestClear queues to reset the identified audio resource to a silent one-sample audio.
func (service SoundEffectService) RequestClear(key resource.Key, restoreFunc func()) {
service.requestCommand(
func(setter media.SoundEffectBlockSetter) {
service.wrapped.Clear(setter, key)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
// Audio returns the audio component of identified sound effect.
func (service SoundEffectService) Audio(key resource.Key) audio.L8 {
return service.wrapped.Audio(key)
}
// RequestSetAudio queues the change to update the audio.
func (service SoundEffectService) RequestSetAudio(key resource.Key, data audio.L8, restoreFunc func()) {
service.requestCommand(
func(setter media.SoundEffectBlockSetter) {
service.wrapped.SetAudio(setter, key, data)
},
service.wrapped.RestoreFunc(key),
restoreFunc)
}
func (service SoundEffectService) requestCommand(
forward func(modder media.SoundEffectBlockSetter),
reverse func(modder media.SoundEffectBlockSetter),
restore func()) {
c := command{
forward: func(modder world.Modder) { forward(modder) },
reverse: func(modder world.Modder) { reverse(modder) },
restore: restore,
}
service.commander.Queue(c)
}
@@ -0,0 +1,15 @@
package cmd
import "github.com/inkyblackness/hacked/ss1/world"
// Command describes an action that can be performed, undone, and redone.
type Command interface {
// Do performs the action.
// An error is returned if the action could not be performed.
Do(modder world.Modder) error
// Undo reverses its action to restore the environment to the previous state.
// An error is returned if the action could not be successfully undone. The
// environment may not be in the state as before in an error occurred.
Undo(modder world.Modder) error
}
@@ -0,0 +1,6 @@
package cmd
// Commander tries to execute the given command.
type Commander interface {
Queue(command Command)
}
@@ -0,0 +1,30 @@
package cmd
import "github.com/inkyblackness/hacked/ss1/world"
// List is a sequence of commands, which are executed as one step.
type List []Command
// Do performs the entries in the list in ascending order.
// If an entry returns an error, the iteration is aborted and that error is returned.
func (list List) Do(modder world.Modder) error {
for _, entry := range list {
err := entry.Do(modder)
if err != nil {
return err
}
}
return nil
}
// Undo performs the entries in the list in descending order.
// If an entry returns an error, the iteration is aborted and that error is returned.
func (list List) Undo(modder world.Modder) error {
for i := len(list) - 1; i >= 0; i-- {
err := list[i].Undo(modder)
if err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,100 @@
package cmd
import "github.com/inkyblackness/hacked/ss1/world"
type stackEntry struct {
link *stackEntry
cmd Command
}
// Stack describes a list of commands. The stack allows to sequentially
// undo and redo stacked commands.
// It essentially stores two lists: a list of commands to undo, and
// another of commands to redo.
// Modifying stack functions will panic if they are called while already in use.
type Stack struct {
lockedBy string
undoList *stackEntry
redoList *stackEntry
}
// Perform executes the given command and puts it on the stack
// if the command was successful.
// This function also clears the list of commands to be redone.
func (stack *Stack) Perform(cmd Command, modder world.Modder) error {
stack.lock("Perform")
defer stack.unlock()
err := cmd.Do(modder)
if err != nil {
return err
}
stack.undoList = &stackEntry{link: stack.undoList, cmd: cmd}
stack.redoList = nil
return nil
}
// CanUndo returns true if there is at least one more command that can be undone.
func (stack *Stack) CanUndo() bool {
return stack.undoList != nil
}
// Undo attempts to undo the previous command on the list.
// If there is no further command to undo, nothing happens.
// An error is returned if the command failed. In this case, the stack is
// unchanged and a further attempt to undo will try the same command again.
func (stack *Stack) Undo(modder world.Modder) error {
stack.lock("Undo")
defer stack.unlock()
if stack.undoList == nil {
return nil
}
entry := stack.undoList
err := entry.cmd.Undo(modder)
if err != nil {
return err
}
stack.undoList = entry.link
entry.link = stack.redoList
stack.redoList = entry
return nil
}
// CanRedo returns true if there is at least one more command that can be redone.
func (stack *Stack) CanRedo() bool {
return stack.redoList != nil
}
// Redo attempts to perform the next command on the redo list.
// If there is no further command to redo, nothing happens.
// An error is returned if the command failed. In this case, the stack is
// unchanged and a further attempt to redo will try the same command again.
func (stack *Stack) Redo(modder world.Modder) error {
stack.lock("Redo")
defer stack.unlock()
if stack.redoList == nil {
return nil
}
entry := stack.redoList
err := entry.cmd.Do(modder)
if err != nil {
return err
}
stack.redoList = entry.link
entry.link = stack.undoList
stack.undoList = entry
return nil
}
func (stack *Stack) lock(by string) {
if stack.lockedBy != "" {
panic("Stack already in use by <" + stack.lockedBy + ">")
}
stack.lockedBy = by
}
func (stack *Stack) unlock() {
stack.lockedBy = ""
}
@@ -0,0 +1,348 @@
package cmd_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/inkyblackness/hacked/ss1"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/world"
)
type TestCommand struct {
name string
executed int
reverted int
pendingError error
task func()
}
func (cmd *TestCommand) Do(modder world.Modder) error {
cmd.executed++
return cmd.run()
}
func (cmd *TestCommand) Undo(modder world.Modder) error {
cmd.reverted++
return cmd.run()
}
func (cmd *TestCommand) run() (err error) {
if cmd.task != nil {
cmd.task()
}
err = cmd.pendingError
cmd.pendingError = nil
return
}
type StackSuite struct {
suite.Suite
stack cmd.Stack
commands map[string]*TestCommand
}
func TestStackSuite(t *testing.T) {
suite.Run(t, new(StackSuite))
}
func (suite *StackSuite) SetupTest() {
suite.stack = cmd.Stack{}
suite.commands = make(map[string]*TestCommand)
}
func (suite *StackSuite) TestNewStackCantDoAnything() {
suite.thenStackShouldNotSupportRedo()
suite.thenStackShouldNotSupportUndo()
}
func (suite *StackSuite) TestPerformExecutesCommand() {
suite.whenPerforming(suite.aCommand("cmd1"))
suite.thenCommandShouldHaveBeenExecuted("cmd1")
}
func (suite *StackSuite) TestPerformAllowsUndoIfSuccessful() {
suite.whenPerforming(suite.aCommand("cmd1"))
suite.thenStackShouldSupportUndo()
}
func (suite *StackSuite) TestPerformIgnoresCommandIfItFails() {
suite.whenPerforming(suite.aCommandReturningError())
suite.thenStackShouldNotSupportUndo()
}
func (suite *StackSuite) TestPerformReturnsErrorOfCommand() {
err := ss1.StringError("fail first time")
suite.thenPerformShouldReturnError(suite.aCommandReturning(err), err)
}
func (suite *StackSuite) TestUndoRevertsCommand() {
suite.givenCommandWasPerformed("cmd1")
suite.whenUndoing()
suite.thenCommandShouldHaveBeenReverted("cmd1")
}
func (suite *StackSuite) TestUndoRevertsCommandOnlyOnce() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.whenUndoing()
suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 1)
}
func (suite *StackSuite) TestUndoRevertsCommandsInSequence() {
suite.givenCommandWasPerformed("cmd1")
suite.givenCommandWasPerformed("cmd2")
suite.whenUndoing()
suite.thenCommandShouldHaveBeenReverted("cmd2")
suite.whenUndoing()
suite.thenCommandShouldHaveBeenReverted("cmd1")
}
func (suite *StackSuite) TestUndoLeavesStackUnchangedIfCommandFails() {
suite.givenCommandWasPerformed("cmd1")
suite.givenCommandWasPerformed("cmd2")
suite.givenCommandWillFail("cmd2")
suite.givenUndoWasCalledTimes(1)
suite.whenUndoing()
suite.thenCommandShouldHaveBeenRevertedTimes("cmd2", 2)
}
func (suite *StackSuite) TestUndoEnablesRedo() {
suite.givenCommandWasPerformed("cmd1")
suite.whenUndoing()
suite.thenStackShouldSupportRedo()
}
func (suite *StackSuite) TestRedoExecutesCommandAgain() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2)
}
func (suite *StackSuite) TestRedoExecutesCommandOnlyOnce() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.givenRedoWasCalledTimes(1)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2)
}
func (suite *StackSuite) TestRedoExecutesCommandsInSequence() {
suite.givenCommandWasPerformed("cmd1")
suite.givenCommandWasPerformed("cmd2")
suite.givenUndoWasCalledTimes(2)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd2", 2)
}
func (suite *StackSuite) TestRedoLeavesStackUnchangedIfCommandFails() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.givenCommandWillFail("cmd1")
suite.givenRedoWasCalledTimes(1)
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 3)
}
func (suite *StackSuite) TestRedoMakesCommandsUndoableAgain() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.givenRedoWasCalledTimes(1)
suite.whenUndoing()
suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 2)
}
func (suite *StackSuite) TestPerformDropsPendingRedoStack() {
suite.givenCommandWasPerformed("cmd1")
suite.givenUndoWasCalledTimes(1)
suite.whenPerforming(suite.aCommand("cmd2"))
suite.thenStackShouldNotSupportRedo()
suite.whenRedoing()
suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 1)
}
func (suite *StackSuite) TestPerformPanicsIfStackIsInUse() {
callPerform := func(name string) func() {
var times int
return func() {
require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.")
times++
assert.Panics(suite.T(), func() {
_ = suite.stack.Perform(suite.aCommand(name+"-nested"), nil)
}, "Perform during function <"+name+"> should panic")
}
}
suite.assertPanics(callPerform)
}
func (suite *StackSuite) TestUndoPanicsIfStackIsInUse() {
callUndo := func(name string) func() {
var times int
return func() {
require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.")
times++
assert.Panics(suite.T(), func() {
_ = suite.stack.Undo(nil)
}, "Undo during function <"+name+"> should panic")
}
}
suite.assertPanics(callUndo)
}
func (suite *StackSuite) TestRedoPanicsIfStackIsInUse() {
callRedo := func(name string) func() {
var times int
return func() {
require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.")
times++
assert.Panics(suite.T(), func() {
_ = suite.stack.Undo(nil)
}, "Redo during function <"+name+"> should panic")
}
}
suite.assertPanics(callRedo)
}
func (suite *StackSuite) assertPanics(taskFor func(string) func()) {
cmd1 := suite.aCommandExecuting("cmd1", taskFor("Perform"))
suite.whenPerforming(cmd1)
suite.givenCommandWasPerformed("cmd2")
suite.givenCommandExecutes("cmd2", taskFor("Undo"))
suite.whenUndoing()
suite.givenCommandWasPerformed("cmd3")
suite.givenUndoWasCalledTimes(1)
suite.givenCommandExecutes("cmd3", taskFor("Redo"))
suite.whenRedoing()
}
func (suite *StackSuite) givenCommandWasPerformed(name string) {
suite.whenPerforming(suite.aCommand(name))
}
func (suite *StackSuite) givenUndoWasCalledTimes(times int) {
for i := 0; i < times; i++ {
_ = suite.stack.Undo(nil)
}
}
func (suite *StackSuite) givenRedoWasCalledTimes(times int) {
for i := 0; i < times; i++ {
_ = suite.stack.Redo(nil)
}
}
func (suite *StackSuite) givenCommandWillFail(name string) {
suite.pastCommand(name).pendingError = ss1.StringError("failing")
}
func (suite *StackSuite) givenCommandExecutes(name string, task func()) {
cmd := suite.pastCommand(name)
cmd.task = task
}
func (suite *StackSuite) whenUndoing() {
_ = suite.stack.Undo(nil)
}
func (suite *StackSuite) whenRedoing() {
_ = suite.stack.Redo(nil)
}
func (suite *StackSuite) whenPerforming(command cmd.Command) {
_ = suite.stack.Perform(command, nil)
}
func (suite *StackSuite) thenStackShouldSupportRedo() {
assert.True(suite.T(), suite.stack.CanRedo(), "Stack should be able to redo")
}
func (suite *StackSuite) thenStackShouldNotSupportRedo() {
assert.False(suite.T(), suite.stack.CanRedo(), "Stack should not be able to redo")
}
func (suite *StackSuite) thenStackShouldSupportUndo() {
assert.True(suite.T(), suite.stack.CanUndo(), "Stack should be able to undo")
}
func (suite *StackSuite) thenStackShouldNotSupportUndo() {
assert.False(suite.T(), suite.stack.CanUndo(), "Stack should not be able to undo")
}
func (suite *StackSuite) thenCommandShouldHaveBeenExecuted(name string) {
cmd := suite.pastCommand(name)
assert.True(suite.T(), cmd.executed > 0, "Command <"+name+"> should have been executed at least once")
}
func (suite *StackSuite) thenCommandShouldHaveBeenExecutedTimes(name string, expected int) {
cmd := suite.pastCommand(name)
assert.Equal(suite.T(), expected, cmd.executed)
}
func (suite *StackSuite) thenCommandShouldHaveBeenReverted(name string) {
cmd := suite.pastCommand(name)
assert.True(suite.T(), cmd.reverted > 0, "Command <"+name+"> should have been reverted at least once")
}
func (suite *StackSuite) thenCommandShouldHaveBeenRevertedTimes(name string, expected int) {
cmd := suite.pastCommand(name)
assert.Equal(suite.T(), expected, cmd.reverted)
}
func (suite *StackSuite) thenPerformShouldReturnError(cmd cmd.Command, expected error) {
result := suite.stack.Perform(cmd, nil)
assert.Equal(suite.T(), expected, result)
}
func (suite *StackSuite) aCommand(name string) cmd.Command {
cmd := &TestCommand{name: name}
suite.commands[name] = cmd
return cmd
}
func (suite *StackSuite) aCommandExecuting(name string, task func()) cmd.Command {
cmd := &TestCommand{name: name, task: task}
suite.commands[name] = cmd
return cmd
}
func (suite *StackSuite) aCommandReturningError() cmd.Command {
return suite.aCommandReturning(ss1.StringError("fail"))
}
func (suite *StackSuite) aCommandReturning(err error) cmd.Command {
name := "unnamed"
cmd := &TestCommand{name: name, pendingError: err}
suite.commands[name] = cmd
return cmd
}
func (suite *StackSuite) pastCommand(name string) *TestCommand {
cmd, found := suite.commands[name]
if !found {
require.True(suite.T(), found, "Command not found <"+name+"> - test is wrong")
}
return cmd
}
@@ -0,0 +1,157 @@
package cmd
import (
"fmt"
"github.com/inkyblackness/hacked/ss1/world"
)
// Task represents a modification on given modder.
type Task func(modder world.Modder) error
// TransactionError is returned from a transaction execution if a task failed.
type TransactionError struct {
// Name is that of the transaction, if it was set.
Name string
// Index identifies the list index of tasks.
Index int
// Nested is the error returned from the task. In case of nested commands, this may be another TransactionError.
Nested error
}
// Error returns the path of the transaction task, combined with the nested error text.
func (err TransactionError) Error() string {
return fmt.Sprintf("%s[%d]: %v", err.Name, err.Index, err.Nested)
}
// Unwrap returns the nested error, to support diving into nested errors.
func (err TransactionError) Unwrap() error {
return err.Nested
}
// Transaction is a named list of forward and reverse tasks.
// Transaction provides execution of the tasks as a Command.
type Transaction struct {
Name string
Forward []Task
Reverse []Task
}
// Do executes the forward tasks in series. The first task to return an error aborts the iteration.
// A TransactionError is returned in case of aborted iteration.
func (txn Transaction) Do(modder world.Modder) error {
return txn.perform(txn.Forward, modder)
}
// Undo executes the reverse tasks in series. The first task to return an error aborts the iteration.
// A TransactionError is returned in case of aborted iteration.
func (txn Transaction) Undo(modder world.Modder) error {
return txn.perform(txn.Reverse, modder)
}
func (txn Transaction) perform(tasks []Task, modder world.Modder) error {
for i, task := range tasks {
err := task(modder)
if err != nil {
return TransactionError{
Name: txn.Name,
Index: i,
Nested: err,
}
}
}
return nil
}
// TransactionModifier is a function to change a transaction while it is being built.
type TransactionModifier func(*Transaction) error
// Registry allows to build commands as transactions with modifier functions.
type Registry interface {
Commander
Register(modifier ...TransactionModifier) error
}
// TransactionBuilder provides a way to register a command at a commander with
// possibly nested actions.
type TransactionBuilder struct {
Commander Commander
active *Transaction
}
// Register creates a new transaction based on given modifier.
func (builder *TransactionBuilder) Register(modifier ...TransactionModifier) error {
previous := builder.active
current := &Transaction{}
builder.active = current
for _, mod := range modifier {
err := mod(current)
if err != nil {
return err
}
}
builder.active = previous
// invert reverse task for easier iteration
for left, right := 0, len(current.Reverse)-1; left < right; left, right = left+1, right-1 {
current.Reverse[left], current.Reverse[right] = current.Reverse[right], current.Reverse[left]
}
builder.Queue(current)
return nil
}
// Queue implements the Commander interface to add given command to a currently built transaction.
// If there is no outer transaction being built, then the command is immediately forwarded to the commander.
// Avoid building a loop between TransactionBuilder instances, as this will result in a stack overflow.
func (builder *TransactionBuilder) Queue(command Command) {
if builder.active == nil {
builder.Commander.Queue(command)
return
}
builder.active.Forward = append(builder.active.Forward, command.Do)
builder.active.Reverse = append(builder.active.Reverse, command.Undo)
}
// Named allows to name the current level of transaction.
func Named(name string) TransactionModifier {
return func(txn *Transaction) error {
txn.Name = name
return nil
}
}
// Forward creates a modifier for a task that is called when doing a command.
// If a nested command is added to the transaction after the forward function, then
// the nested task is performed after the given task.
// If a nested command is added to the transaction before the forward function, then
// the nested task is performed before the given task.
func Forward(task Task) TransactionModifier {
return func(txn *Transaction) error {
txn.Forward = append(txn.Forward, task)
return nil
}
}
// Reverse creates a modifier for a task that is called when undoing a command.
// If a nested command is added to the transaction after the reverse function, then
// the nested task is performed before the given task.
// If a nested command is added to the transaction before the reverse function, then
// the nested task is performed after the given task.
func Reverse(task Task) TransactionModifier {
return func(txn *Transaction) error {
txn.Reverse = append(txn.Reverse, task)
return nil
}
}
// Nested adds a further level to the currently registered transaction.
// Depending on the order of the modifier in the register, the corresponding tasks will be called
// in sequence. See other modifier.
func Nested(nested func() error) TransactionModifier {
return func(txn *Transaction) error {
return nested()
}
}
@@ -0,0 +1,135 @@
package cmd_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/inkyblackness/hacked/ss1"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/world"
)
func TestTransactionBuilder(t *testing.T) {
suite.Run(t, new(TransactionBuilderSuite))
}
type TransactionBuilderSuite struct {
suite.Suite
builder cmd.TransactionBuilder
command cmd.Command
result []string
err error
}
func (suite *TransactionBuilderSuite) SetupTest() {
suite.builder = cmd.TransactionBuilder{Commander: suite}
suite.command = nil
suite.result = nil
suite.err = nil
}
func (suite *TransactionBuilderSuite) TestTransactionCallsForwardTasksFromLeftToRight() {
suite.givenRegisteredTransaction(
cmd.Forward(suite.aTask("fa")),
cmd.Forward(suite.aTask("fb")),
cmd.Reverse(suite.aTask("ra")),
)
suite.whenCommandIsDone()
suite.thenResultShouldBe([]string{"fa", "fb"})
}
func (suite *TransactionBuilderSuite) TestTransactionCallsReverseTasksFromRightToLeft() {
suite.givenRegisteredTransaction(
cmd.Forward(suite.aTask("fa")),
cmd.Reverse(suite.aTask("ra")),
cmd.Reverse(suite.aTask("rb")),
)
suite.whenCommandIsUndone()
suite.thenResultShouldBe([]string{"rb", "ra"})
}
func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInForwardDirection() {
suite.givenRegisteredTransaction(
cmd.Forward(suite.aTask("fa")),
cmd.Nested(func() error {
return suite.builder.Register(cmd.Forward(suite.aTask("na")))
}),
cmd.Forward(suite.aTask("fb")),
)
suite.whenCommandIsDone()
suite.thenResultShouldBe([]string{"fa", "na", "fb"})
}
func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInReverseDirection() {
suite.givenRegisteredTransaction(
cmd.Reverse(suite.aTask("ra")),
cmd.Nested(func() error {
return suite.builder.Register(cmd.Reverse(suite.aTask("na")), cmd.Reverse(suite.aTask("nb")))
}),
cmd.Reverse(suite.aTask("rb")),
)
suite.whenCommandIsUndone()
suite.thenResultShouldBe([]string{"rb", "nb", "na", "ra"})
}
func (suite *TransactionBuilderSuite) TestTransactionReturnsErrorWithNamedPath() {
suite.givenRegisteredTransaction(
cmd.Named("main"),
cmd.Forward(suite.aTask("fa")),
cmd.Nested(func() error {
return suite.builder.Register(cmd.Named("nested"), cmd.Forward(suite.aTaskReturning(ss1.StringError("fail"))))
}),
cmd.Forward(suite.aTask("fb")),
)
suite.whenCommandIsDone()
suite.thenResultShouldBeError("main[1]: nested[0]: fail")
}
func (suite *TransactionBuilderSuite) givenRegisteredTransaction(modifier ...cmd.TransactionModifier) {
err := suite.builder.Register(modifier...)
require.Nil(suite.T(), err, "no error expected registering transaction")
}
func (suite *TransactionBuilderSuite) whenCommandIsDone() {
suite.err = suite.command.Do(nil)
}
func (suite *TransactionBuilderSuite) whenCommandIsUndone() {
suite.err = suite.command.Undo(nil)
}
func (suite *TransactionBuilderSuite) thenResultShouldBe(expected []string) {
assert.Equal(suite.T(), expected, suite.result)
}
func (suite *TransactionBuilderSuite) thenResultShouldBeError(expected string) {
require.NotNil(suite.T(), suite.err, "no error returned")
var txnErr cmd.TransactionError
isTxnErr := errors.As(suite.err, &txnErr)
require.True(suite.T(), isTxnErr, "error is not a transaction error: %v", suite.err)
assert.Equal(suite.T(), expected, txnErr.Error())
assert.NotNil(suite.T(), txnErr.Nested, "no nested error set")
}
func (suite *TransactionBuilderSuite) Queue(command cmd.Command) {
require.Nil(suite.T(), suite.command, "Not expecting a second command")
suite.command = command
}
func (suite *TransactionBuilderSuite) aTask(text string) cmd.Task {
return func(modder world.Modder) error {
suite.result = append(suite.result, text)
return nil
}
}
func (suite *TransactionBuilderSuite) aTaskReturning(err error) cmd.Task {
return func(modder world.Modder) error {
return err
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package undoable contains edit extensions that provide an undo-mechanism.
package undoable