From d30c965abbe85b961c22d68c525455b27770e29d Mon Sep 17 00:00:00 2001 From: Eugene Obrezkov Date: Sat, 28 Mar 2020 20:18:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20Builder=20Pattern?= =?UTF-8?q?=20to=20Shape/Animation/Slide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With this commit I'm introducing new features in the kittik-slide package that allows you to build animations via AnimationBuilder, shapes via ShapeBuilder and slides via SlideBuilder. These builders provide you typed API that checks everything you passed as their arguments, so there is a lesser room for some errors. --- .../spec/AnimationBuilder.spec.ts | 48 +++++++++++ src/kittik-slide/spec/ShapeBuilder.spec.ts | 62 +++++++++++++ src/kittik-slide/spec/Slide.spec.ts | 51 ++++++++++- src/kittik-slide/spec/SlideBuilder.spec.ts | 62 +++++++++++++ .../src/animation/AnimationBuilder.ts | 48 +++++++++++ src/kittik-slide/src/shape/ShapeBuilder.ts | 86 +++++++++++++++++++ src/kittik-slide/src/slide/Slide.ts | 77 ++++++++++++----- src/kittik-slide/src/slide/SlideBuilder.ts | 33 +++++++ 8 files changed, 444 insertions(+), 23 deletions(-) create mode 100644 src/kittik-slide/spec/AnimationBuilder.spec.ts create mode 100644 src/kittik-slide/spec/ShapeBuilder.spec.ts create mode 100644 src/kittik-slide/spec/SlideBuilder.spec.ts create mode 100644 src/kittik-slide/src/animation/AnimationBuilder.ts create mode 100644 src/kittik-slide/src/shape/ShapeBuilder.ts create mode 100644 src/kittik-slide/src/slide/SlideBuilder.ts diff --git a/src/kittik-slide/spec/AnimationBuilder.spec.ts b/src/kittik-slide/spec/AnimationBuilder.spec.ts new file mode 100644 index 0000000..7af892a --- /dev/null +++ b/src/kittik-slide/spec/AnimationBuilder.spec.ts @@ -0,0 +1,48 @@ +import { AnimationBuilder } from '../src/animation/AnimationBuilder'; + +describe('AnimationBuilder', () => { + it('Should properly create animation from the builder', () => { + const animation = AnimationBuilder + .start('Focus') + .withType('Focus') + .withDuration(2000) + .withEasing('inBack') + .end(); + + expect(animation.toObject()).toEqual({ + type: 'Focus', + options: { + direction: 'shakeX', + duration: 2000, + easing: 'inBack', + offset: 5, + repeat: 1 + } + }); + }); + + it('Should properly build animation using withOptions()', () => { + const animation = AnimationBuilder + .start('Print') + .withOptions({ duration: 5000 }) + .end(); + + expect(animation.toObject()).toEqual({ + type: 'Print', + options: { + duration: 5000, + easing: 'outQuad' + } + }); + }); + + it('Should properly throw an error if animation is absent', () => { + expect(() => { + // This is a specific case where I check if someone tries to build not existing animation + // Though, this case was covered by types, so I need to disable it to write the test + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + AnimationBuilder.start('Nonsense').end(); + }).toThrowError('Animation "Nonsense" you tried to build does not exist'); + }); +}); diff --git a/src/kittik-slide/spec/ShapeBuilder.spec.ts b/src/kittik-slide/spec/ShapeBuilder.spec.ts new file mode 100644 index 0000000..31ec23d --- /dev/null +++ b/src/kittik-slide/spec/ShapeBuilder.spec.ts @@ -0,0 +1,62 @@ +import { ShapeBuilder } from '../src/shape/ShapeBuilder'; +import { Canvas } from 'terminal-canvas'; + +describe('ShapeBuilder', () => { + it('Should properly build the shape via builder', () => { + const shape = ShapeBuilder + .start('Rectangle') + .withCursor(Canvas.create()) + .withType('Rectangle') + .withText('Hello, World') + .withX('10%') + .withY('10%') + .withWidth('60%') + .withHeight('40%') + .withBackground('white') + .withForeground('black') + .end(); + + expect(shape.toObject()).toEqual({ + type: 'Rectangle', + options: { + background: 'white', + foreground: 'black', + height: '40%', + text: 'Hello, World', + width: '60%', + x: '10%', + y: '10%' + } + }); + }); + + it('Should properly build the shape via withOptions()', () => { + const shape = ShapeBuilder + .start('Rectangle') + .withOptions({ text: 'Hello, World' }) + .end(); + + expect(shape.toObject()).toEqual({ + type: 'Rectangle', + options: { + background: 'none', + foreground: 'none', + height: '25%', + text: 'Hello, World', + width: '50%', + x: 'left', + y: 'top' + } + }); + }); + + it('Should properly throw an error if shape is absent', () => { + expect(() => { + // This is a corner case where I check if builder throws an error when providing wrong type + // Since this case has been covered by type system of TypeScript, I need to disable it + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + ShapeBuilder.start('Nonsense').end(); + }).toThrowError('Shape "Nonsense" you tried to build does not exist'); + }); +}); diff --git a/src/kittik-slide/spec/Slide.spec.ts b/src/kittik-slide/spec/Slide.spec.ts index 95469fc..1a681d9 100644 --- a/src/kittik-slide/spec/Slide.spec.ts +++ b/src/kittik-slide/spec/Slide.spec.ts @@ -5,7 +5,8 @@ import { OrderDeclaration } from '../src/slide/OrderDeclaration'; import { ShapeDeclaration } from '../src/shape/ShapeDeclaration'; import { Slide } from '../src/slide/Slide'; import { SlideDeclaration } from '../src/slide/SlideDeclaration'; -import { TextObject } from 'kittik-shape-text'; +import { TextObject, Text } from 'kittik-shape-text'; +import { Print } from 'kittik-animation-print'; const SLIDE_DECLARATION: SlideDeclaration = { shapes: [{ @@ -152,4 +153,52 @@ describe('Core::Slide', () => { await expect(slide.render()).resolves.toBeUndefined(); }); + + it('Should properly instantiate an empty slide instance when no declaration nor cursor is passed', () => { + const slide = new Slide(); + + expect(slide.cursor).toBeInstanceOf(Canvas); + expect(slide.shapes.size).toBe(0); + expect(slide.animations.size).toBe(0); + expect(slide.order.length).toBe(0); + }); + + it('Should properly instantiate an empty slide instance when nothing is passed but an empty arrays', () => { + const slide = new Slide(undefined, { order: [], shapes: [] }); + + expect(slide.cursor).toBeInstanceOf(Canvas); + expect(slide.shapes.size).toBe(0); + expect(slide.animations.size).toBe(0); + expect(slide.order.length).toBe(0); + }); + + it('Should properly throw an error when trying to add shape that is already added', () => { + const slide = new Slide(undefined, { shapes: [{ name: 'Test', type: 'Text' }], order: [] }); + + expect(() => slide.addShape('Test', Text.create(new Canvas()))).toThrowError('Shape "Test" already exists in slide'); + }); + + it('Should properly throw an error when trying to add animation that is already added', () => { + const slide = new Slide(undefined, { shapes: [], order: [], animations: [{ name: 'Test', type: 'Print' }] }); + + expect(() => slide.addAnimation('Test', Print.create())).toThrowError('Animation "Test" already exists in slide'); + }); + + it('Should properly throw an error when trying to add ordering for the shape that is already added', () => { + const slide = new Slide(undefined, { shapes: [], order: [{ shape: 'Test' }] }); + + expect(() => slide.addOrder('Test')).toThrowError( + 'You already have an ordering for "Test"\n' + + 'Seems like it was defined somewhere before' + ); + }); + + it('Should properly add order to the slide', () => { + const slide = new Slide(); + + slide.addOrder('Test'); + + expect(slide.order.length).toBe(1); + expect(slide.order[0]).toEqual({ shape: 'Test' }); + }); }); diff --git a/src/kittik-slide/spec/SlideBuilder.spec.ts b/src/kittik-slide/spec/SlideBuilder.spec.ts new file mode 100644 index 0000000..52a9eb5 --- /dev/null +++ b/src/kittik-slide/spec/SlideBuilder.spec.ts @@ -0,0 +1,62 @@ +import { SlideBuilder } from '../src/slide/SlideBuilder'; +import { ShapeBuilder, AnimationBuilder } from '../src/slide/Slide'; + +describe('SlideBuilder', () => { + it('Should properly instantiate slide via builder', () => { + const slide = SlideBuilder + .start() + .withShape( + 'Hello, World', + ShapeBuilder + .start('Text') + .withText('Hello, World') + .withBackground('white') + .withForeground('black') + .end() + ) + .withAnimation( + 'Print', + AnimationBuilder + .start('Print') + .withDuration(2000) + .end() + ) + .withOrder('Hello, World', ['Print']) + .end(); + + expect(slide.shapes.size).toBe(1); + expect(slide.shapes.get('Hello, World')?.toObject()).toEqual({ + type: 'Text', + options: { + align: 'center', + background: 'white', + blink: false, + bold: false, + dim: false, + foreground: 'black', + height: '25%', + hidden: false, + reverse: false, + text: 'Hello, World', + underlined: false, + width: '50%', + x: 'left', + y: 'top' + } + }); + + expect(slide.animations.size).toBe(1); + expect(slide.animations.get('Print')?.toObject()).toEqual({ + type: 'Print', + options: { + duration: 2000, + easing: 'outQuad' + } + }); + + expect(slide.order).toEqual([{ + shape: 'Hello, World', + animations: ['Print'] + }]); + }); +}); diff --git a/src/kittik-slide/src/animation/AnimationBuilder.ts b/src/kittik-slide/src/animation/AnimationBuilder.ts new file mode 100644 index 0000000..606315d --- /dev/null +++ b/src/kittik-slide/src/animation/AnimationBuilder.ts @@ -0,0 +1,48 @@ +import { AnimationObject, AnimationOptions, Animationable, Easing } from 'kittik-animation-basic'; +import { AnimationType, ANIMATIONS } from './Animations'; + +export class AnimationBuilder implements AnimationObject { + type: AnimationType; + options?: Partial; + + constructor (type: AnimationType) { + this.type = type; + } + + withType (type: AnimationType): this { + this.type = type; + + return this; + } + + withOptions (options: Partial): this { + this.options = { ...this.options, ...options }; + + return this; + } + + withDuration (duration: number): this { + this.options = { ...this.options, duration }; + + return this; + } + + withEasing (easing: Easing): this { + this.options = { ...this.options, easing }; + + return this; + } + + end (): Animationable { + const ctr = ANIMATIONS.get(this.type); + if (ctr === undefined) { + throw new Error(`Animation "${this.type}" you tried to build does not exist`); + } + + return ctr.fromObject(this); + } + + static start (type: AnimationType): AnimationBuilder { + return new this(type); + } +} diff --git a/src/kittik-slide/src/shape/ShapeBuilder.ts b/src/kittik-slide/src/shape/ShapeBuilder.ts new file mode 100644 index 0000000..4941f31 --- /dev/null +++ b/src/kittik-slide/src/shape/ShapeBuilder.ts @@ -0,0 +1,86 @@ +import { ShapeObject, ShapeOptions, ShapeRenderable } from 'kittik-shape-basic'; +import { ShapeType, SHAPES } from './Shapes'; +import { Canvas } from 'terminal-canvas'; + +export class ShapeBuilder implements ShapeObject { + cursor: Canvas = Canvas.create(); + type: ShapeType; + options?: Partial; + + constructor (type: ShapeType) { + this.type = type; + } + + withCursor (cursor: Canvas): this { + this.cursor = cursor; + + return this; + } + + withType (type: ShapeType): this { + this.type = type; + + return this; + } + + withOptions (options: Partial): this { + this.options = { ...this.options, ...options }; + + return this; + } + + withText (text: string): this { + this.options = { ...this.options, text }; + + return this; + } + + withX (x: string): this { + this.options = { ...this.options, x }; + + return this; + } + + withY (y: string): this { + this.options = { ...this.options, y }; + + return this; + } + + withWidth (width: string): this { + this.options = { ...this.options, width }; + + return this; + } + + withHeight (height: string): this { + this.options = { ...this.options, height }; + + return this; + } + + withBackground (background: string): this { + this.options = { ...this.options, background }; + + return this; + } + + withForeground (foreground: string): this { + this.options = { ...this.options, foreground }; + + return this; + } + + end (): ShapeRenderable { + const ctr = SHAPES.get(this.type); + if (ctr === undefined) { + throw new Error(`Shape "${this.type}" you tried to build does not exist`); + } + + return ctr.fromObject(this, this.cursor); + } + + static start (type: ShapeType): ShapeBuilder { + return new this(type); + } +} diff --git a/src/kittik-slide/src/slide/Slide.ts b/src/kittik-slide/src/slide/Slide.ts index 12258db..4301873 100644 --- a/src/kittik-slide/src/slide/Slide.ts +++ b/src/kittik-slide/src/slide/Slide.ts @@ -8,29 +8,41 @@ import { ShapeRenderable } from 'kittik-shape-basic'; import { SHAPES, ShapeType } from '../shape/Shapes'; import { SlideDeclaration } from './SlideDeclaration'; +export { AnimationBuilder } from '../animation/AnimationBuilder'; export { AnimationDeclaration } from '../animation/AnimationDeclaration'; export { AnimationType } from '../animation/Animations'; export { OrderDeclaration } from './OrderDeclaration'; +export { ShapeBuilder } from '../shape/ShapeBuilder'; export { ShapeDeclaration } from '../shape/ShapeDeclaration'; export { ShapeType } from '../shape/Shapes'; +export { SlideBuilder } from './SlideBuilder'; export { SlideDeclaration } from './SlideDeclaration'; export class Slide { - private readonly cursor: Canvas; - private readonly shapes: Map; - private readonly animations: Map; - private readonly order: OrderDeclaration[]; - - constructor (cursor: Canvas, declaration: SlideDeclaration) { - this.cursor = cursor; - this.shapes = this.initShapes(declaration.shapes); - this.animations = this.initAnimations(declaration.animations ?? []); - this.order = declaration.order; - } + readonly cursor: Canvas = Canvas.create(); + readonly shapes: Map = new Map(); + readonly animations: Map = new Map(); + readonly order: OrderDeclaration[] = []; + + constructor (cursor?: Canvas, declaration?: SlideDeclaration) { + if (cursor !== undefined) { + this.cursor = cursor; + } + + if (declaration?.shapes !== undefined) { + this.initShapes(declaration.shapes); + } + + if (declaration?.animations !== undefined) { + this.initAnimations(declaration.animations); + } - private initShapes (declaration: ShapeDeclaration[]): Map { - const map = new Map(); + if (declaration?.order !== undefined) { + this.order = declaration.order; + } + } + private initShapes (declaration: ShapeDeclaration[]): void { declaration.forEach(shapeDeclaration => { const ctor = SHAPES.get(shapeDeclaration.type as ShapeType); @@ -38,15 +50,11 @@ export class Slide { throw new Error(`Shape "${shapeDeclaration.name}" (${shapeDeclaration.type}) is unknown for me, maybe you made a typo?`); } - map.set(shapeDeclaration.name, ctor.fromObject(shapeDeclaration, this.cursor)); + this.addShape(shapeDeclaration.name, ctor.fromObject(shapeDeclaration, this.cursor)); }); - - return map; } - private initAnimations (declaration: AnimationDeclaration[]): Map { - const map = new Map(); - + private initAnimations (declaration: AnimationDeclaration[]): void { declaration.forEach(animationDeclaration => { const ctor = ANIMATIONS.get(animationDeclaration.type as AnimationType); @@ -54,10 +62,8 @@ export class Slide { throw new Error(`Animation "${animationDeclaration.name}" (${animationDeclaration.type}) is unknown for me, maybe you made a typo?`); } - map.set(animationDeclaration.name, ctor.fromObject(animationDeclaration)); + this.addAnimation(animationDeclaration.name, ctor.fromObject(animationDeclaration)); }); - - return map; } private renderShapes (shapes: ShapeRenderable[]): void { @@ -66,6 +72,33 @@ export class Slide { this.cursor.flush(); } + addShape (name: string, shape: ShapeRenderable, toOverride = false): void { + if (this.shapes.has(name) && !toOverride) { + throw new Error(`Shape "${name}" already exists in slide`); + } + + this.shapes.set(name, shape); + } + + addAnimation (name: string, animation: Animationable, toOverride = false): void { + if (this.animations.has(name) && !toOverride) { + throw new Error(`Animation "${name}" already exists in slide`); + } + + this.animations.set(name, animation); + } + + addOrder (shape: string, animations?: string[]): void { + if (this.order.findIndex(decl => decl.shape === shape) !== -1) { + throw new Error( + `You already have an ordering for "${shape}"\n` + + 'Seems like it was defined somewhere before' + ); + } + + this.order.push({ shape, animations }); + } + async render (): Promise { const shapes = this.shapes; const animations = this.animations; diff --git a/src/kittik-slide/src/slide/SlideBuilder.ts b/src/kittik-slide/src/slide/SlideBuilder.ts new file mode 100644 index 0000000..06299c0 --- /dev/null +++ b/src/kittik-slide/src/slide/SlideBuilder.ts @@ -0,0 +1,33 @@ +import { Animationable } from 'kittik-animation-basic'; +import { ShapeRenderable } from 'kittik-shape-basic'; +import { Slide } from './Slide'; + +export class SlideBuilder { + slide: Slide = new Slide(); + + withShape (name: string, shape: ShapeRenderable): this { + this.slide.addShape(name, shape); + + return this; + } + + withAnimation (name: string, animation: Animationable): this { + this.slide.addAnimation(name, animation); + + return this; + } + + withOrder (shape: string, animations?: string[]): this { + this.slide.addOrder(shape, animations); + + return this; + } + + end (): Slide { + return this.slide; + } + + static start (): SlideBuilder { + return new this(); + } +}