program KanbanModelConsole; // Exercises TAtlasKanbanModel without a window: columns, cards, work limits, // moves, a veto rule, change notifications and the JSON round trip. // Every step verifies its own result; the exit code is 0 when all pass. {$APPTYPE CONSOLE} uses System.SysUtils, Atlas.Kanban; type TRules = class Vetoes, Changes: Integer; ReentryRefused: Boolean; procedure BeforeMove(Sender: TObject; const CardID, FromColumn, ToColumn: string; var Allow: Boolean); procedure Changed(Sender: TObject); end; // Cards may enter "done" only from "review". The callback also tries to // mutate the board, which the model refuses while a move is in progress. procedure TRules.BeforeMove(Sender: TObject; const CardID, FromColumn, ToColumn: string; var Allow: Boolean); begin Allow := (ToColumn <> 'done') or (FromColumn = 'review'); if not Allow then Inc(Vetoes); try TAtlasKanbanModel(Sender).RemoveCard(CardID); except on EAtlasKanban do ReentryRefused := True; end; end; procedure TRules.Changed(Sender: TObject); begin Inc(Changes); end; procedure Check(Value: Boolean; const Message: string); begin if not Value then raise Exception.Create('Check failed: ' + Message); Writeln(' ok: ', Message); end; // True when Action raises EAtlasKanban. function Refused(const Action: TProc): Boolean; begin Result := False; try Action; except on EAtlasKanban do Result := True; end; end; // The class name of the exception Action raises, or '' when it succeeds. function ErrorClass(const Action: TProc): string; begin Result := ''; try Action; except on E: Exception do Result := E.ClassName; end; end; procedure PrintBoard(Model: TAtlasKanbanModel); var C, I: Integer; Card: TAtlasKanbanCard; begin for C := 0 to Model.ColumnCount - 1 do begin Write(' ', Model.ColumnTitle(C), ' (', Model.CardCount(C)); if Model.ColumnLimit(C) > 0 then Write('/', Model.ColumnLimit(C)); Writeln(')'); for I := 0 to Model.CardCount(C) - 1 do begin Card := Model.Card(C, I); Writeln(' ', Card.ID, ': ', Card.Title); end; end; end; var Model, Copy: TAtlasKanbanModel; Rules: TRules; Before: string; Changes: Integer; begin try Model := TAtlasKanbanModel.Create(nil); Copy := TAtlasKanbanModel.Create(nil); Rules := TRules.Create; try Model.BeforeMove := Rules.BeforeMove; Model.OnChange := Rules.Changed; Writeln('Building the board'); Model.AddColumn('todo', 'To do'); Model.AddColumn('active', 'In progress', 2); Model.AddColumn('review', 'Review'); Model.AddColumn('done', 'Done'); Model.AddCard('todo', 'order-42', 'Prepare customer order', 'Pick, pack and label'); Model.AddCard('todo', 'invoice-7', 'Send invoice 7'); Model.AddCard('active', 'audit', 'Stock audit'); Model.AddCard('active', 'catalog', 'Update the catalog'); PrintBoard(Model); Check((Model.ColumnCount = 4) and (Model.CardCount(0) = 2), 'four columns, two cards waiting'); Check(Rules.Changes = 8, 'OnChange fired once per column and card'); Writeln('Validation'); Before := Model.ToJSON; Check(Refused(procedure begin Model.AddColumn('todo', 'Again'); end), 'duplicate column id refused'); Check(Refused(procedure begin Model.AddCard('todo', 'audit', 'Duplicate'); end), 'duplicate card id refused'); Check(Refused(procedure begin Model.AddCard('active', 'extra', 'Over the limit'); end), 'work limit refuses a third card'); Check(Refused(procedure begin Model.MoveCard('order-42', 'active'); end), 'work limit refuses a move into a full column'); Check(Refused(procedure begin Model.MoveCard('order-42', 'review', 5); end), 'destination index out of range'); Check(Refused(procedure begin Model.RemoveCard('nobody'); end), 'unknown card refused'); Check(Model.ToJSON = Before, 'refused operations leave the board unchanged'); Writeln('Moving cards'); Changes := Rules.Changes; Check(Model.MoveCard('audit', 'review'), 'audit goes to review'); Check(Rules.ReentryRefused, 'BeforeMove cannot mutate the board'); Check(Model.MoveCard('order-42', 'active'), 'the freed slot accepts order-42'); Check(not Model.MoveCard('catalog', 'done'), 'veto: catalog cannot skip review'); Check((Rules.Vetoes = 1) and (Model.Card(1, 0).ID = 'catalog'), 'vetoed card stays where it was'); Check(Model.MoveCard('audit', 'done'), 'audit may finish from review'); Check(Model.MoveCard('order-42', 'active', 0), 'explicit index reorders inside a column'); Check((Model.Card(1, 0).ID = 'order-42') and (Model.Card(1, 1).ID = 'catalog'), 'order-42 is first in progress'); Check(Model.MoveCard('order-42', 'active', 0), 'a move to the same place succeeds'); Check(Rules.Changes = Changes + 4, 'only real moves fire OnChange'); PrintBoard(Model); Writeln('JSON round trip'); Before := Model.ToJSON; Writeln(' ', Before); Copy.LoadJSON(Before); Check(Copy.ToJSON = Before, 'a second model loads the same board'); Check(Refused(procedure begin Model.LoadJSON('{"schema":2,"columns":[]}'); end), 'unknown schema refused'); Check(Refused(procedure begin Model.LoadJSON('not json'); end), 'invalid text refused'); Check(ErrorClass(procedure begin Model.LoadJSON('{"schema":1,"columns":[{"id":"x","title":"X","limit":0,' + '"cards":[{"id":"c","title":"C"}]}]}'); end) = 'EJSONException', 'a card without "detail" fails in the JSON reader'); Check(Model.ToJSON = Before, 'failed imports leave the board intact'); Writeln('Editing and clearing'); // There is no rename: replace the card and put it back where it was. Model.RemoveCard('catalog'); Model.AddCard('active', 'catalog', 'Update the autumn catalog', 'New prices'); Check(Model.MoveCard('catalog', 'active', 0), 'the replacement goes back to the top'); Check((Model.Card(1, 0).ID = 'catalog') and (Model.Card(1, 0).Detail = 'New prices'), 'same id, new title and detail'); Model.RemoveCard('invoice-7'); Check(Model.CardCount(0) = 0, 'removed card is gone'); Model.LoadJSON('{"schema":1,"columns":[]}'); Check(Model.ColumnCount = 0, 'loading an empty document clears the board'); Writeln('All checks passed.'); finally Rules.Free; Copy.Free; Model.Free; end; except on E: Exception do begin Writeln(E.ClassName, ': ', E.Message); Halt(1); end; end; end.