diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 38b6f7e..7db8138 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -2,7 +2,7 @@ name: C/C++ CI on: push: - branches: [ master, v1 ] + branches: [ master, v1, v2 ] pull_request: branches: [ master ] @@ -10,9 +10,17 @@ jobs: build: runs-on: ubuntu-latest + env: + DISPLAY: ':99' steps: - uses: actions/checkout@v2 + - name: Setup headless environment + run: | + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & - name: make image_unittest run: make image_unittest - working-directory: graphics + working-directory: graphics/test + - name: make karel_unittest + run: make karel_unittest + working-directory: karel/src/test diff --git a/.gitignore b/.gitignore index 637d115..0ece1af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ *~ -graphics/image_unittest +graphics/test/image_unittest +graphics/test/*.bmp +!graphics/test/example_bmp.bmp +karel/src/test/karel_unittest diff --git a/LICENSE b/LICENSE index 74637fb..d47d372 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Paul Salvador Inventado +Copyright (c) 2020 Paul Salvador Inventado and Google LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 454e880..6a2b289 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,41 @@ # C++ Utils -Libraries for student C++ code. +Libraries for student C++ code available under the MIT license. -## Graphics +## Graphics: Image library -graphics/image.h can be used to create, view, load and save images. Example: +``graphics/image.h`` can be used to create, view, load and save images. Example: -![example fractal tree](graphics/example_fractal_tree.png) +![example fractal tree](graphics/test/example_fractal_tree.png) -### TODOs: -1. Set up continuous integration testing of image_unittest -2. Update this README with some instructions and a link to documentation for graphics. +``graphics/image_event.h`` allows for interactive graphical programs with animation and mouse event handling. -## Usage (for project creators): +### Usage -To use this as a submodule in another lab or project, run: +Learn more about how to use the C++ Utils graphics class in this [interactive tutorial](https://lab.cs50.io/ILXL-guides/intro-to-graphics). -``` -git submodule add -b v1 https://github.com/ILXL/cpputils -``` +## Karel the Robot in C++ -This will create a .gitmodules file which you can add and commit, pointed at a particular -branch (in this case v1). +Karel the Robot is a gentle introductory programming language created by [Professor Richard Pattis](https://www.ics.uci.edu/~pattis/) in [*Karel the Robot: A Gentle Introduction to The Art of Programming*](https://www.google.com/books/edition/_/ghcZAQAAIAAJ?hl=en&gbpv=1) and implemented in C++ here with his permission. The Karel language emphasizes logic while hiding tricky syntax, allowing anyone to begin making exciting graphical programs with very little background. -Then make sure everyone who checks out the repo gets the updates, for example adding this -command to your project Makefile to check if image.h is available: +![karel the robot screenshot](karel/src/test/karel.png) -``` -cpputils/graphics/image.h: - @git submodule update --init --recursive -``` +Karel lives in a two-dimensional grid and has a position and an orientation (north, east, south or west). Each cell of the grid may contain one or more beepers, or no beepers at all. Karel has a bag of beepers (which may be empty). Cells may be separated by walls. + +Karel has four actions: they can move forward, turn left, put down a beeper or pick up a beeper. In addition, Karel is able to check state of the area around themselves. Karel cannot move through walls or off the edge of the world. + +### Accessibility + +Karel programs may export the world to CSV after each action. This may be helpful to screen-reader users. + +Karel programs may prompt before each robot action. This may be helpful for users who need to control the speed of execution. + +### Usage + +To create Karel the Robot programs in C++, include ``karel/karel.h``. + +Learn more about how to use the C++ Utils Karel the Robot functions in this [interactive tutorial](https://lab.cs50.io/ILXL-guides/intro-to-karel) + +## For developers and instructors + +Read more about developing and including C++ Utils and unittesting assignments in the [wiki](https://github.com/ILXL/cpputils/wiki). diff --git a/graphics/Makefile b/graphics/Makefile deleted file mode 100644 index e6700e5..0000000 --- a/graphics/Makefile +++ /dev/null @@ -1,18 +0,0 @@ -.PHONY: update_cimg image_unittest - -update_cimg: - @echo -e "Getting CImg..." - @wget -q https://raw.githubusercontent.com/dtschump/CImg/master/CImg.h - @mv CImg.h cimg/ - @echo -e "CImg updated" - -/usr/lib/libgtest.a: - @echo -e "google test library not installed\n" - @echo -e "Installing cmake. Please provide the password when asked\n" - @sudo apt-get install cmake # install cmake - @echo -e "\nDownloading and installing googletest\n" - @cd /tmp/; git clone https://github.com/google/googletest.git; cd googletest; cmake CMakeLists.txt; make; sudo cp -r googletest/include/. /usr/include; sudo cp -r googlemock/include/. /usr/include; sudo cp lib/*.a /usr/lib - @echo -e "Finished installing google test library\n" - -image_unittest: /usr/lib/libgtest.a - @clang++ -std=c++17 image.cc image_unittest.cc -o image_unittest -pthread -lgtest -lm -lX11 -lpthread && ./image_unittest diff --git a/graphics/image.cc b/graphics/image.cc index eff3060..cd37217 100644 --- a/graphics/image.cc +++ b/graphics/image.cc @@ -1,10 +1,18 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "image.h" + +#include + #include #include #include -#include #include "cimg/CImg.h" -#include "image.h" using std::cout; using std::endl; @@ -13,7 +21,7 @@ using std::string; namespace graphics { namespace { - constexpr int MAX_PIXEL_VALUE = 255; +constexpr int MAX_PIXEL_VALUE = 255; } Color::Color(int red, int green, int blue) { @@ -31,11 +39,7 @@ Image::~Image() = default; Image::Image(int width, int height) { assert(width > 0 && height > 0 && "Width and height must be at least 1"); - // Quiet exception mode. - cimg::exception_mode(0); - cimage = std::make_unique>(width, height, 1, 3, MAX_PIXEL_VALUE); - width_ = width; - height_ = height; + Initialize(width, height); } bool Image::Load(const string& filename) { @@ -45,14 +49,14 @@ bool Image::Load(const string& filename) { } cimg::exception_mode(0); try { - cimage = std::make_unique>(); - cimage->load(filename.c_str()); - } catch (CImgException &e) { + cimage_ = std::make_unique>(); + cimage_->load(filename.c_str()); + } catch (CImgException& e) { cout << "Failed to open image file " << filename << endl; return false; } - width_ = cimage->width(); - height_ = cimage->height(); + width_ = cimage_->width(); + height_ = cimage_->height(); if (!IsValid()) { cout << "Invaild image file " << filename << endl; return false; @@ -60,6 +64,17 @@ bool Image::Load(const string& filename) { return true; } +bool Image::Initialize(int width, int height) { + if (width < 1 || height < 1) return false; + // Quiet exception mode. + cimg::exception_mode(0); + cimage_ = std::make_unique>(width, height, 1, 3, + MAX_PIXEL_VALUE); + width_ = width; + height_ = height; + return true; +} + bool Image::SaveImageBmp(const string& filename) const { if (!IsValid()) { return false; @@ -68,39 +83,58 @@ bool Image::SaveImageBmp(const string& filename) const { cout << "You must provide a non-empty filename" << endl; return false; } - cimage->save_bmp(filename.c_str()); + cimage_->save_bmp(filename.c_str()); return true; } -bool Image::Show(const string& title) { +bool Image::ShowForMs(int milliseconds, const std::string& title) { if (!IsValid()) return false; - if (!display) { + if (!display_) { try { - display = std::make_unique(*cimage, title.c_str()); + display_ = + std::make_unique(*cimage_, title.c_str()); } catch (CImgException& ex) { cout << "Failed to open display" << endl; return false; } } else { - display->set_title(title.c_str()); - display->show(); - display->display(*cimage); + display_->set_title("%s", title.c_str()); + display_->show(); + display_->display(*cimage_); + if (milliseconds > 0) display_->wait(milliseconds); } return true; } -bool Image::ShowUntilClosed(const string& title) { +bool Image::ShowUntilClosed(const string& title, int animation_ms) { if (!Show(title)) { return false; } - while (!display->is_closed()) display->wait(); + while (!display_->is_closed()) { + ProcessEvent(); + if (timer_ > animation_ms) { + ProcessAnimation(); + // Reset the timer. + timer_ = timer_ % animation_ms; + } + // May need to tweak this for performance. + const int kEventCheckMs = 5; + display_->wait(kEventCheckMs); + timer_ += kEventCheckMs; + } + timer_ = 0; return true; } +void Image::Flush() { + if (display_ && !display_->is_closed()) { + display_->display(*cimage_); + } +} + void Image::Hide() { - if (display && !display->is_closed()) { - display->close(); - display.reset(); + if (display_ && !display_->is_closed()) { + display_->close(); } } @@ -122,8 +156,8 @@ bool Image::SetColor(int x, int y, const Color& color) { if (!CheckPixelInBounds(x, y)) { return false; } - return SetRed(x, y, color.Red()) && SetGreen(x, y, color.Green()) - && SetBlue(x, y, color.Blue()); + return SetRed(x, y, color.Red()) && SetGreen(x, y, color.Green()) && + SetBlue(x, y, color.Blue()); } bool Image::SetRed(int x, int y, int r) { return SetPixel(x, y, 0, r); } @@ -133,13 +167,52 @@ bool Image::SetGreen(int x, int y, int g) { return SetPixel(x, y, 1, g); } bool Image::SetBlue(int x, int y, int b) { return SetPixel(x, y, 2, b); } bool Image::DrawLine(int x0, int y0, int x1, int y1, int red, int green, - int blue) { + int blue, int thickness) { const int color[] = {red, green, blue}; - if (!CheckPixelInBounds(x0, y0) || !CheckPixelInBounds(x1, y1) - || !CheckColorInBounds(color)) { + if (thickness < 1 || !CheckPixelInBounds(x0, y0) || + !CheckPixelInBounds(x1, y1) || !CheckColorInBounds(color)) { return false; } - cimage->draw_line(x0, y0, x1, y1, color); + if (x0 == x1 && y0 == y1) { + return true; + } + if (thickness == 1) { + cimage_->draw_line(x0, y0, x1, y1, color); + return true; + } + + // Swap x0 and y0 with x1 and y1 so the lower x goes first. + // Fixes test DrawsLinesWithThicknessOrderDoesntMatter. + if (x1 < x0) { + int x_tmp = x1; + int y_tmp = y1; + x1 = x0; + y1 = y0; + x0 = x_tmp; + y0 = y_tmp; + } + + // Use CImage::draw_polygon to draw a thick line. + const double diff_x = x0 - x1; + const double diff_y = y0 - y1; + const double theta = std::atan(-diff_y / diff_x); + const double hyp = thickness / 2.0; + + // Convert to integer to get nearest pixel. + const int delta_x = hyp * std::sin(theta); + const int delta_y = hyp * std::cos(theta); + + CImg points(4, 2); + points(0, 0) = x0 + delta_x; + points(0, 1) = y0 + delta_y; + points(1, 0) = x0 - delta_x; + points(1, 1) = y0 - delta_y; + points(2, 0) = x1 - delta_x; + points(2, 1) = y1 - delta_y; + points(3, 0) = x1 + delta_x; + points(3, 1) = y1 + delta_y; + + cimage_->draw_polygon(points, color); return true; } @@ -148,7 +221,7 @@ bool Image::DrawCircle(int x, int y, int radius, int red, int green, int blue) { if (!CheckPixelInBounds(x, y) || !CheckColorInBounds(color)) { return false; } - cimage->draw_circle(x, y, radius, color); + cimage_->draw_circle(x, y, radius, color); return true; } @@ -158,19 +231,71 @@ bool Image::DrawRectangle(int x, int y, int width, int height, int red, if (!CheckPixelInBounds(x, y) || !CheckColorInBounds(color)) { return false; } - cimage->draw_rectangle(x, y, x + width, y + height, color); + if (width < 0 || height < 0) { + return false; + } + cimage_->draw_rectangle(x, y, x + width - 1, y + height - 1, color); return true; } -bool Image::DrawText(int x, int y, const string& text, int font_size, int red, int green, int blue) { +bool Image::DrawText(int x, int y, const string& text, int font_size, int red, + int green, int blue) { const int color[] = {red, green, blue}; if (!CheckPixelInBounds(x, y) || !CheckColorInBounds(color)) { return false; } - cimage->draw_text(x, y, text.c_str(), color, 0, 1, font_size); + cimage_->draw_text(x, y, text.c_str(), color, 0, 1, font_size); return true; } +void Image::ProcessEvent() { + int mouse_x = display_->mouse_x(); + int mouse_y = display_->mouse_y(); + if (display_->button() & 1 && mouse_x >= 0 && mouse_y >= 0) { + // Left button has been pressed or moved. + MouseAction action; + if (latest_event_.GetMouseAction() == MouseAction::kReleased || + latest_event_.GetMouseAction() == MouseAction::kMoved) { + action = MouseAction::kPressed; + } else { + if (mouse_x == latest_event_.GetX() && mouse_y == latest_event_.GetY()) { + // Mouse position hasn't changed, so don't send a drag event. + return; + } + action = MouseAction::kDragged; + } + latest_event_ = MouseEvent(mouse_x, mouse_y, action); + for (auto listener : mouse_listeners_) { + listener->OnMouseEvent(latest_event_); + } + } else if (!(display_->button() & 1)) { + // Left button is not clicked. + if (latest_event_.GetMouseAction() == MouseAction::kDragged || + latest_event_.GetMouseAction() == MouseAction::kPressed) { + // We were dragging or pressing, send a release. + latest_event_ = MouseEvent(latest_event_.GetX(), latest_event_.GetY(), + MouseAction::kReleased); + for (auto listener : mouse_listeners_) { + listener->OnMouseEvent(latest_event_); + } + } else if ((mouse_x != latest_event_.GetX() || + mouse_y != latest_event_.GetY()) && + (mouse_x >= 0 && mouse_y >= 0)) { + // Mouse position has changed, send a move. + latest_event_ = MouseEvent(mouse_x, mouse_y, MouseAction::kMoved); + for (auto listener : mouse_listeners_) { + listener->OnMouseEvent(latest_event_); + } + } + } +} + +void Image::ProcessAnimation() { + for (auto listener : animation_listeners_) { + listener->OnAnimationStep(); + } +} + bool Image::CheckPixelInBounds(int x, int y) const { if (x < 0 || y < 0 || x >= GetWidth() || y >= GetHeight()) { cout << "(" << x << ", " << y << ") is out of bounds." << endl; @@ -190,7 +315,8 @@ bool Image::CheckColorInBounds(int value) const { bool Image::CheckColorInBounds(const int value[]) const { for (int i = 0; i < 3; i++) { if (value[i] < 0 || value[i] > MAX_PIXEL_VALUE) { - cout << value[i] << " is out of range, must be between 0 and 255." << endl; + cout << value[i] << " is out of range, must be between 0 and 255." + << endl; return false; } } @@ -199,15 +325,16 @@ bool Image::CheckColorInBounds(const int value[]) const { int Image::GetPixel(int x, int y, int channel) const { if (!CheckPixelInBounds(x, y)) return -1; - const uint8_t *r = cimage->data(x, y, channel); + const uint8_t* r = cimage_->data(x, y, channel); return static_cast(*r); } bool Image::SetPixel(int x, int y, int channel, int value) { if (!CheckPixelInBounds(x, y)) return false; if (!CheckColorInBounds(value)) return false; - uint8_t *px = cimage->data(x, y, channel); + uint8_t* px = cimage_->data(x, y, channel); *px = static_cast(value); + // Inefficient. Should we have a "flush" or similar? return true; } diff --git a/graphics/image.h b/graphics/image.h index 4e633b2..6171c5c 100644 --- a/graphics/image.h +++ b/graphics/image.h @@ -1,7 +1,16 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + #include #include +#include #include +#include "image_event.h" + #ifndef GRAPHICS_IMAGE_H #define GRAPHICS_IMAGE_H @@ -15,24 +24,53 @@ using namespace cimg_library; namespace graphics { +const int kDefaultAnimationMs = 30; + /** * Represents an RGB pixel color, where |red|, |green| and |blue| - * may be between 0 and 255, inclusive. + * may be between 0 and 255, inclusive. Default color is black. */ class Color { public: - explicit Color(int red, int green, int blue); + explicit Color(int red = 0, int green = 0, int blue = 0); + + // Copy constructor. + Color(const Color& other) { + red_ = other.Red(); + green_ = other.Green(); + blue_ = other.Blue(); + } + + // Assignment operator. + Color& operator=(const Color& other) { + red_ = other.Red(); + green_ = other.Green(); + blue_ = other.Blue(); + return *this; + } + ~Color() = default; + // Equality operator. bool operator==(const Color& other) const { - return red_ == other.Red() && green_ == other.Green() && - blue_ == other.Blue(); + return red_ == other.Red() && green_ == other.Green() && blue_ == other.Blue(); + } + + // Inequality operator. + bool operator!=(const Color& other) const { + return red_ != other.Red() || green_ != other.Green() || blue_ != other.Blue(); } + // Getters int Red() const { return red_; } int Green() const { return green_; } int Blue() const { return blue_; } + // Setters + void SetRed(int red) { red_ = red; } + void SetGreen(int green) { green_ = green; } + void SetBlue(int blue) { blue_ = blue; } + private: int red_; int green_; @@ -41,7 +79,8 @@ class Color { // Use by gtest. static void PrintTo(const Color& color, std::ostream* stream) { - *stream << "Color: (" << color.Red() << "," << color.Green() << "," << color.Blue() << ")"; + *stream << "Color: (" << color.Red() << "," << color.Green() << "," + << color.Blue() << ")"; } class Image { @@ -65,6 +104,12 @@ class Image { */ bool Load(const std::string& filename); + /* + * Resets the image to be a blank white image size |width| by |height|, + * returns false if unsuccessful (if |width| or |height| are less than 1). + */ + bool Initialize(int width, int height); + /** * Saves the current image to the file with |filename| in bitmap * format. Returns false if saving failed. @@ -74,30 +119,49 @@ class Image { /** * Shows the current image. Returns false if the image could not be shown. */ - bool Show() { - return Show("Image"); - } + bool Show() { return Show("Image"); } /** * Shows the image in a window with the title |title|. Returns false if * the image could not be shown. */ - bool Show(const std::string& title); + bool Show(const std::string& title) { return ShowForMs(0, title); } + + /** + * Shows the image in a window for |milliseconds| duration. Returns false if + * the image could not be shown, or true after |milliseconds| are ellapsed. + */ + bool ShowForMs(int milliseconds) { return ShowForMs(milliseconds, "Image"); } + + /** + * Shows the image in a window with the title |title| for |milliseconds| + * duration. Returns false if the image could not be shown, or true after + * |milliseconds| are ellapsed. + */ + bool ShowForMs(int milliseconds, const std::string& title); /** * Shows the current image until the window is closed. Returns false if * the image could not be shown. */ - bool ShowUntilClosed() { - return ShowUntilClosed("Image"); - } + bool ShowUntilClosed() { return ShowUntilClosed("Image"); } /** * Shows the current image until the window is closed. * Optional |title| for the window. Returns false if the image * could not be shown. */ - bool ShowUntilClosed(const std::string& title); + bool ShowUntilClosed(const std::string& title) { + return ShowUntilClosed(title, kDefaultAnimationMs); + } + + bool ShowUntilClosed(const std::string& title, int animation_ms); + + /** + * Refreshes the display with any update to the image. Does nothing if the + * image is not displayed. + */ + void Flush(); /** * Hides the image if it is currently being shown. @@ -107,12 +171,12 @@ class Image { /** * Returns the width of the loaded image, in pixels. */ - int GetWidth() const {return width_;} + int GetWidth() const { return width_; } /** * Returns the height of the loaded image, in pixels. */ - int GetHeight() const {return height_;} + int GetHeight() const { return height_; } /** * Gets the color at pixel at position (x, y) in the image. @@ -167,19 +231,18 @@ class Image { bool SetBlue(int x, int y, int b); /** - * Draws a line from (x0, y0) to (x1, y1) with color |color|. + * Draws a line from (x0, y0) to (x1, y1) with color |color| and optional width |thickness|. * Returns false if params are out of bounds. */ - bool DrawLine(int x0, int y0, int x1, int y1, const Color& color) { - return DrawLine(x0, y0, x1, y1, color.Red(), color.Green(), color.Blue()); + bool DrawLine(int x0, int y0, int x1, int y1, const Color& color, int thickness = 1) { + return DrawLine(x0, y0, x1, y1, color.Red(), color.Green(), color.Blue(), thickness); } /** - * Draws a line from (x0, y0) to (x1, y1) with color specified - * by |red|, |green| and |blue| channels. Returns false if params - * are out of bounds. + * Draws a line from (x0, y0) to (x1, y1) with color specified by |red|, |green| and + * |blue| channels, and optional width |thickness|. Returns false if params are out of bounds. */ - bool DrawLine(int x0, int y0, int x1, int y1, int red, int green, int blue); + bool DrawLine(int x0, int y0, int x1, int y1, int red, int green, int blue, int thickness = 1); /** * Draws a circle centered at (x, y) with radius |radius|, and color @@ -202,7 +265,8 @@ class Image { * params are out of bounds. */ bool DrawRectangle(int x, int y, int width, int height, const Color& color) { - return DrawRectangle(x, y, width, height, color.Red(), color.Green(), color.Blue()); + return DrawRectangle(x, y, width, height, color.Red(), color.Green(), + color.Blue()); } /** @@ -218,8 +282,10 @@ class Image { * with |font_size| in pixels, colored by |color|. Returns false if the * params are out of bounds. */ - bool DrawText(int x, int y, const std::string& text, int font_size, const Color& color) { - return DrawText(x, y, text, font_size, color.Red(), color.Green(), color.Blue()); + bool DrawText(int x, int y, const std::string& text, int font_size, + const Color& color) { + return DrawText(x, y, text, font_size, color.Red(), color.Green(), + color.Blue()); } /** @@ -230,11 +296,64 @@ class Image { bool DrawText(int x, int y, const std::string& text, int font_size, int red, int green, int blue); + /** + * Adds a MouseEventListener to this image. This MouseEventListener's OnMouseEvent + * function will be called whenever the display receives left-button mouse + * events. + */ + void AddMouseEventListener(MouseEventListener& listener) { + if (mouse_listeners_.find(&listener) == mouse_listeners_.end()) { + mouse_listeners_.insert(&listener); + } + } + + /** + * Removes a MouseEventListener if it was added. This MouseEventListener's + * OnMouseEvent function will no longer be called when the display receives + * mouse events. + */ + void RemoveMouseEventListener(MouseEventListener& listener) { + if (mouse_listeners_.find(&listener) != mouse_listeners_.end()) { + mouse_listeners_.erase(&listener); + } + } + + /** + * Adds a AnimationEventListener to this image. This AnimationEventListener's + * OnAnimationStep function will be called whenever the time has ellapsed + * for the next animation step. + */ + void AddAnimationEventListener(AnimationEventListener& listener) { + if (animation_listeners_.find(&listener) == animation_listeners_.end()) { + animation_listeners_.insert(&listener); + } + } + + /** + * Removes a AnimationEventListener if it was added. This + * AnimationEventListener's OnAnimationStep function will no longer be called + * every animation step. + */ + void RemoveAnimationEventListener(AnimationEventListener& listener) { + if (animation_listeners_.find(&listener) != animation_listeners_.end()) { + animation_listeners_.erase(&listener); + } + } + private: - bool IsValid() const { - return height_ > 0 && width_ > 0; + friend class TestEventGenerator; + + CImgDisplay* GetDisplayForTesting() { + if (!display_) return nullptr; + return display_.get(); } + void ProcessEvent(); + + void ProcessAnimation(); + + bool IsValid() const { return height_ > 0 && width_ > 0; } + bool CheckPixelInBounds(int x, int y) const; bool CheckColorInBounds(int value) const; @@ -247,8 +366,17 @@ class Image { int width_ = 0; int height_ = 0; - std::unique_ptr> cimage; - std::unique_ptr display; + std::unique_ptr> cimage_; + std::unique_ptr display_; + int timer_ = 0; + + // Mouse listeners. Unowned. + std::set mouse_listeners_; + + // Animation listeners. Unowned. + std::set animation_listeners_; + + MouseEvent latest_event_ = MouseEvent(0, 0, MouseAction::kReleased); }; } // namespace graphics diff --git a/graphics/image_event.h b/graphics/image_event.h new file mode 100644 index 0000000..0b5599f --- /dev/null +++ b/graphics/image_event.h @@ -0,0 +1,72 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#ifndef GRAPHICS_IMAGE_EVENT_H +#define GRAPHICS_IMAGE_EVENT_H + +namespace graphics { + +/** + * Enum representing whether a button was pressed or released. + */ +enum class MouseAction { + // Left button down. + kPressed = 0, + // Moved while left button was down. + kDragged, + // Left button up. + kReleased, + // Moved but the left button was not down. + kMoved, +}; + +/** + * Represents a left-button mouse event at a particular location within a + * displayed Image. + */ +class MouseEvent { + public: + explicit MouseEvent(int x, int y, MouseAction action) { + x_ = x; + y_ = y; + action_ = action; + } + ~MouseEvent() = default; + + int GetX() const { return x_; } + int GetY() const { return y_; } + MouseAction GetMouseAction() const { return action_; } + + private: + int x_; + int y_; + MouseAction action_; +}; + +/** + * Abstract interface for listening to MouseEvents on images. + * Use Image::AddMouseEventListener and Image::RemoveMouse EventListener to start and stop + * listening for mouse events on the Image's display, shown with Image::ShowUntilClosed(). + */ +class MouseEventListener { + public: + virtual void OnMouseEvent(const MouseEvent& event) = 0; +}; + +/** + * Abstract interface for listening to AnimationEvents on images. Add and + * remove with Image::Add/RemoveAnimationEventListener + * Use Image::ShowUntilClosed with a ms for custom animation duration, and the + * AnimationListener::OnAnimationStep() will be called at that duration. + */ +class AnimationEventListener { + public: + virtual void OnAnimationStep() = 0; +}; + +} // namespace graphics + +#endif // GRAPHICS_IMAGE_EVENT_H diff --git a/graphics/image_unittest.cc b/graphics/image_unittest.cc deleted file mode 100644 index 2654eaf..0000000 --- a/graphics/image_unittest.cc +++ /dev/null @@ -1,154 +0,0 @@ -#include -#include -#include - -#include "image.h" - -TEST(ImageTest, CreatesImageWithoutCrashing) { - graphics::Image image(100, 100); -} - -TEST(ImageTest, LoadsImageWithoutCrashing) { - graphics::Image image; - image.Load("example_fractal_tree.png"); - EXPECT_TRUE(image.GetWidth() > 0); - EXPECT_TRUE(image.GetHeight() > 0); -} - -TEST(ImageTest, NonExistantImage) { - graphics::Image image; - ASSERT_FALSE(image.Load("does_not_exist")); - - // Shouldn't crash even if we use getters and setters. - EXPECT_FALSE(image.SetRed(0, 0, 250)); - EXPECT_EQ(image.GetRed(0, 0), -1); - EXPECT_EQ(image.GetGreen(1, 1), -1); - EXPECT_EQ(image.GetBlue(-1, 1), -1); - - // Shouldn't be able to save or show the image. - EXPECT_FALSE(image.SaveImageBmp("invalid.bmp")); - - // Should do nothing but not crash. - EXPECT_FALSE(image.Show()); -} - -TEST(ImageTest, InvalidImageFile) { - graphics::Image image; - EXPECT_FALSE(image.Load("")); - EXPECT_FALSE(image.Load("image.cpp")); -} - -TEST(ImageDeathTest, InvalidSizeConstructorZero) { - ASSERT_DEATH(graphics::Image image(0, 0), ""); -} - -TEST(ImageDeathTest, InvalidSizeConstructorNegative) { - ASSERT_DEATH(graphics::Image image(10, -1), ""); -} - -TEST(ImageTest, BlankImageCreation) { - // Check size is correct. - graphics::Image image(10, 10); - EXPECT_EQ(image.GetWidth(), 10); - EXPECT_EQ(image.GetHeight(), 10); - - // Check initial color is correct. - const graphics::Color white(255, 255, 255); - for (int i = 0; i < 10; i++) { - for (int j = 0; j < 10; j++) { - EXPECT_EQ(image.GetColor(i, j), white); - } - } - - // Doesn't crash when accessing out of bounds pixels. - const graphics::Color black{0, 0, 0}; - EXPECT_EQ(image.GetColor(-1, 0), black); - EXPECT_EQ(image.GetColor(5, 50), black); -} - -TEST(ImageTest, InvalidDrawing) { - graphics::Image image(50, 50); - graphics::Color white(255, 255, 255); - - // Doesn't crash when setting an invalid color. - image.SetRed(0, 0, -1); - image.SetBlue(0, 0, 256); - - // Nothing is changed. - EXPECT_EQ(image.GetColor(0, 0), white); - - image.SetRed(0, 0, -20); - EXPECT_EQ(image.GetColor(0, 0), white); -} - -TEST(ImageTest, Drawing) { - graphics::Image image(50, 50); - graphics::Color white(255, 255, 255); - graphics::Color blue(0, 0, 255); - image.DrawCircle(20, 20, 5, blue); - // Spot check some pixels. - EXPECT_EQ(image.GetColor(20, 20), blue); - EXPECT_EQ(image.GetColor(25, 20), blue); - EXPECT_EQ(image.GetColor(26, 20), white); - - image.DrawRectangle(2, 2, 15, 10, 255, 0, 0); - for (int i = 2; i < 17; i++) { - for (int j = 2; j < 12; j++) { - EXPECT_EQ(image.GetRed(i, j), 255); - EXPECT_EQ(image.GetGreen(i, j), 0); - EXPECT_EQ(image.GetBlue(i, j), 0); - } - } - - // Drawing something out of bounds doesn't work. - image.DrawRectangle(-1, -1, 50, 50, 0, 255, 0); - EXPECT_EQ(image.GetColor(0, 0), white); - - image.DrawCircle(40, 50, 100, 0, 255, 0); - EXPECT_EQ(image.GetColor(0, 0), white); - - // Hard to test the line because of anti-aliasing. - image.DrawLine(0, 0, 40, 40, 255, 0, 0); - EXPECT_EQ(image.GetGreen(0, 0), 0); - EXPECT_EQ(image.GetBlue(0, 0), 0); - - // Drawing text works (hard to check sizing...) - image.DrawText(50, 50, "Kitties are cute", 32, 100, 200, 255); - - // Show works. - image.Show("Test image"); - - // Hide works. - image.Hide(); -} - -TEST(ImageTest, SavesAndLoadsImage) { - int size = 50; - graphics::Image image(size, size); - - // Try drawing a circle that goes off the edge to ensure - // no crashes from CImg. - image.DrawCircle(size / 2, size / 2, 40, 100, 100, 100); - std::string filename = "test_image.bmp"; - EXPECT_TRUE(image.SaveImageBmp(filename)); - - graphics::Image loaded; - EXPECT_TRUE(loaded.Load(filename)); - EXPECT_EQ(loaded.GetWidth(), image.GetWidth()); - EXPECT_EQ(loaded.GetHeight(), image.GetHeight()); - - // Check every pixel is correct. - for (int i = 0; i < size; i++) { - for (int j = 0; j < size; j++) { - EXPECT_EQ(image.GetColor(i, j), loaded.GetColor(i, j)); - } - } - - // Delete the result. - remove(filename.c_str()); -} - -int main(int argc, char **argv) { - testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/graphics/test/Makefile b/graphics/test/Makefile new file mode 100644 index 0000000..542e467 --- /dev/null +++ b/graphics/test/Makefile @@ -0,0 +1,54 @@ +# Copyright 2020 Paul Salvador Inventado and Google LLC +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +.PHONY: update_cimg image_unittest + +OS_NAME := $(shell uname -s | tr A-Z a-z) +SHELL := /bin/bash +HAS_GTEST := $(shell echo -e "int main() { }" >> test.cc ; clang++ test.cc -o test -lgtest 2> /dev/null; echo $$?; rm -f test.cc test;) +COMPILE_FLAGS := -lm -lX11 -lpthread + +ifeq ($(OS_NAME), darwin) + COMPILE_FLAGS := -lm -lpthread -lX11 -I/usr/X11R6/include -L/usr/X11R6/lib + HAS_BREW := $(shell command -v brew 2> /dev/null) +endif + +update_cimg: + @echo -e "Getting CImg..." + @wget -q https://raw.githubusercontent.com/dtschump/CImg/master/CImg.h + @mv CImg.h ../cimg/ + @echo -e "CImg updated" + +install_gtest: +ifeq ($(HAS_GTEST),1) + @echo -e "google test not installed\n" +ifeq ($(OS_NAME), darwin) +ifndef ($(HAS_BREW)) + @echo -e "Installing brew, please provide your password when asked.\n" + @/bin/bash -c "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)" + @brew install coreutils + # Need to redefine the path directories otherwise this will fail the first time. +endif + @echo -e "Installing cmake. Please provide the password when asked\n" + @brew install cmake + @echo -e "\nDownloading and installing googletest\n" + @cd /tmp/; git clone https://github.com/google/googletest; cd googletest; mkdir build; cd build; cmake .. -DCMAKE_CXX_STANDARD=17; make; make install + @echo -e "Finished installing google test\n" +else +ifneq ($(shell lsb_release -sr), 20.04) + @cd /tmp/; git clone https://github.com/google/googletest.git; cd googletest; cmake CMakeLists.txt; make; sudo cp -r googletest/include/. /usr/include; sudo cp -r googlemock/include/. /usr/include; sudo cp lib/*.a /usr/lib +else + @echo -e "Installing cmake. Please provide the password when asked\n" + @sudo apt-get install cmake # install cmake + @echo -e "\nDownloading and installing googletest\n" + @sudo apt-get install libgtest-dev libgmock-dev + @echo -e "Finished installing google test\n" +endif +endif +endif + +image_unittest: install_gtest + @clang++ -std=c++17 ../image.cc image_unittest.cc -o image_unittest -pthread -lgtest $(COMPILE_FLAGS) && ./image_unittest diff --git a/graphics/test/example_bmp.bmp b/graphics/test/example_bmp.bmp new file mode 100644 index 0000000..0ced88e Binary files /dev/null and b/graphics/test/example_bmp.bmp differ diff --git a/graphics/example_fractal_tree.png b/graphics/test/example_fractal_tree.png similarity index 100% rename from graphics/example_fractal_tree.png rename to graphics/test/example_fractal_tree.png diff --git a/graphics/test/image_test_utils.h b/graphics/test/image_test_utils.h new file mode 100644 index 0000000..d1d3b42 --- /dev/null +++ b/graphics/test/image_test_utils.h @@ -0,0 +1,97 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include +#include + +#include "../image.h" + +#ifndef IMAGE_TEST_UTILS_H +#define IMAGE_TEST_UTILS_H + +enum DiffType { + kTypeOverlay, + kTypeHighlight, + kTypeSideBySide, +}; + +/* + * Returns true if the image |expected| and |actual| are a pixel perfect + * match. If they are not a perfect match, saves a new image in |output_file| + * depending on the |diff_type| chosen, and returns false. + */ +bool ImagesMatch(graphics::Image* expected, graphics::Image* actual, + std::string output_file, DiffType diff_type) { + int width = expected->GetWidth(); + int height = expected->GetHeight(); + if (width != actual->GetWidth() || height != actual->GetHeight()) { + std::cout << "Images are different dimensions. Expected: " << width + << " by " << height << "px" << std::endl; + return false; + } + + // Create the output image. If we want a side-by-side comparison, it + // has twice the width. + graphics::Image result(diff_type == kTypeSideBySide ? width * 2 : width, + height); + bool matching = true; + for (int i = 0; i < width; i++) { + for (int j = 0; j < height; j++) { + graphics::Color c_actual = actual->GetColor(i, j); + graphics::Color c_expected = expected->GetColor(i, j); + if (c_actual.Red() != c_expected.Red() || + c_actual.Green() != c_expected.Green() || + c_actual.Blue() != c_expected.Blue()) { + matching = false; + if (diff_type == kTypeHighlight) { + // Saturate the red in the result where the channels + // differ. This is good if the diff is likely to be + // small or in one particular region. + graphics::Color red(255, 0, 0); + result.SetColor(i, j, red); + } else if (diff_type == kTypeOverlay) { + // Overlay by averaging pixels. This is good for comparing + // line drawings or shapes, but harder to look at when + // comparing pictures. + result.SetRed(i, j, (c_actual.Red() + c_expected.Red()) / 2); + result.SetGreen(i, j, (c_actual.Green() + c_expected.Green()) / 2); + result.SetBlue(i, j, (c_actual.Blue() + c_expected.Blue()) / 2); + } + } else if (diff_type != kTypeSideBySide) { + result.SetColor(i, j, c_actual); + } + if (diff_type == kTypeSideBySide) { + result.SetColor(i + width, j, c_actual); + result.SetColor(i, j, c_expected); + } + } + } + + if (matching) return true; + std::cout << "Images do not match. See " << output_file << " for diff." + << std::endl; + result.SaveImageBmp(output_file); + return false; +} + +/* + * Returns true if the file in |expected_file| and in |actual_file| + * are a pixel perfect match. If they are not a perfect match, saves + * a new image in |output_file| depending on the |diff_type| chosen, + * and returns false. + */ +bool ImagesMatch(std::string expected_file, std::string actual_file, + std::string output_file, DiffType diff_type) { + graphics::Image expected; + if (!expected.Load(expected_file)) return false; + graphics::Image actual; + if (!actual.Load(actual_file)) return false; + + bool result = ImagesMatch(&expected, &actual, output_file, diff_type); + return result; +} + +#endif // IMAGE_TEST_UTILS_H diff --git a/graphics/test/image_unittest.cc b/graphics/test/image_unittest.cc new file mode 100644 index 0000000..d52285f --- /dev/null +++ b/graphics/test/image_unittest.cc @@ -0,0 +1,386 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "../image.h" + +#include +#include + +#include + +#include "image_test_utils.h" +#include "test_event_generator.h" + +TEST(ImageTest, CreatesImageWithoutCrashing) { + graphics::Image image(100, 100); +} + +TEST(ImageTest, LoadsImageWithoutCrashing) { + graphics::Image image; + image.Load("example_bmp.bmp"); + EXPECT_TRUE(image.GetWidth() > 0); + EXPECT_TRUE(image.GetHeight() > 0); +} + +TEST(ImageTest, NonExistantImage) { + graphics::Image image; + ASSERT_FALSE(image.Load("does_not_exist")); + + // Shouldn't crash even if we use getters and setters. + EXPECT_FALSE(image.SetRed(0, 0, 250)); + EXPECT_EQ(image.GetRed(0, 0), -1); + EXPECT_EQ(image.GetGreen(1, 1), -1); + EXPECT_EQ(image.GetBlue(-1, 1), -1); + + // Shouldn't be able to save or show the image. + EXPECT_FALSE(image.SaveImageBmp("invalid.bmp")); + + // Should do nothing but not crash. + EXPECT_FALSE(image.Show()); +} + +TEST(ImageTest, InvalidImageFile) { + graphics::Image image; + EXPECT_FALSE(image.Load("")); + EXPECT_FALSE(image.Load("image.cpp")); +} + +TEST(ImageDeathTest, InvalidSizeConstructorZero) { + ASSERT_DEATH(graphics::Image image(0, 0), ""); +} + +TEST(ImageDeathTest, InvalidSizeConstructorNegative) { + ASSERT_DEATH(graphics::Image image(10, -1), ""); +} + +TEST(ColorTest, ColorOperators) { + graphics::Color black(0, 0, 0); + graphics::Color red(255, 0, 0); + ASSERT_NE(black, red); + ASSERT_EQ(red, graphics::Color(255, 0, 0)); + + graphics::Color red_copy = red; + ASSERT_EQ(red, red_copy); + + graphics::Color& red_ref = red; + red_ref.SetBlue(255); + ASSERT_EQ(red.Blue(), 255); +} + +TEST(ImageTest, BlankImageCreation) { + // Check size is correct. + graphics::Image image(10, 10); + EXPECT_EQ(image.GetWidth(), 10); + EXPECT_EQ(image.GetHeight(), 10); + + // Check initial color is correct. + const graphics::Color white(255, 255, 255); + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + EXPECT_EQ(image.GetColor(i, j), white); + } + } + + // Doesn't crash when accessing out of bounds pixels. + const graphics::Color black{0, 0, 0}; + EXPECT_EQ(image.GetColor(-1, 0), black); + EXPECT_EQ(image.GetColor(5, 50), black); +} + +TEST(ImageTest, InvalidDrawing) { + graphics::Image image(50, 50); + graphics::Color white(255, 255, 255); + + // Doesn't crash when setting an invalid color. + image.SetRed(0, 0, -1); + image.SetBlue(0, 0, 256); + + // Nothing is changed. + EXPECT_EQ(image.GetColor(0, 0), white); + + image.SetRed(0, 0, -20); + EXPECT_EQ(image.GetColor(0, 0), white); +} + +TEST(ImageTest, Drawing) { + graphics::Image image(50, 50); + graphics::Color white(255, 255, 255); + graphics::Color blue(0, 0, 255); + image.DrawCircle(20, 20, 5, blue); + // Spot check some pixels. + EXPECT_EQ(image.GetColor(20, 20), blue); + EXPECT_EQ(image.GetColor(25, 20), blue); + EXPECT_EQ(image.GetColor(26, 20), white); + + image.DrawRectangle(2, 2, 15, 10, 255, 0, 0); + for (int i = 2; i < 17; i++) { + for (int j = 2; j < 12; j++) { + EXPECT_EQ(image.GetRed(i, j), 255); + EXPECT_EQ(image.GetGreen(i, j), 0); + EXPECT_EQ(image.GetBlue(i, j), 0); + } + } + + // Drawing something out of bounds doesn't work. + image.DrawRectangle(-1, -1, 50, 50, 0, 255, 0); + EXPECT_EQ(image.GetColor(0, 0), white); + + image.DrawCircle(40, 50, 100, 0, 255, 0); + EXPECT_EQ(image.GetColor(0, 0), white); + + // Hard to test the line because of anti-aliasing. + image.DrawLine(0, 0, 40, 40, 255, 0, 0); + EXPECT_EQ(image.GetGreen(0, 0), 0); + EXPECT_EQ(image.GetBlue(0, 0), 0); + + // Drawing text works (hard to check sizing...) + image.DrawText(50, 50, "Kitties are cute", 32, 100, 200, 255); + + // Show works. + EXPECT_TRUE(image.Show("Test image")); + + // Hide works. + image.Hide(); +} + +TEST(ImageTest, SavesAndLoadsImage) { + int size = 50; + graphics::Image image(size, size); + + // Try drawing a circle that goes off the edge to ensure + // no crashes from CImg. + image.DrawCircle(size / 2, size / 2, 40, 100, 100, 100); + std::string filename = "test_image.bmp"; + EXPECT_TRUE(image.SaveImageBmp(filename)); + + graphics::Image loaded; + EXPECT_TRUE(loaded.Load(filename)); + EXPECT_EQ(loaded.GetWidth(), image.GetWidth()); + EXPECT_EQ(loaded.GetHeight(), image.GetHeight()); + + // Check every pixel is correct. + for (int i = 0; i < size; i++) { + for (int j = 0; j < size; j++) { + EXPECT_EQ(image.GetColor(i, j), loaded.GetColor(i, j)); + } + } + + // Delete the result. + remove(filename.c_str()); +} + +TEST(ImageTest, DrawsLinesWithThickness) { + remove("DrawsLinesWithThicknessHorizontal.bmp"); + remove("DrawsLinesWithThicknessVertical.bmp"); + + // Use an odd size thickness. Even size thickness isn't evenly divisible + // by 2, meaning we can't create a rectangle of the right size. + int thickness = 21; + int size = 100; + graphics::Color blue(0, 0, 255); + + graphics::Image expected(size, size); + graphics::Image actual(size, size); + + // Horizontal rectangle is the same as a thick line. + expected.DrawRectangle(10, 40, 81, thickness, blue); + actual.DrawLine(10, 50, 90, 50, blue, thickness); + EXPECT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessHorizontal.bmp", + DiffType::kTypeHighlight)); + + // Vertical rectangle is the same as a thick line. + expected.DrawRectangle(40, 5, thickness, 91, blue); + actual.DrawLine(50, 5, 50, 95, blue, thickness); + EXPECT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessVertical.bmp", + DiffType::kTypeHighlight)); + + // 45 degree rectangle is as thick as expected. Check some points that + // wouldn't be colored unless the line had appropriate thickness. + graphics::Color green(0, 255, 0); + actual.DrawLine(0, 0, size - 1, size - 1, green, thickness); + EXPECT_EQ(actual.GetColor(thickness / 2, 0), green); + EXPECT_EQ(actual.GetColor(0, thickness / 2), green); + EXPECT_EQ(actual.GetColor(size / 2, size / 2 - thickness / 2), green); + EXPECT_EQ(actual.GetColor(size / 2, size / 2 + thickness / 2), green); + + // But not too wide! + EXPECT_NE(actual.GetColor(size / 2, + size / 2 + std::sqrt(2 * thickness * thickness)), + green); +} + +TEST(ImageTest, DrawsLinesWithThicknessOrderDoesntMatter) { + remove("DrawsLinesWithThicknessOrderDiagonal1.bmp"); + remove("DrawsLinesWithThicknessOrderDiagonal2.bmp"); + remove("DrawsLinesWithThicknessOrderDiagonal3.bmp"); + remove("DrawsLinesWithThicknessOrderDiagonal4.bmp"); + remove("DrawsLinesWithThicknessOrderHorizontal.bmp"); + remove("DrawsLinesWithThicknessOrderVertical.bmp"); + + int thickness = 10; + int size = 100; + graphics::Color blue(0, 0, 255); + graphics::Color green(0, 255, 0); + graphics::Color black(0, 0, 0); + + graphics::Image expected(size, size); + graphics::Image actual(size, size); + + // Expected has smaller x0 than x1, smaller y0 than y1. + expected.DrawLine(20, 31, 17, 80, blue, thickness); + actual.DrawLine(17, 80, 20, 31, blue, thickness); + ASSERT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessOrderDiagonal1.bmp", + DiffType::kTypeHighlight)); + + // Expected has bigger x0 than x1, smaller y0 than y1. + expected.DrawLine(77, 20, 80, 80, black, thickness); + actual.DrawLine(80, 80, 77, 20, black, thickness); + ASSERT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessOrderDiagonal2.bmp", + DiffType::kTypeHighlight)); + + // Expected has smaller x0 than x1, bigger y0 than y1. + expected.DrawLine(10, 78, 80, 13, black, thickness); + actual.DrawLine(80, 13, 10, 78, black, thickness); + ASSERT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessOrderDiagonal3.bmp", + DiffType::kTypeHighlight)); + + // Expected has bigger x0 than x1, bigger y0 than y1. + expected.DrawLine(57, 80, 80, 20, blue, thickness); + actual.DrawLine(80, 20, 57, 80, blue, thickness); + ASSERT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessOrderDiagonal4.bmp", + DiffType::kTypeHighlight)); + + // Horizontal rectangle is the same as a thick line. + expected.DrawLine(10, 50, 90, 50, green, thickness); + actual.DrawLine(90, 50, 10, 50, green, thickness); + ASSERT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessOrderHorizontal.bmp", + DiffType::kTypeHighlight)); + + // Vertical rectangle is the same as a thick line. + expected.DrawLine(50, 5, 50, 95, black, thickness); + actual.DrawLine(50, 95, 50, 5, black, thickness); + ASSERT_TRUE(ImagesMatch(&expected, &actual, + "DrawsLinesWithThicknessOrderVertical.bmp", + DiffType::kTypeHighlight)); +} + +class TestEventListener : public graphics::MouseEventListener { + public: + TestEventListener() = default; + ~TestEventListener() = default; + void OnMouseEvent(const graphics::MouseEvent& event) override { + latest_event_ = graphics::MouseEvent(event.GetX(), event.GetY(), + event.GetMouseAction()); + } + + graphics::MouseEvent GetLatestEvent() { return latest_event_; } + + private: + graphics::MouseEvent latest_event_ = + graphics::MouseEvent(0, 0, graphics::MouseAction::kReleased); +}; + +// We can send fake events if we have a reference to the image on which to send +// events, and we are not in the ShowUntilClosed loop. +TEST(ImageEventTest, HandlesEvents) { + TestEventListener listener; + int size = 100; + graphics::Image image(size, size); + image.AddMouseEventListener(listener); + image.Show(); + + graphics::TestEventGenerator generator(&image); + generator.MouseDown(10, 20); + EXPECT_EQ(listener.GetLatestEvent().GetX(), 10); + EXPECT_EQ(listener.GetLatestEvent().GetY(), 20); + EXPECT_EQ(listener.GetLatestEvent().GetMouseAction(), + graphics::MouseAction::kPressed); + + generator.MoveMouseTo(30, 80); + EXPECT_EQ(listener.GetLatestEvent().GetX(), 30); + EXPECT_EQ(listener.GetLatestEvent().GetY(), 80); + EXPECT_EQ(listener.GetLatestEvent().GetMouseAction(), + graphics::MouseAction::kDragged); + + generator.MoveMouseTo(90, 80); + EXPECT_EQ(listener.GetLatestEvent().GetX(), 90); + EXPECT_EQ(listener.GetLatestEvent().GetY(), 80); + EXPECT_EQ(listener.GetLatestEvent().GetMouseAction(), + graphics::MouseAction::kDragged); + + generator.MouseUp(); + EXPECT_EQ(listener.GetLatestEvent().GetX(), 90); + EXPECT_EQ(listener.GetLatestEvent().GetY(), 80); + EXPECT_EQ(listener.GetLatestEvent().GetMouseAction(), + graphics::MouseAction::kReleased); + + generator.MoveMouseTo(30, 30); + // Mouse moved event. + EXPECT_EQ(listener.GetLatestEvent().GetX(), 30); + EXPECT_EQ(listener.GetLatestEvent().GetY(), 30); + EXPECT_EQ(listener.GetLatestEvent().GetMouseAction(), + graphics::MouseAction::kMoved); + + // Ensure other mouse buttons don't interfere. + generator.MouseDown(10, 20); + generator.RightMouseDown(); + generator.RightMouseUp(); + EXPECT_EQ(listener.GetLatestEvent().GetX(), 10); + EXPECT_EQ(listener.GetLatestEvent().GetY(), 20); + EXPECT_EQ(listener.GetLatestEvent().GetMouseAction(), + graphics::MouseAction::kPressed); + generator.RightMouseDown(); + generator.MouseUp(); + EXPECT_EQ(listener.GetLatestEvent().GetMouseAction(), + graphics::MouseAction::kReleased); + + image.RemoveMouseEventListener(listener); + image.Hide(); +} + +class TestAnimationEventListener : public graphics::AnimationEventListener { + public: + TestAnimationEventListener() = default; + ~TestAnimationEventListener() = default; + void OnAnimationStep() override { num_events_++; } + + int GetNumEvents() { return num_events_; } + + private: + int num_events_ = 0; +}; + +TEST(AnimationEventTest, CallsAnimationListeners) { + TestAnimationEventListener listener; + ASSERT_EQ(0, listener.GetNumEvents()); + graphics::Image image(50, 50); + graphics::TestEventGenerator generator(&image); + image.Show(); + + image.AddAnimationEventListener(listener); + ASSERT_EQ(0, listener.GetNumEvents()); + generator.SendAnimationEvent(); + ASSERT_EQ(1, listener.GetNumEvents()); + generator.SendAnimationEvent(); + ASSERT_EQ(2, listener.GetNumEvents()); + image.RemoveAnimationEventListener(listener); + + // Actually removed. + generator.SendAnimationEvent(); + ASSERT_EQ(2, listener.GetNumEvents()); +} + +int main(int argc, char** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/graphics/test/test_event_generator.h b/graphics/test/test_event_generator.h new file mode 100644 index 0000000..509dd17 --- /dev/null +++ b/graphics/test/test_event_generator.h @@ -0,0 +1,66 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "../cimg/CImg.h" +#include "../image.h" + +#ifndef GRAPHICS_TEST_EVENT_GENERATOR_H +#define GRAPHICS_TEST_EVENT_GENERATOR_H + +namespace graphics { + +class TestEventGenerator { + public: + TestEventGenerator(graphics::Image* image) { image_ = image; } + + void MouseDown(int x, int y) { + if (!image_->GetDisplayForTesting()) return; + cimg_library::CImgDisplay* display = image_->GetDisplayForTesting(); + display->set_mouse(x, y); + display->set_button(1, true /* is pressed */); + image_->ProcessEvent(); + } + + void MoveMouseTo(int x, int y) { + if (!image_->GetDisplayForTesting()) return; + cimg_library::CImgDisplay* display = image_->GetDisplayForTesting(); + display->set_mouse(x, y); + image_->ProcessEvent(); + } + + void MouseUp() { + if (!image_->GetDisplayForTesting()) return; + cimg_library::CImgDisplay* display = image_->GetDisplayForTesting(); + display->set_button(1, false /* is pressed*/); + image_->ProcessEvent(); + } + + void RightMouseDown() { + if (!image_->GetDisplayForTesting()) return; + cimg_library::CImgDisplay* display = image_->GetDisplayForTesting(); + display->set_button(0x2, true /* is pressed*/); + image_->ProcessEvent(); + } + + void RightMouseUp() { + if (!image_->GetDisplayForTesting()) return; + cimg_library::CImgDisplay* display = image_->GetDisplayForTesting(); + display->set_button(0x2, false /* is pressed*/); + image_->ProcessEvent(); + } + + void SendAnimationEvent() { + if (!image_->GetDisplayForTesting()) return; + image_->ProcessAnimation(); + } + + private: + graphics::Image* image_; // Unowned +}; + +} // namespace graphics + +#endif // GRAPHICS_TEST_EVENT_GENERATOR_H diff --git a/karel/karel.cc b/karel/karel.cc new file mode 100644 index 0000000..17f6636 --- /dev/null +++ b/karel/karel.cc @@ -0,0 +1,117 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "karel.h" + +#include "src/robot.h" + +void LoadWorld(std::string filename) { + try { + karel::Robot& r = karel::Robot::InitializeInstance(filename); + } catch (std::string error) { + std::cout << "Error loading world file " << filename << ": \n" + << error << std::endl; + exit(1); + } +} + +void Move() { + karel::Robot& r = karel::Robot::GetInstance(); + r.Move(); +} + +void TurnLeft() { + karel::Robot& r = karel::Robot::GetInstance(); + r.TurnLeft(); +} + +void PutBeeper() { + karel::Robot& r = karel::Robot::GetInstance(); + r.PutBeeper(); +} + +void PickBeeper() { + karel::Robot& r = karel::Robot::GetInstance(); + r.PickBeeper(); +} + +bool HasBeepersInBag() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.HasBeepersInBag(); +} + +bool NoBeepersInBag() { return !HasBeepersInBag(); } + +bool BeepersPresent() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.BeepersPresent(); +} + +bool NoBeepersPresent() { return !BeepersPresent(); } + +bool FrontIsClear() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.FrontIsClear(); +} + +bool FrontIsBlocked() { return !FrontIsClear(); } + +bool LeftIsClear() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.LeftIsClear(); +} + +bool LeftIsBlocked() { return !LeftIsClear(); } + +bool RightIsClear() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.RightIsClear(); +} + +bool RightIsBlocked() { return !RightIsClear(); } + +bool FacingNorth() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.FacingNorth(); +} + +bool NotFacingNorth() { return !FacingNorth(); } + +bool FacingEast() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.FacingEast(); +} + +bool NotFacingEast() { return !FacingEast(); } + +bool FacingSouth() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.FacingSouth(); +} + +bool NotFacingSouth() { return !FacingSouth(); } + +bool FacingWest() { + karel::Robot& r = karel::Robot::GetInstance(); + return r.FacingWest(); +} + +bool NotFacingWest() { return !FacingWest(); } + +void Finish() { + karel::Robot& r = karel::Robot::GetInstance(); + r.Finish(); +} + +void EnableCSVOutput() { + karel::Robot& r = karel::Robot::GetInstance(); + r.EnableCSVOutput(); +} + +void EnablePromptBeforeAction() { + karel::Robot& r = karel::Robot::GetInstance(); + r.EnablePromptBeforeAction(); +} diff --git a/karel/karel.h b/karel/karel.h new file mode 100644 index 0000000..9970266 --- /dev/null +++ b/karel/karel.h @@ -0,0 +1,169 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include + +///////////////////////////////////////////////////////// +// Methods for core Karel functionality // +// These methods allow interaction with Karel. // +///////////////////////////////////////////////////////// + +/** + * Move Karel forward one step. Results in an error if Karel cannot move + * forward because of a wall or an edge. + */ +void Move(); + +/** + * Turns Karel to the left. + */ +void TurnLeft(); + +/** + * Places a beeper from Karel's beeper bag onto the current cell where Karel + * is. Results in an error if Karel has no beepers left in their bag. + */ +void PutBeeper(); + +/** + * Picks a beeper from the cell where Karel is standing and places it in + * their beeper bag. Results in an error if there are no beepers in Karel's + * current cell. + */ +void PickBeeper(); + +/** + * Returns true if Karel has beepers in their bag, or false otherwise. + */ +bool HasBeepersInBag(); + +/** + * Returns true if Karel has no beepers in their bag, or true otherwise. + */ +bool NoBeepersInBag(); + +/** + * Returns true if Karel is standing on a cell with at least one beeper, or + * false otherwise. + */ +bool BeepersPresent(); + +/** + * Returns true if Karel is standing on a cell no beepers at all, or + * false otherwise. + */ +bool NoBeepersPresent(); + +/** + * Returns true if there is no wall nor edge in front of Karel and they could + * move forward, or false otherwise. + */ +bool FrontIsClear(); + +/** + * Returns true if there is a wall or edge in front of Karel or false otherwise. + */ +bool FrontIsBlocked(); + +/** + * Returns true if there is no wall nor edge directly to Karel's left, or false + * otherwise. + */ +bool LeftIsClear(); + +/** + * Returns true if there is a wall or edge directly to Karel's left, or false + * otherwise. + */ +bool LeftIsBlocked(); + +/** + * Returns true if there is no wall nor edge directly to Karel's right, or false + * otherwise. + */ +bool RightIsClear(); + +/** + * Returns true if there is a wall or edge directly to Karel's right or false + * otherwise. + */ +bool RightIsBlocked(); + +/** + * Returns true if Karel is facing north or false otherwise. + */ +bool FacingNorth(); + +/** + * Returns true if Karel is not facing north or false otherwise. + */ +bool NotFacingNorth(); + +/** + * Returns true if Karel is facing east or false otherwise. + */ +bool FacingEast(); + +/** + * Returns true if Karel is not facing east or false otherwise. + */ +bool NotFacingEast(); + +/** + * Returns true if Karel is facing south or false otherwise. + */ +bool FacingSouth(); + +/** + * Returns true if Karel is facing south or false otherwise. + */ +bool NotFacingSouth(); + +/** + * Returns true if Karel is facing west or false otherwise. + */ +bool FacingWest(); + +/** + * Returns true if Karel is not facing west or false otherwise. + */ +bool NotFacingWest(); + +///////////////////////////////////////////////////////////////////////////// +// Methods for setting up a Karel program. // +// Instructors may use these methods wrapping student's Karel commands. // +///////////////////////////////////////////////////////////////////////////// + +/** + * Loads a Karel world from a file. + * If this is not the first Karel function called a default world will be + * created instead and this will have no effect. + */ +void LoadWorld(std::string filename); + +/** + * Completes a Karel program. Continues to show the image but will not + * do any more actions. + */ +void Finish(); + +///////////////////////////////////////////////////////////////////////////// +// Methods for improving the accessibility of Karel // +///////////////////////////////////////////////////////////////////////////// + +/** + * Enables Karel CSV output. This will print Karel's world to a CSV between + * each action, and prompt to continue to the next action. May be used by + * screen-reader users to inspect Karel's world. + */ +void EnableCSVOutput(); + +/** + * Causes Karel to wait between each action function (Move, TurnLeft, + * PutBeeper, PickBeeper) until the user enters input into the terminal to + * proceed. + */ +void EnablePromptBeforeAction(); diff --git a/karel/src/cell.h b/karel/src/cell.h new file mode 100644 index 0000000..faa14ba --- /dev/null +++ b/karel/src/cell.h @@ -0,0 +1,60 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "orientation.h" + +#ifndef CELL_H +#define CELL_H + +namespace karel { + +/** + * Class representing a cell in Karel's world, including a count of beepers + * and whether or not there are walls. + */ +class Cell { + public: + int GetNumBeepers() const { return num_beepers_; } + void SetNumBeepers(int beepers) { num_beepers_ = beepers; } + + bool HasNorthWall() const { return north_wall_; } + bool HasEastWall() const { return east_wall_; } + bool HasSouthWall() const { return south_wall_; } + bool HasWestWall() const { return west_wall_; } + + /** + * Adds a wall on the given side of the cell. For example, a north wall would + * be at the top of the cell, and an east wall would be on the right of the + * cell. + */ + void AddWall(Orientation wall_orientation) { + switch (wall_orientation) { + case kNorth: + north_wall_ = true; + break; + case kEast: + east_wall_ = true; + break; + case kSouth: + south_wall_ = true; + break; + case kWest: + west_wall_ = true; + break; + } + } + + private: + int num_beepers_ = 0; + bool north_wall_ = false; + bool east_wall_ = false; + bool south_wall_ = false; + bool west_wall_ = false; +}; + +} // namespace karel + +#endif // CELL_H diff --git a/karel/src/error.h b/karel/src/error.h new file mode 100644 index 0000000..d8de672 --- /dev/null +++ b/karel/src/error.h @@ -0,0 +1,29 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "orientation.h" + +#ifndef ERROR_H +#define ERROR_H + +namespace karel { + +/** + * Defines an error state for Karel. kNoError means that there is no error. + */ +enum RobotError { + kNoError = 0, + kCannotMoveNorth, + kCannotMoveEast, + kCannotMoveSouth, + kCannotMoveWest, + kCannotPutBeeper, + kCannotPickBeeper, +}; + +} // namespace karel + +#endif diff --git a/karel/src/orientation.h b/karel/src/orientation.h new file mode 100644 index 0000000..3c12530 --- /dev/null +++ b/karel/src/orientation.h @@ -0,0 +1,34 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#ifndef ORIENTATION_H +#define ORIENTATION_H + +namespace karel { + +/** + * Defines an orientation in the grid using compass directions: north is + * up, east is right, west is down, and south is left. + */ +enum Orientation { + kNorth = 0, + kEast = 1, + kSouth = 2, + kWest = 3, +}; + +/** + * Defines a position and orientation in the world grid. + */ +struct PositionAndOrientation { + int x = 0; + int y = 0; + Orientation orientation = Orientation::kNorth; +}; + +} // namespace karel + +#endif // ORIENTATION_H diff --git a/karel/src/robot.cc b/karel/src/robot.cc new file mode 100644 index 0000000..adbbda6 --- /dev/null +++ b/karel/src/robot.cc @@ -0,0 +1,826 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "robot.h" + +#include + +#include +#include +#include +#include + +#include "../../graphics/image.h" +#include "cell.h" +#include "error.h" +#include "orientation.h" + +namespace karel { + +namespace { + +// Default width and height in cells. +const int kDefaultDimen = 10; + +// Milliseconds for a long animation duration to show Karel, used after each +// Karel action. +const int kLongDuration = 300; + +// Milliseconds for a short animation duration to show Karel, used when Karel +// is moving. +const int kShortDuration = 30; + +// Number of steps to take in the animation moving karel between cells. +const int kNumAnimationSteps = 10; + +// Pixel constants. +const int pxPerCell = 50; +const int markSize = 10; +const int robotSize = 30; +const int beeperSize = 30; +const int eyeSize = 4; +const int eyeOffset = 2; +const int legLength = 6; +const int limbWidth = 5; +const int kWallThickness = 3; +const int fontSize = 16; +const int kErrorFontSize = 20; +const int margin = 32; + +// Color constants. +const graphics::Color eyeColor(50, 50, 50); +const graphics::Color karelColor(125, 125, 125); +const graphics::Color markColor(150, 150, 255); +const graphics::Color innerBeeperColor(172, 147, 194); +const graphics::Color limbColor(105, 105, 105); +const graphics::Color kWhite(255, 255, 255); +const graphics::Color kWallColor(50, 50, 50); +const graphics::Color kGridColor(220, 220, 220); +const graphics::Color kErrorColor(173, 0, 35); + +const std::string kCSVFilename = "karel.csv"; + +// Helper methods +void ParseWorldFileError(std::string error_text, int line_number) { + if (line_number > 0) { + error_text += " (line " + std::to_string(line_number) + ")"; + } + std::cout << error_text << std::endl << std::flush; + throw error_text; +} + +void CheckParsePosition(char open_paren, char comma, char closed_paren, + int line_number) { + if (open_paren != '(') { + ParseWorldFileError("Invalid syntax: expected open parenthesis but found " + + std::string(1, open_paren), + line_number); + } + if (comma != ',') { + ParseWorldFileError( + "Invalid syntax: expected a comma but found " + std::string(1, comma), + line_number); + } + if (closed_paren != ')') { + ParseWorldFileError( + "Invalid syntax: expected closed parenthesis but found " + + std::string(1, closed_paren), + line_number); + } +} + +std::string GetErrorMessage(RobotError error) { + std::string message = "Error: "; + switch (error) { + case kNoError: + // This shouldn't happen. + return ""; + case kCannotMoveNorth: + message += " Cannot move north"; + break; + case kCannotMoveEast: + message += " Cannot move east"; + break; + case kCannotMoveSouth: + message += " Cannot move south"; + break; + case kCannotMoveWest: + message += " Cannot move west"; + break; + case kCannotPickBeeper: + message += "Cannot pick beeper\n(No beepers present)"; + break; + case kCannotPutBeeper: + message += " Cannot put beeper\n(No beepers in bag)"; + break; + } + return message; +} + +} // namespace + +Robot::Robot() {} + +// Singleton. Static. +karel::Robot& Robot::PrivateGetInstance() { + static Robot instance; + return instance; +} + +// static. +karel::Robot& Robot::GetInstance(bool enable_graphics, bool force_initialize) { + karel::Robot& instance = PrivateGetInstance(); + instance.Initialize("", enable_graphics, force_initialize); + return instance; +} + +// Get the Robot singleton and intialize it from a file. +// static. +karel::Robot& Robot::InitializeInstance(std::string filename, + bool enable_graphics, + bool force_initialize) { + karel::Robot& instance = PrivateGetInstance(); + instance.Initialize(filename, enable_graphics, force_initialize); + return instance; +} + +void Robot::Initialize(std::string filename, bool enable_graphics, + bool force_initialize) { + // Ensure only intitialized once unless |force_initialize| is true. + if (initialized_ && !force_initialize) return; + finished_ = false; + if (force_initialize) { + prompt_between_actions_ = false; + enable_csv_output_ = false; + } + // Reset default speed and beeper count. + speed_ = 1; + // Nearly infinite beepers. + beeper_count_ = std::numeric_limits::max(); + world_.clear(); + enable_graphics_ = enable_graphics; + + if (!filename.size()) { + // No file. Default 10x10 blank world with no walls and no beepers. + x_dimen_ = kDefaultDimen; + y_dimen_ = kDefaultDimen; + for (int i = 0; i < x_dimen_; i++) { + world_.push_back(std::vector()); + for (int j = 0; j < y_dimen_; j++) { + world_[i].push_back(Cell()); + } + } + position_ = {0, kDefaultDimen - 1, Orientation::kEast}; + } else { + std::fstream world_file; + try { + world_file.open(filename); + } catch (std::ifstream::failure e) { + ParseWorldFileError("Error opening file " + filename, -1); + } + if (!world_file.is_open()) { + ParseWorldFileError("Error opening file " + filename, -1); + } + std::string line; + int line_number = 1; + + const std::string dimension_prefix = "Dimension:"; + const std::string beeper_prefix = "Beeper:"; + const std::string wall_prefix = "Wall:"; + const std::string bag_prefix = "BeeperBag:"; + const std::string karel_prefix = "Karel:"; + const std::string speed_prefix = "Speed:"; + + std::string line_prefix; + char open_paren, comma, closed_paren; + if (!(world_file >> line_prefix >> open_paren >> x_dimen_ >> comma >> + y_dimen_ >> closed_paren)) { + ParseWorldFileError( + "Could not parse world dimensions from the first line", line_number); + } + if (line_prefix != dimension_prefix) { + ParseWorldFileError("Could not find \"Dimension:\" in first line", + line_number); + } + CheckParsePosition(open_paren, comma, closed_paren, line_number); + if (x_dimen_ < 1 || y_dimen_ < 1) { + ParseWorldFileError( + "Cannot load a world less than 1 cell wide or less than 1 cell " + "tall", + line_number); + } + for (int i = 0; i < x_dimen_; i++) { + world_.push_back(std::vector()); + for (int j = 0; j < y_dimen_; j++) { + world_[i].push_back(Cell()); + } + } + + // Read the rest of the file to get beepers and walls. + while (world_file >> line_prefix) { + line_number++; + if (line_prefix == wall_prefix) { + PositionAndOrientation wall = + ParsePositionAndOrientation(world_file, line_number); + world_[wall.x][wall.y].AddWall(wall.orientation); + } else if (line_prefix == beeper_prefix) { + PositionAndOrientation beeper = ParsePosition(world_file, line_number); + int count; + if (!(world_file >> count)) { + ParseWorldFileError("Error reading Beeper count", line_number); + } + world_[beeper.x][beeper.y].SetNumBeepers(count); + } else if (line_prefix == bag_prefix) { + std::string beepers; + if (!(world_file >> beepers)) { + ParseWorldFileError("Error reading quantity for BeeperBag", + line_number); + } + if (beepers == "INFINITY" || beepers == "INFINITE") { + beeper_count_ = std::numeric_limits::max(); + } else { + try { + beeper_count_ = stoi(beepers); + } catch (std::invalid_argument& e) { + ParseWorldFileError("Unknown BeeperBag quanity, " + beepers, + line_number); + } + } + } else if (line_prefix == karel_prefix) { + position_ = ParsePositionAndOrientation(world_file, line_number); + } else if (line_prefix == speed_prefix) { + if (!(world_file >> speed_)) { + ParseWorldFileError("Error reading Speed", line_number); + } + if (speed_ < 0) { + ParseWorldFileError("Speed must be greater than 0", line_number); + } else if (speed_ < 0.1) { + // Minimum speed. + speed_ = 0.1; + } + } else { + ParseWorldFileError("Unexpected token in file: " + line_prefix, + line_number); + break; + } + } + world_file.close(); + } + int min_width = 5 * pxPerCell + margin; + image_.Initialize(std::max(x_dimen_ * pxPerCell + margin, min_width), + y_dimen_ * pxPerCell + margin); + + initialized_ = true; + error_ = RobotError::kNoError; + + DrawWorld(); + DrawRobot(); + Show(/* long duration */ true); +} + +void Robot::Move() { + if (finished_) return; + PromptBeforeActionIfNeeded(); + switch (position_.orientation) { + case Orientation::kNorth: + if (position_.y == 0) { + Error(RobotError::kCannotMoveNorth); + } else if (world_[position_.x][position_.y].HasNorthWall() || + world_[position_.x][position_.y - 1].HasSouthWall()) { + Error(RobotError::kCannotMoveNorth); + } else { + AnimateMove(position_.x, position_.y - 1); + } + break; + case Orientation::kEast: + if (position_.x == x_dimen_ - 1) { + Error(RobotError::kCannotMoveEast); + } else if (world_[position_.x][position_.y].HasEastWall() || + world_[position_.x + 1][position_.y].HasWestWall()) { + Error(RobotError::kCannotMoveEast); + } else { + AnimateMove(position_.x + 1, position_.y); + } + break; + case Orientation::kSouth: + if (position_.y == y_dimen_ - 1) { + Error(RobotError::kCannotMoveSouth); + } else if (world_[position_.x][position_.y].HasSouthWall() || + world_[position_.x][position_.y + 1].HasNorthWall()) { + Error(RobotError::kCannotMoveSouth); + } else { + AnimateMove(position_.x, position_.y + 1); + } + break; + case Orientation::kWest: + if (position_.x == 0) { + Error(RobotError::kCannotMoveWest); + } else if (world_[position_.x][position_.y].HasWestWall() || + world_[position_.x - 1][position_.y].HasEastWall()) { + Error(RobotError::kCannotMoveWest); + } else { + AnimateMove(position_.x - 1, position_.y); + } + break; + } +} + +void Robot::TurnLeft() { + if (finished_) return; + PromptBeforeActionIfNeeded(); + switch (position_.orientation) { + case Orientation::kNorth: + position_.orientation = Orientation::kWest; + break; + case Orientation::kEast: + position_.orientation = Orientation::kNorth; + break; + case Orientation::kSouth: + position_.orientation = Orientation::kEast; + break; + case Orientation::kWest: + position_.orientation = Orientation::kSouth; + break; + } + DrawWorld(); + DrawRobot(); + Show(/* long duration */ true); +} + +void Robot::PutBeeper() { + if (finished_) return; + PromptBeforeActionIfNeeded(); + if (!HasBeepersInBag()) { + Error(RobotError::kCannotPutBeeper); + return; + } + if (beeper_count_ != std::numeric_limits::max()) { + beeper_count_--; + } + Cell& cell = world_[position_.x][position_.y]; + cell.SetNumBeepers(cell.GetNumBeepers() + 1); + DrawWorld(); + DrawRobot(); + Show(/* long duration */ true); +} + +void Robot::PickBeeper() { + if (finished_) return; + PromptBeforeActionIfNeeded(); + if (!BeepersPresent()) { + Error(RobotError::kCannotPickBeeper); + return; + } + Cell& cell = world_[position_.x][position_.y]; + cell.SetNumBeepers(cell.GetNumBeepers() - 1); + if (beeper_count_ != std::numeric_limits::max()) { + beeper_count_++; + } + DrawWorld(); + DrawRobot(); + Show(/* long duration */ true); +} + +bool Robot::HasBeepersInBag() const { return beeper_count_ > 0; } + +bool Robot::BeepersPresent() const { + return world_[position_.x][position_.y].GetNumBeepers() > 0; +} + +bool Robot::FrontIsClear() const { + return DirectionIsClear(position_.orientation); +} + +bool Robot::LeftIsClear() const { + return DirectionIsClear(static_cast( + (static_cast(position_.orientation) - 1 + 4) % 4)); +} + +bool Robot::RightIsClear() const { + return DirectionIsClear(static_cast( + (static_cast(position_.orientation) + 1) % 4)); +} + +bool Robot::FacingNorth() const { + return position_.orientation == Orientation::kNorth; +} + +bool Robot::FacingEast() const { + return position_.orientation == Orientation::kEast; +} + +bool Robot::FacingSouth() const { + return position_.orientation == Orientation::kSouth; +} + +bool Robot::FacingWest() const { + return position_.orientation == Orientation::kWest; +} + +void Robot::Finish() { + if (finished_) return; + finished_ = true; + if (enable_csv_output_) { + WriteWorldCSV(); + std::cout << "Finished. ctrl+c to exit." << std::endl << std::flush; + } else { + std::cout << "Finished. Close the image or ctrl+c to exit." << std::endl + << std::flush; + } + if (enable_graphics_) { + image_.ShowUntilClosed("Karel's World"); + } +} + +void Robot::EnablePromptBeforeAction() { prompt_between_actions_ = true; } + +void Robot::EnableCSVOutput() { + enable_csv_output_ = true; + prompt_between_actions_ = true; + if (initialized_) { + // If we are already initialized write the world CSV. The initial image + // has already been shown. + WriteWorldCSV(); + } +} + +Orientation Robot::GetOrientation() const { return position_.orientation; } + +int Robot::GetXPosition() const { return position_.x + 1; } + +int Robot::GetYPosition() const { return y_dimen_ - position_.y; } + +int Robot::GetNumBeepersInBag() const { return beeper_count_; } + +const Cell& Robot::GetCell(int x, int y) const { + return world_[x - 1][y_dimen_ - y]; +} + +int Robot::GetWorldWidth() const { return x_dimen_; } + +int Robot::GetWorldHeight() const { return y_dimen_; } + +RobotError Robot::GetError() const { return error_; } + +void Robot::SaveWorldBmp(std::string filename) const { + if (!image_.SaveImageBmp(filename)) { + std::cout << "Failed to save image to " << filename << std::endl + << std::flush; + } +} + +void Robot::Show(bool long_duration) { + if (finished_) return; + if (long_duration && enable_csv_output_) { + WriteWorldCSV(); + } + if (enable_graphics_) { + image_.ShowForMs((long_duration ? kLongDuration : kShortDuration) / speed_, + "Karel's World"); + } +} + +void Robot::WriteWorldCSV() { + std::ofstream csv; + csv.open(kCSVFilename); + if (!csv.is_open()) { + std::cout << "Error: Could not open " << kCSVFilename + << " to write Karel's world. Perhaps it is opened by another " + "application?" + << std::endl + << std::flush; + return; + } + for (int y = 0; y < y_dimen_; y++) { + for (int x = 0; x < x_dimen_; x++) { + // print contents walls. + Cell& cell = world_[x][y]; + csv << "\""; + if (x == position_.x && y == position_.y) { + if (position_.orientation == Orientation::kNorth) { + csv << "kn "; + } else if (position_.orientation == Orientation::kEast) { + csv << "ke "; + } else if (position_.orientation == Orientation::kSouth) { + csv << "ks "; + } else { + csv << "kw "; + } + } + if (cell.GetNumBeepers() > 0) { + csv << "b" << cell.GetNumBeepers() << " "; + } else { + csv << "o "; + } + csv << "(" << x + 1 << "," << y_dimen_ - y << ")\","; + if (x < x_dimen_ - 1) { + if (cell.HasEastWall() || world_[x + 1][y].HasWestWall()) { + csv << "w,"; + } else { + csv << ","; + } + } + } + csv << std::endl; + if (y < y_dimen_ - 1) { + for (int x = 0; x < x_dimen_; x++) { + // print bottom walls and next top walls + if (world_[x][y].HasSouthWall() || world_[x][y + 1].HasNorthWall()) { + csv << "w,,"; + } else { + csv << ",,"; + } + } + } + csv << std::endl; + } + csv << GetErrorMessage(error_) << std::endl; + csv << "symbol,kn,ke,ks,kw,o,b,w,\"(x,y)\"\n"; + csv << "meaning,Karel facing north,Karel facing east, Karel facing south, " + "Karel facing west,empty cell,cell with beepers and count,wall " + "between cells,cell coordinates\n"; + csv.close(); + std::cout << "World state written to " << kCSVFilename << std::endl + << std::flush; +} + +void Robot::Error(RobotError error) { + error_ = error; + if (error_ == RobotError::kNoError) { + return; + } + std::string message = GetErrorMessage(error); + std::cout << message << std::endl << std::flush; + int approx_width = 25 * kErrorFontSize / 4; + int text_x = std::max(2, image_.GetWidth() / 2 - approx_width); + int text_y = image_.GetHeight() / 2 - kErrorFontSize / 2; + image_.DrawText(text_x - 2, text_y - 2, message, kErrorFontSize, kWhite); + image_.DrawText(text_x + 2, text_y - 2, message, kErrorFontSize, kWhite); + image_.DrawText(text_x - 2, text_y + 2, message, kErrorFontSize, kWhite); + image_.DrawText(text_x + 2, text_y + 2, message, kErrorFontSize, kWhite); + image_.DrawText(text_x, text_y, message, kErrorFontSize, kErrorColor); + Finish(); +} + +bool Robot::DirectionIsClear(Orientation orientation) const { + switch (orientation) { + case Orientation::kNorth: + if (position_.y == 0) { + return false; + } else if (world_[position_.x][position_.y].HasNorthWall() || + world_[position_.x][position_.y - 1].HasSouthWall()) { + return false; + } else { + return true; + } + break; + case Orientation::kEast: + if (position_.x == x_dimen_ - 1) { + return false; + } else if (world_[position_.x][position_.y].HasEastWall() || + world_[position_.x + 1][position_.y].HasWestWall()) { + return false; + } else { + return true; + } + break; + case Orientation::kSouth: + if (position_.y == y_dimen_ - 1) { + return false; + } else if (world_[position_.x][position_.y].HasSouthWall() || + world_[position_.x][position_.y + 1].HasNorthWall()) { + return false; + } else { + return true; + } + break; + case Orientation::kWest: + if (position_.x == 0) { + return false; + } else if (world_[position_.x][position_.y].HasWestWall() || + world_[position_.x - 1][position_.y].HasEastWall()) { + return false; + } else { + return true; + } + break; + } +} + +void Robot::DrawWorld() { + image_.DrawRectangle(0, 0, x_dimen_ * pxPerCell, y_dimen_ * pxPerCell, + kWhite); + for (int i = 0; i <= y_dimen_; i++) { + // Draw horizontal lines and indexes. + int x = pxPerCell * x_dimen_; + int y = i * pxPerCell; + image_.DrawLine(0, y, x, y, kGridColor, kWallThickness); + if (i < y_dimen_) { + image_.DrawText(x + fontSize / 2, y + (pxPerCell - fontSize) / 2, + std::to_string(y_dimen_ - i), fontSize, kWallColor); + } + } + for (int i = 0; i <= x_dimen_; i++) { + // Draw vertical lines and indexes. + int x = i * pxPerCell; + int y = pxPerCell * y_dimen_; + image_.DrawLine(x, 0, x, y, kGridColor, kWallThickness); + if (i < x_dimen_) { + image_.DrawText(x + (pxPerCell - fontSize) / 2, y + fontSize / 2, + std::to_string(i + 1), fontSize, kWallColor); + } + } + for (int i = 0; i < x_dimen_; i++) { + for (int j = 0; j < y_dimen_; j++) { + int x_center = i * pxPerCell + pxPerCell / 2; + int y_center = j * pxPerCell + pxPerCell / 2; + // Draw the little plus at the center of the cell. + image_.DrawLine(x_center - markSize / 2, y_center, + x_center + markSize / 2, y_center, markColor, + kWallThickness); + image_.DrawLine(x_center, y_center - markSize / 2, x_center, + y_center + markSize / 2, markColor, kWallThickness); + Cell& cell = world_[i][j]; + int beeper_count = cell.GetNumBeepers(); + if (beeper_count > 0) { + // Draw the beepers. Beepers are stacked so you can't tell if there's + // more than one in a stack. + // trig to get diamonds from thick lines! + double line_size = sqrt((beeperSize / 2) * (beeperSize / 2) / 2); + int inner_beeper_size = beeperSize - kWallThickness * 2; + double inner_line_size = + sqrt((inner_beeper_size / 2) * (inner_beeper_size / 2) / 2); + image_.DrawLine(x_center - line_size, y_center - line_size, + x_center + line_size, y_center + line_size, kWallColor, + beeperSize); + image_.DrawLine(x_center - inner_line_size, y_center - inner_line_size, + x_center + inner_line_size, y_center + inner_line_size, + innerBeeperColor, inner_beeper_size); + if (beeper_count > 1) { + // Draw the beeper count in the cell if it's biger than 1. + image_.DrawText(x_center - fontSize / 4, y_center - fontSize / 2, + std::to_string(beeper_count), fontSize, kWallColor); + } + } + // Draw the walls. + if (cell.HasNorthWall()) { + image_.DrawLine(i * pxPerCell, j * pxPerCell, (i + 1) * pxPerCell, + j * pxPerCell, kWallColor, kWallThickness); + } + if (cell.HasSouthWall()) { + image_.DrawLine(i * pxPerCell, (j + 1) * pxPerCell, (i + 1) * pxPerCell, + (j + 1) * pxPerCell, kWallColor, kWallThickness); + } + if (cell.HasWestWall()) { + image_.DrawLine(i * pxPerCell, j * pxPerCell, i * pxPerCell, + (j + 1) * pxPerCell, kWallColor, kWallThickness); + } + if (cell.HasEastWall()) { + image_.DrawLine((i + 1) * pxPerCell, j * pxPerCell, (i + 1) * pxPerCell, + (j + 1) * pxPerCell, kWallColor, kWallThickness); + } + } + } +} + +void Robot::DrawRobot() { + // Center in the cell. + DrawRobot(position_.x * pxPerCell + pxPerCell / 2, + position_.y * pxPerCell + pxPerCell / 2); +} + +// pixel_x and pixel_y are the center of the cell in pixels. +void Robot::DrawRobot(int pixel_x, int pixel_y) { + image_.DrawRectangle(pixel_x - robotSize / 2, pixel_y - robotSize / 2, + robotSize, robotSize, karelColor); + switch (position_.orientation) { + case Orientation::kNorth: + image_.DrawCircle(pixel_x, + pixel_y - robotSize / 2 + eyeSize / 2 + eyeOffset, + eyeSize, kWhite); + image_.DrawCircle(pixel_x, pixel_y + eyeOffset, eyeSize, kWhite); + image_.DrawCircle(pixel_x, pixel_y - robotSize / 2 + eyeSize / 2, eyeSize, + eyeColor); + image_.DrawLine(pixel_x + robotSize / 2, pixel_y - legLength, + pixel_x + robotSize / 2 + legLength, pixel_y - legLength, + limbColor, limbWidth); + image_.DrawLine(pixel_x + robotSize / 2, pixel_y + legLength, + pixel_x + robotSize / 2 + legLength, pixel_y + legLength, + limbColor, limbWidth); + break; + case Orientation::kEast: + image_.DrawCircle(pixel_x - eyeOffset, pixel_y, eyeSize, kWhite); + image_.DrawCircle(pixel_x + robotSize / 2 - eyeSize / 2 - eyeOffset, + pixel_y, eyeSize, kWhite); + image_.DrawCircle(pixel_x + robotSize / 2 - eyeSize / 2, pixel_y, eyeSize, + eyeColor); + image_.DrawLine(pixel_x - legLength, pixel_y + robotSize / 2, + pixel_x - legLength, pixel_y + robotSize / 2 + legLength, + limbColor, limbWidth); + image_.DrawLine(pixel_x + legLength, pixel_y + robotSize / 2, + pixel_x + legLength, pixel_y + robotSize / 2 + legLength, + limbColor, limbWidth); + break; + case Orientation::kSouth: + image_.DrawCircle(pixel_x, + pixel_y + robotSize / 2 - eyeSize / 2 - eyeOffset, + eyeSize, kWhite); + image_.DrawCircle(pixel_x, pixel_y - eyeOffset, eyeSize, kWhite); + image_.DrawCircle(pixel_x, pixel_y + robotSize / 2 - eyeSize / 2, eyeSize, + eyeColor); + image_.DrawLine(pixel_x - robotSize / 2, pixel_y - legLength, + pixel_x - robotSize / 2 - legLength, pixel_y - legLength, + limbColor, limbWidth); + image_.DrawLine(pixel_x - robotSize / 2, pixel_y + legLength, + pixel_x - robotSize / 2 - legLength, pixel_y + legLength, + limbColor, limbWidth); + break; + case Orientation::kWest: + image_.DrawCircle(pixel_x + eyeOffset, pixel_y, eyeSize, kWhite); + image_.DrawCircle(pixel_x - robotSize / 2 + eyeSize / 2 + eyeOffset, + pixel_y, eyeSize, kWhite); + image_.DrawCircle(pixel_x - robotSize / 2 + eyeSize / 2, pixel_y, eyeSize, + eyeColor); + image_.DrawLine(pixel_x - legLength, pixel_y - robotSize / 2, + pixel_x - legLength, pixel_y - robotSize / 2 - legLength, + limbColor, limbWidth); + image_.DrawLine(pixel_x + legLength, pixel_y - robotSize / 2, + pixel_x + legLength, pixel_y - robotSize / 2 - legLength, + limbColor, limbWidth); + break; + } + image_.DrawCircle(pixel_x, pixel_y, eyeSize, eyeColor); +} + +void Robot::AnimateMove(int next_x, int next_y) { + int steps = enable_graphics_ ? kNumAnimationSteps : 0; + for (int i = 1; i <= steps; i++) { + DrawWorld(); + double fraction = i * 1.0 / steps; + double x = position_.x * (1 - fraction) + next_x * fraction; + double y = position_.y * (1 - fraction) + next_y * fraction; + DrawRobot(x * pxPerCell + pxPerCell / 2, y * pxPerCell + pxPerCell / 2); + Show(/* short duration */ false); + } + position_.x = next_x; + position_.y = next_y; + Show(/* long duration */ true); +} + +void Robot::PromptBeforeActionIfNeeded() { + if (!prompt_between_actions_) return; + std::cout << "Paused. Enter any character to continue: " << std::flush; + std::string value; + std::cin >> value; +} + +// Note that the orientation is not populated. This is just a helper to +// get the x and y position from a file with the next items in the stream +// being (x, y) +PositionAndOrientation Robot::ParsePosition(std::fstream& file, + int line_number) const { + char open_paren, comma, closed_paren; + int x, y; + if (!(file >> open_paren >> x >> comma >> y >> closed_paren)) { + ParseWorldFileError("Error reading position", line_number); + } + CheckParsePosition(open_paren, comma, closed_paren, line_number); + PositionAndOrientation result; + // Convert y in the file to y on-screen. In the file, (1, 1) is the + // bottom left corner. + // In Karel coordinates, that's (0, y_dimen - 1). + result.y = y_dimen_ - y; + result.x = x - 1; + return result; +} + +// Helper to get orientation of the form, `(x, y) direction`, for example, +// (3, 7) East +// Direction may be lower-case or have an uppercase first letter. +PositionAndOrientation Robot::ParsePositionAndOrientation( + std::fstream& file, int line_number) const { + PositionAndOrientation result = ParsePosition(file, line_number); + std::string direction; + if (!(file >> direction)) { + ParseWorldFileError("Error reading orientation", line_number); + } + // Ensure the first character is lower cased. + direction[0] = tolower(direction[0]); + if (direction == "north") { + result.orientation = Orientation::kNorth; + } else if (direction == "east") { + result.orientation = Orientation::kEast; + } else if (direction == "south") { + result.orientation = Orientation::kSouth; + } else if (direction == "west") { + result.orientation = Orientation::kWest; + } else { + ParseWorldFileError("Unknown orientation " + direction, line_number); + } + return result; +} + +} // namespace karel diff --git a/karel/src/robot.h b/karel/src/robot.h new file mode 100644 index 0000000..0012932 --- /dev/null +++ b/karel/src/robot.h @@ -0,0 +1,299 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include +#include + +#include "../../graphics/image.h" +#include "cell.h" +#include "error.h" +#include "orientation.h" + +#ifndef ROBOT_H +#define ROBOT_H + +namespace karel { + +class Robot { + public: + /** + * Get the Robot singleton. Initializes it with default values if it isn't + * already initialized. Set |enable_graphics| to false for testing, which + * will disable animations. Use |force_initialize| only for testing which + * resets the singleton state. + */ + static karel::Robot& GetInstance(bool enable_graphics = true, + bool force_initialize = false); + + /** + * Get the Robot singleton and intialize it from a file. Set |enable_graphics| + * to false for testing, which will disable animations. Use |force_initialize| + * only for testing which resets the singleton state. + */ + static karel::Robot& InitializeInstance(std::string filename, + bool enable_graphics = true, + bool force_initialize = false); + + // Disallow copy and assignment. + Robot(const Robot&) = delete; + karel::Robot& operator=(const Robot&) = delete; + + /////////////////////////////////////////////////////////// + // Methods for core Karel functionality // + // These methods allow interaction with Karel. // + /////////////////////////////////////////////////////////// + + /** + * Move Karel forward one step. Results in an error if Karel cannot move + * forward because of a wall or an edge. + */ + void Move(); + + /** + * Turns Karel to the left. + */ + void TurnLeft(); + + /** + * Places a beeper from Karel's beeper bag onto the current cell where Karel + * is. Results in an error if Karel has no beepers left in their bag. + */ + void PutBeeper(); + + /** + * Picks a beeper from the cell where Karel is standing and places it in + * their beeper bag. Results in an error if there are no beepers in Karel's + * current cell. + */ + void PickBeeper(); + + /** + * Returns true if Karel has beepers in their bag, or false otherwise. + */ + bool HasBeepersInBag() const; + + /** + * Returns true if Karel is standing on a cell with at least one beeper, or + * false otherwise. + */ + bool BeepersPresent() const; + + /** + * Returns true if there is no wall nor edge in front of Karel and they could + * move forward, or false otherwise. + */ + bool FrontIsClear() const; + + /** + * Returns true if there is no wall nor edge directly to Karel's left, or + * false otherwise. + */ + bool LeftIsClear() const; + + /** + * Returns true if there is no wall nor edge directly to Karel's right, or + * false otherwise. + */ + bool RightIsClear() const; + + /** + * Returns true if Karel is facing north or false otherwise. + */ + bool FacingNorth() const; + + /** + * Returns true if Karel is facing east or false otherwise. + */ + bool FacingEast() const; + + /** + * Returns true if Karel is facing south or false otherwise. + */ + bool FacingSouth() const; + + /** + * Returns true if Karel is facing west or false otherwise. + */ + bool FacingWest() const; + + /** + * Completes a Karel program. Continues to show the image but will not + * do any more actions. + */ + void Finish(); + + /** + * Causes Robot to wait between each action function (Move, TurnLeft, + * PutBeeper, PickBeeper) until the user enters input into the terminal to + * proceed. This may be used to improve the robot's accessibility. + */ + void EnablePromptBeforeAction(); + + /** + * Enables Robot CSV output. This will print Karel's world to a CSV between + * each action, and prompt to continue to the next action. May be used by + * screen-reader users to inspect Karel's world. + */ + void EnableCSVOutput(); + + /////////////////////////////////////////////////////////////////////// + // Methods for tests. Tests should access Robot with GetInstance // + // and may inspect its state with the following methods. // + /////////////////////////////////////////////////////////////////////// + + /** + * Gets the orientation that Karel is facing. May be simpler to use this in + * tests than Facing* functions. + */ + Orientation GetOrientation() const; + + /** + * Gets the X position with respect to the grid coordinates. 1 is the + * left-most cell. + */ + int GetXPosition() const; + + /** + * Gets the Y position with respect to the grid coordinates. 1 is the bottom + * cell. + */ + int GetYPosition() const; + + /** + * Gets the total number of beepers in Karel's bag. + */ + int GetNumBeepersInBag() const; + + /** + * Gets the current grid. Takes in grid coordinates, where (1, 1) is the + * bottom left cell. + */ + const Cell& GetCell(int x, int y) const; + + /** + * Gets the width of the current world. + */ + int GetWorldWidth() const; + + /** + * Gets the height of the current world. + */ + int GetWorldHeight() const; + + /** + * Returns the robot's current error state or kNoError if it has none. + */ + RobotError GetError() const; + + /** + * Saves an image of Karel's world with the given filename in .bmp format. + */ + void SaveWorldBmp(std::string filename) const; + + private: + // Private constructor: Robot can only be accessed with GetInstance. + Robot(); + + // Singleton. + static karel::Robot& PrivateGetInstance(); + + /** + * Loads a Karel world from a file, or if |filename| is the empty string loads + * a default world with Karel in a default position. + */ + void Initialize(std::string filename, bool enable_graphics, + bool force_initialize); + + /** + * Shows karel's world, blocking for a long duration or a short duration. + * If long_duration, may also print to the terminal when EnableTerminalOutput + * was called. + */ + void Show(bool long_duration); + + /** + * Writes the world to a CSV file. + */ + void WriteWorldCSV(); + + /** + * Displays an error and finishes the program. + */ + void Error(RobotError error); + + /** + * Helper method to see if a compass direction is clear. + */ + bool DirectionIsClear(Orientation orientation) const; + + void DrawWorld(); + + void DrawRobot(); + + /** + * Draws Karel at a location on the image. |pixel_x| and |pixel_y| are the + * center of the cell in pixels. + */ + void DrawRobot(int pixel_x, int pixel_y); + + void AnimateMove(int next_x, int next_y); + + void PromptBeforeActionIfNeeded(); + + /** + * Helper method to parse position from the next item in a file stream. + * Note that the orientation is not populated. This is just a helper to + * get the x and y position from a file with the next items in the stream + * being (x, y) + */ + PositionAndOrientation ParsePosition(std::fstream& file, int line_number) const; + + /** + * Helper to get orientation of the form, `(x, y) direction`, for example, + * (3, 7) East + * Direction may be lower-case or have an uppercase first letter. + */ + PositionAndOrientation ParsePositionAndOrientation(std::fstream& file, int line_number) const; + + // Whether graphics are enabled. They should probably be disabled for + // testing. + bool enable_graphics_ = true; + + // Whether to prompt the user to continue between actions. May be useful as + // an accessibility feature. + bool prompt_between_actions_ = false; + + // Whether to enable terminal output -- a text-based display of Karel's world. + bool enable_csv_output_ = false; + + // Speed multiplier for animation. + double speed_ = 1.0; + + // Underlying image. + graphics::Image image_; + + // Grid dimensions. + int x_dimen_; + int y_dimen_; + + // Karel's position and orientation. + PositionAndOrientation position_; + + // Karel's beeper bag. + int beeper_count_ = 0; + + // The cells in the world. + std::vector> world_; + + // Whether the world has been initialized. It will not re-initialize. + bool initialized_ = false; + bool finished_ = false; + RobotError error_ = RobotError::kNoError; +}; + +} // namespace karel + +#endif // ROBOT_H diff --git a/karel/src/test/Makefile b/karel/src/test/Makefile new file mode 100644 index 0000000..6cd9b29 --- /dev/null +++ b/karel/src/test/Makefile @@ -0,0 +1,48 @@ +# Copyright 2020 Paul Salvador Inventado and Google LLC +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +.PHONY: install_gtest karel_unittest + +OS_NAME := $(shell uname -s | tr A-Z a-z) +SHELL := /bin/bash +HAS_GTEST := $(shell echo -e "int main() { }" >> test.cc ; clang++ test.cc -o test -lgtest 2> /dev/null; echo $$?; rm -f test.cc test;) +COMPILE_FLAGS := -lm -lX11 -lpthread + +ifeq ($(OS_NAME), darwin) + COMPILE_FLAGS := -lm -lpthread -lX11 -I/usr/X11R6/include -L/usr/X11R6/lib + HAS_BREW := $(shell command -v brew 2> /dev/null) +endif + +install_gtest: +ifeq ($(HAS_GTEST),1) + @echo -e "google test not installed\n" +ifeq ($(OS_NAME), darwin) +ifndef ($(HAS_BREW)) + @echo -e "Installing brew, please provide your password when asked.\n" + @/bin/bash -c "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)" + @brew install coreutils + # Need to redefine the path directories otherwise this will fail the first time. +endif + @echo -e "Installing cmake. Please provide the password when asked\n" + @brew install cmake + @echo -e "\nDownloading and installing googletest\n" + @cd /tmp/; git clone https://github.com/google/googletest; cd googletest; mkdir build; cd build; cmake .. -DCMAKE_CXX_STANDARD=17; make; make install + @echo -e "Finished installing google test\n" +else +ifneq ($(shell lsb_release -sr), 20.04) + @cd /tmp/; git clone https://github.com/google/googletest.git; cd googletest; cmake CMakeLists.txt; make; sudo cp -r googletest/include/. /usr/include; sudo cp -r googlemock/include/. /usr/include; sudo cp lib/*.a /usr/lib +else + @echo -e "Installing cmake. Please provide the password when asked\n" + @sudo apt-get install cmake # install cmake + @echo -e "\nDownloading and installing googletest\n" + @sudo apt-get install libgtest-dev libgmock-dev + @echo -e "Finished installing google test\n" +endif +endif +endif + +karel_unittest: install_gtest + @clang++ -std=c++17 ../../../graphics/image.cc ../robot.cc ../../karel.cc karel_unittest.cc -o karel_unittest -pthread -lgtest $(COMPILE_FLAGS) && ./karel_unittest diff --git a/karel/src/test/karel.png b/karel/src/test/karel.png new file mode 100644 index 0000000..ef274d5 Binary files /dev/null and b/karel/src/test/karel.png differ diff --git a/karel/src/test/karel_unittest.cc b/karel/src/test/karel_unittest.cc new file mode 100644 index 0000000..7b4cd5a --- /dev/null +++ b/karel/src/test/karel_unittest.cc @@ -0,0 +1,604 @@ +// Copyright 2020 Paul Salvador Inventado and Google LLC +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#include "../../karel.h" + +#include +#include + +#include "../../../graphics/image.h" +#include "../cell.h" +#include "../error.h" +#include "../orientation.h" +#include "../robot.h" + +using karel::Cell; +using karel::Orientation; +using karel::Robot; +using karel::RobotError; + +// Test TODOs: +// Test loading bad files and helpful output +// Test CSV output: walls and beepers + +bool IsBasicallyInfinite(int number) { + return number > std::numeric_limits::max() / 2; +} + +void CellIsEmptyWithNoWalls(const Cell& cell) { + ASSERT_EQ(0, cell.GetNumBeepers()); + ASSERT_FALSE(cell.HasNorthWall()); + ASSERT_FALSE(cell.HasEastWall()); + ASSERT_FALSE(cell.HasSouthWall()); + ASSERT_FALSE(cell.HasWestWall()); +} + +TEST(KarelTest, GetsKarelInstance) { + Robot& r = Robot::GetInstance(/* enable animations */ false, + /* force initialize */ true); +} + +TEST(KarelTest, DefaultWorld) { + Robot& r = Robot::GetInstance(/* enable animations */ false, + /* force initialize */ true); + + // Default world is 10x10. + ASSERT_EQ(10, r.GetWorldWidth()); + ASSERT_EQ(10, r.GetWorldHeight()); + + // Default world has no beepers and no walls. + for (int i = 1; i <= 10; i++) { + for (int j = 1; j <= 10; j++) { + CellIsEmptyWithNoWalls(r.GetCell(i, j)); + } + } + + // Default Karel is in bottom left with infinite beepers. + ASSERT_EQ(1, r.GetXPosition()); + ASSERT_EQ(1, r.GetYPosition()); + ASSERT_EQ(Orientation::kEast, r.GetOrientation()); + ASSERT_TRUE(IsBasicallyInfinite(r.GetNumBeepersInBag())); + // Facing east, so the right is blocked. + EXPECT_FALSE(RightIsClear()); + EXPECT_TRUE(RightIsBlocked()); + EXPECT_TRUE(LeftIsClear()); + EXPECT_FALSE(LeftIsBlocked()); +} + +TEST(KarelTest, LoadsWorld) { + Robot& r = + Robot::InitializeInstance("worlds/2x1.w", /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(2, r.GetWorldWidth()); + ASSERT_EQ(1, r.GetWorldHeight()); + CellIsEmptyWithNoWalls(r.GetCell(1, 1)); + CellIsEmptyWithNoWalls(r.GetCell(2, 1)); + + ASSERT_EQ(1, r.GetXPosition()); + ASSERT_EQ(1, r.GetYPosition()); + ASSERT_EQ(Orientation::kEast, r.GetOrientation()); + ASSERT_TRUE(IsBasicallyInfinite(r.GetNumBeepersInBag())); + + ASSERT_TRUE(FrontIsClear()); + ASSERT_FALSE(FrontIsBlocked()); + EXPECT_FALSE(RightIsClear()); + EXPECT_TRUE(RightIsBlocked()); + EXPECT_FALSE(LeftIsClear()); + EXPECT_TRUE(LeftIsBlocked()); +} + +TEST(KarelTest, LoadsWorldWithInfinityBeepers) { + // INFINITY vs INFINITE + Robot& r = Robot::InitializeInstance("worlds/8x1.w", + /* enable animations */ false, + /* force initialize */ true); + ASSERT_TRUE(IsBasicallyInfinite(r.GetNumBeepersInBag())); +} + +TEST(KarelTest, LoadsWorldWithNoBeepersInBag) { + Robot& r = Robot::InitializeInstance("worlds/beepers.w", + /* enable animations */ false, + /* force initialize */ true); + EXPECT_EQ(0, r.GetNumBeepersInBag()); +} + +TEST(KarelTest, LoadsWorldWithFiniteBeepers) { + Robot& r = Robot::InitializeInstance("worlds/inner_walls.w", + /* enable animations */ false, + /* force initialize */ true); + EXPECT_EQ(42, r.GetNumBeepersInBag()); +} + +TEST(KarelTest, LoadsWorldWithBeepersInCells) { + Robot& r = Robot::InitializeInstance("worlds/beepers.w", + /* enable animations */ false, + /* force initialize */ true); + EXPECT_EQ(1, r.GetCell(2, 1).GetNumBeepers()); + EXPECT_EQ(2, r.GetCell(3, 1).GetNumBeepers()); + EXPECT_EQ(3, r.GetCell(3, 2).GetNumBeepers()); + EXPECT_EQ(4, r.GetCell(4, 4).GetNumBeepers()); +} + +TEST(KarelTest, LoadsWorldWithOuterWalls) { + Robot& r = Robot::InitializeInstance("worlds/outer_walls.w", + /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(8, r.GetWorldWidth()); + ASSERT_EQ(8, r.GetWorldHeight()); + + // Cell at (3, 5) has only north wall. + EXPECT_TRUE(r.GetCell(3, 5).HasNorthWall()); + // Cell at (2, 6) has only east wall. + EXPECT_TRUE(r.GetCell(2, 6).HasEastWall()); + // Cell at (3, 7) has only south wall + EXPECT_TRUE(r.GetCell(3, 7).HasSouthWall()); + // Cell at (4, 6) has only west wall. + EXPECT_TRUE(r.GetCell(4, 6).HasWestWall()); + + ASSERT_EQ(Orientation::kEast, r.GetOrientation()); + ASSERT_EQ(3, r.GetXPosition()); + ASSERT_EQ(6, r.GetYPosition()); +} + +TEST(KarelTest, LoadsWorldWithInnerWalls) { + Robot& r = Robot::InitializeInstance("worlds/inner_walls.w", + /* enable animations */ false, + /* force initialize */ true); + // Cell at (3, 2) has all four walls. + const Cell& c = r.GetCell(3, 2); + EXPECT_TRUE(c.HasNorthWall()); + EXPECT_TRUE(c.HasEastWall()); + EXPECT_TRUE(c.HasSouthWall()); + EXPECT_TRUE(c.HasWestWall()); + + ASSERT_EQ(Orientation::kEast, r.GetOrientation()); + ASSERT_EQ(3, r.GetXPosition()); + ASSERT_EQ(2, r.GetYPosition()); +} + +TEST(KarelTest, CannotMoveThroughOuterWalls) { + for (int i = 0; i < 4; i++) { + Robot& r = Robot::InitializeInstance("worlds/outer_walls.w", + /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + for (int j = 0; j < i; j++) { + TurnLeft(); + } + EXPECT_FALSE(FrontIsClear()); + EXPECT_TRUE(FrontIsBlocked()); + EXPECT_FALSE(RightIsClear()); + EXPECT_TRUE(RightIsBlocked()); + EXPECT_FALSE(LeftIsClear()); + EXPECT_TRUE(LeftIsBlocked()); + Move(); + if (i == 0) { + ASSERT_TRUE(FacingEast()); + EXPECT_EQ(RobotError::kCannotMoveEast, r.GetError()); + } else if (i == 1) { + ASSERT_TRUE(FacingNorth()); + EXPECT_EQ(RobotError::kCannotMoveNorth, r.GetError()); + } else if (i == 2) { + ASSERT_TRUE(FacingWest()); + EXPECT_EQ(RobotError::kCannotMoveWest, r.GetError()); + } else { + ASSERT_TRUE(FacingSouth()); + EXPECT_EQ(RobotError::kCannotMoveSouth, r.GetError()); + } + } +} + +TEST(KarelTest, CannotMoveThroughInnerWalls) { + for (int i = 0; i < 4; i++) { + Robot& r = Robot::InitializeInstance("worlds/inner_walls.w", + /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + for (int j = 0; j < i; j++) { + TurnLeft(); + } + Move(); + EXPECT_FALSE(FrontIsClear()); + EXPECT_TRUE(FrontIsBlocked()); + EXPECT_FALSE(RightIsClear()); + EXPECT_TRUE(RightIsBlocked()); + EXPECT_FALSE(LeftIsClear()); + EXPECT_TRUE(LeftIsBlocked()); + if (i == 0) { + ASSERT_TRUE(FacingEast()); + EXPECT_EQ(RobotError::kCannotMoveEast, r.GetError()); + } else if (i == 1) { + ASSERT_TRUE(FacingNorth()); + EXPECT_EQ(RobotError::kCannotMoveNorth, r.GetError()); + } else if (i == 2) { + ASSERT_TRUE(FacingWest()); + EXPECT_EQ(RobotError::kCannotMoveWest, r.GetError()); + } else { + ASSERT_TRUE(FacingSouth()); + EXPECT_EQ(RobotError::kCannotMoveSouth, r.GetError()); + } + } +} + +TEST(KarelTest, CannotMoveThroughWorldEdges) { + for (int i = 0; i < 4; i++) { + Robot& r = Robot::InitializeInstance("worlds/2x1.w", + /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + for (int j = 0; j < i; j++) { + TurnLeft(); + } + if (FacingEast()) { + Move(); + Move(); + EXPECT_EQ(RobotError::kCannotMoveEast, r.GetError()); + EXPECT_EQ(2, r.GetXPosition()); + EXPECT_EQ(1, r.GetYPosition()); + } else if (FacingNorth()) { + Move(); + EXPECT_EQ(RobotError::kCannotMoveNorth, r.GetError()); + } else if (FacingWest()) { + Move(); + EXPECT_EQ(RobotError::kCannotMoveWest, r.GetError()); + } else if (FacingSouth()) { + Move(); + EXPECT_EQ(RobotError::kCannotMoveSouth, r.GetError()); + } + } +} + +TEST(KarelTest, PutsAndPicksBeeper) { + // Default world has no beepers, and Karel has infinite. + Robot& r = Robot::GetInstance(/* enable animations */ false, + /* force initialize */ true); + ASSERT_FALSE(BeepersPresent()); + ASSERT_EQ(0, r.GetCell(1, 1).GetNumBeepers()); + int count = 10; + for (int i = 1; i <= count; i++) { + PutBeeper(); + ASSERT_TRUE(BeepersPresent()); + EXPECT_EQ(i, r.GetCell(1, 1).GetNumBeepers()); + } + for (int i = count - 1; i >= 0; i--) { + PickBeeper(); + if (i > 0) { + ASSERT_TRUE(BeepersPresent()); + } else { + ASSERT_FALSE(BeepersPresent()); + } + EXPECT_EQ(i, r.GetCell(1, 1).GetNumBeepers()); + } +} + +TEST(KarelTest, PicksManyBeepers) { + Robot& r = Robot::InitializeInstance("worlds/beepers.w", + /* enable animations */ false, + /* force initialize */ true); + EXPECT_FALSE(BeepersPresent()); + EXPECT_TRUE(NoBeepersInBag()); + EXPECT_EQ(0, r.GetNumBeepersInBag()); + + Move(); + // One beeper. + EXPECT_TRUE(BeepersPresent()); + EXPECT_FALSE(NoBeepersPresent()); + + PickBeeper(); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + EXPECT_TRUE(HasBeepersInBag()); + EXPECT_FALSE(NoBeepersInBag()); + EXPECT_EQ(1, r.GetNumBeepersInBag()); + EXPECT_FALSE(BeepersPresent()); + EXPECT_TRUE(NoBeepersPresent()); + + Move(); + // Two beepers. + EXPECT_TRUE(BeepersPresent()); + EXPECT_FALSE(NoBeepersPresent()); + + PickBeeper(); + EXPECT_TRUE(BeepersPresent()); + EXPECT_FALSE(NoBeepersPresent()); + + PickBeeper(); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + EXPECT_TRUE(HasBeepersInBag()); + EXPECT_FALSE(NoBeepersInBag()); + EXPECT_EQ(3, r.GetNumBeepersInBag()); + EXPECT_FALSE(BeepersPresent()); + EXPECT_TRUE(NoBeepersPresent()); + + TurnLeft(); + Move(); + // Three beepers. + EXPECT_TRUE(BeepersPresent()); + EXPECT_FALSE(NoBeepersPresent()); + + PickBeeper(); + EXPECT_TRUE(BeepersPresent()); + EXPECT_FALSE(NoBeepersPresent()); + + PickBeeper(); + EXPECT_TRUE(BeepersPresent()); + EXPECT_FALSE(NoBeepersPresent()); + + PickBeeper(); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + EXPECT_TRUE(HasBeepersInBag()); + EXPECT_FALSE(NoBeepersInBag()); + EXPECT_EQ(6, r.GetNumBeepersInBag()); + EXPECT_FALSE(BeepersPresent()); + EXPECT_TRUE(NoBeepersPresent()); +} + +TEST(KarelTest, CannotPickMissingBeeper) { + Robot& r = Robot::GetInstance(/* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + PickBeeper(); + ASSERT_EQ(RobotError::kCannotPickBeeper, r.GetError()); +} + +TEST(KarelTest, CannotPutWhenBeeperBagEmpty) { + Robot& r = Robot::InitializeInstance("worlds/beepers.w", + /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + PutBeeper(); + ASSERT_EQ(RobotError::kCannotPutBeeper, r.GetError()); +} + +TEST(KarelTest, TurnsLeft) { + Robot& r = Robot::GetInstance(/* enable animations */ false, + /* force initialize */ true); + int count = 10; + // Starts facing East. + for (int i = 0; i < count; i++) { + if (i % 4 == 0) { + // Facing east. + ASSERT_EQ(Orientation::kEast, r.GetOrientation()); + EXPECT_FALSE(FacingNorth()); + EXPECT_TRUE(NotFacingNorth()); + EXPECT_TRUE(FacingEast()); + EXPECT_FALSE(NotFacingEast()); + EXPECT_FALSE(FacingSouth()); + EXPECT_TRUE(NotFacingSouth()); + EXPECT_FALSE(FacingWest()); + EXPECT_TRUE(NotFacingWest()); + } else if (i % 4 == 1) { + // Facing north. + ASSERT_EQ(Orientation::kNorth, r.GetOrientation()); + EXPECT_TRUE(FacingNorth()); + EXPECT_FALSE(NotFacingNorth()); + EXPECT_FALSE(FacingEast()); + EXPECT_TRUE(NotFacingEast()); + EXPECT_FALSE(FacingSouth()); + EXPECT_TRUE(NotFacingSouth()); + EXPECT_FALSE(FacingWest()); + EXPECT_TRUE(NotFacingWest()); + } else if (i % 4 == 2) { + // Facing west. + ASSERT_EQ(Orientation::kWest, r.GetOrientation()); + EXPECT_FALSE(FacingNorth()); + EXPECT_TRUE(NotFacingNorth()); + EXPECT_FALSE(FacingEast()); + EXPECT_TRUE(NotFacingEast()); + EXPECT_FALSE(FacingSouth()); + EXPECT_TRUE(NotFacingSouth()); + EXPECT_TRUE(FacingWest()); + EXPECT_FALSE(NotFacingWest()); + } else { + // Facing south. + ASSERT_EQ(Orientation::kSouth, r.GetOrientation()); + EXPECT_FALSE(FacingNorth()); + EXPECT_TRUE(NotFacingNorth()); + EXPECT_FALSE(FacingEast()); + EXPECT_TRUE(NotFacingEast()); + EXPECT_TRUE(FacingSouth()); + EXPECT_FALSE(NotFacingSouth()); + EXPECT_FALSE(FacingWest()); + EXPECT_TRUE(NotFacingWest()); + } + TurnLeft(); + } +} + +TEST(KarelTest, MovesEastAndWest) { + Robot& r = + Robot::InitializeInstance("worlds/8x1.w", /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(8, r.GetWorldWidth()); + ASSERT_EQ(1, r.GetWorldHeight()); + for (int i = 2; i <= 8; i++) { + Move(); + ASSERT_EQ(i, r.GetXPosition()); + EXPECT_EQ(1, r.GetYPosition()); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + } + TurnLeft(); + TurnLeft(); + for (int i = 7; i > 0; i--) { + Move(); + ASSERT_EQ(i, r.GetXPosition()); + EXPECT_EQ(1, r.GetYPosition()); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + } +} + +TEST(KarelTest, MovesNorthAndSouth) { + Robot& r = + Robot::InitializeInstance("worlds/1x8.w", /* enable animations */ false, + /* force initialize */ true); + ASSERT_EQ(1, r.GetWorldWidth()); + ASSERT_EQ(8, r.GetWorldHeight()); + TurnLeft(); // Face north. + for (int i = 2; i <= 8; i++) { + Move(); + ASSERT_EQ(1, r.GetXPosition()); + EXPECT_EQ(i, r.GetYPosition()); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + } + TurnLeft(); + TurnLeft(); + for (int i = 7; i > 0; i--) { + Move(); + ASSERT_EQ(1, r.GetXPosition()); + EXPECT_EQ(i, r.GetYPosition()); + ASSERT_EQ(RobotError::kNoError, r.GetError()); + } +} + +TEST(KarelTest, DoesNotTakeActionAfterErrorState) { + Robot& r = + Robot::InitializeInstance("worlds/beepers.w", /* enable graphics */ false, + /* force initialize */ true); + EXPECT_EQ(Orientation::kEast, r.GetOrientation()); + EXPECT_EQ(1, r.GetXPosition()); + EXPECT_EQ(1, r.GetYPosition()); + EXPECT_FALSE(BeepersPresent()); + EXPECT_EQ(0, r.GetNumBeepersInBag()); + EXPECT_EQ(RobotError::kNoError, r.GetError()); + + // Create an error by picking a non-existant beeper. + PickBeeper(); + EXPECT_EQ(RobotError::kCannotPickBeeper, r.GetError()); + EXPECT_EQ(Orientation::kEast, r.GetOrientation()); + EXPECT_EQ(1, r.GetXPosition()); + EXPECT_EQ(1, r.GetYPosition()); + EXPECT_FALSE(BeepersPresent()); + // Didn't pick either. + EXPECT_EQ(0, r.GetNumBeepersInBag()); + + // Now it cannot move or turn and the error state stays as it was before. + Move(); + EXPECT_EQ(RobotError::kCannotPickBeeper, r.GetError()); + EXPECT_EQ(Orientation::kEast, r.GetOrientation()); + EXPECT_EQ(1, r.GetXPosition()); + EXPECT_EQ(1, r.GetYPosition()); + + TurnLeft(); + EXPECT_EQ(RobotError::kCannotPickBeeper, r.GetError()); + EXPECT_EQ(Orientation::kEast, r.GetOrientation()); + EXPECT_EQ(1, r.GetXPosition()); + EXPECT_EQ(1, r.GetYPosition()); + + PutBeeper(); + EXPECT_EQ(RobotError::kCannotPickBeeper, r.GetError()); + EXPECT_FALSE(BeepersPresent()); + EXPECT_EQ(0, r.GetNumBeepersInBag()); +} + +TEST(KarelTest, SavesWorldBmp) { + Robot& r = Robot::GetInstance(/* enable graphics */ false, + /* force initialize */ true); + std::string name = "test_world.bmp"; + std::ifstream stream(name.c_str()); + ASSERT_FALSE(stream.good()); + r.SaveWorldBmp(name); + graphics::Image image; + image.Load(name); + ASSERT_TRUE(image.GetWidth() > 0); + ASSERT_TRUE(image.GetHeight() > 0); + remove(name.c_str()); +} + +TEST(KarelTest, PromptsBetweenActionsWhenSet) { + std::streambuf* original = std::cin.rdbuf(); + std::istringstream stream("c\nc\n"); + std::cin.rdbuf(stream.rdbuf()); + + std::streambuf* original_out = std::cout.rdbuf(); + std::stringstream stream_out; + std::cout.rdbuf(stream_out.rdbuf()); + + Robot& r = Robot::InitializeInstance("worlds/2x1.w", + /* enable graphics */ false, + /* force initialize */ true); + Move(); + TurnLeft(); + TurnLeft(); + EXPECT_EQ(2, r.GetXPosition()); + EXPECT_EQ(Orientation::kWest, r.GetOrientation()); + EXPECT_THAT( + stream_out.str(), + Not(testing::MatchesRegex(".*Enter any character to continue.*Enter any " + "character to continue.*"))); + + EnablePromptBeforeAction(); + Move(); + TurnLeft(); + EXPECT_EQ(1, r.GetXPosition()); + EXPECT_EQ(Orientation::kSouth, r.GetOrientation()); + EXPECT_THAT(stream_out.str(), + testing::MatchesRegex(".*Enter any character to continue.*Enter " + "any character to continue.*")); + + std::cin.rdbuf(original); + std::cout.rdbuf(original_out); +} + +TEST(KarelTest, PromptsBetweenActionsWhenCSVOutputSet) { + std::streambuf* original = std::cin.rdbuf(); + std::istringstream stream("ada\nlovelace\n"); + std::cin.rdbuf(stream.rdbuf()); + + std::streambuf* original_out = std::cout.rdbuf(); + std::stringstream stream_out; + std::cout.rdbuf(stream_out.rdbuf()); + + Robot& r = Robot::InitializeInstance("worlds/2x1.w", + /* enable graphics */ false, + /* force initialize */ true); + Move(); + TurnLeft(); + TurnLeft(); + EXPECT_EQ(2, r.GetXPosition()); + EXPECT_EQ(Orientation::kWest, r.GetOrientation()); + EXPECT_THAT( + stream_out.str(), + Not(testing::MatchesRegex(".*Enter any character to continue.*Enter any " + "character to continue.*"))); + + EnableCSVOutput(); + Move(); + TurnLeft(); + EXPECT_EQ(1, r.GetXPosition()); + EXPECT_EQ(Orientation::kSouth, r.GetOrientation()); + EXPECT_THAT(stream_out.str(), + testing::MatchesRegex(".*Enter any character to continue.*Enter " + "any character to continue.*")); + + std::cin.rdbuf(original); + std::cout.rdbuf(original_out); + remove("karel.csv"); +} + +TEST(KarelTest, GeneratesCSVOutput) { + Robot& r = Robot::InitializeInstance("worlds/2x1.w", + /* enable graphics */ false, + /* force initialize */ true); + std::ifstream stream("karel.csv"); + ASSERT_FALSE(stream.good()); + + EnableCSVOutput(); + // Should immediately write the world to a CSV file. + stream = std::ifstream("karel.csv"); + ASSERT_TRUE(stream.good()); + + // The first line of the CSV is the world with karel in the first cell. + std::string line; + ASSERT_TRUE(std::getline(stream, line)); + ASSERT_TRUE(line.size() > 0); + ASSERT_EQ(line, "\"ke o (1,1)\",,\"o (2,1)\","); + + remove("karel.csv"); +} + +int main(int argc, char** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/karel/src/test/worlds/1x8.w b/karel/src/test/worlds/1x8.w new file mode 100644 index 0000000..052a28a --- /dev/null +++ b/karel/src/test/worlds/1x8.w @@ -0,0 +1,4 @@ +Dimension: (1, 8) +BeeperBag: INFINITY +Karel: (1, 1) East +Speed: 0.75 diff --git a/karel/src/test/worlds/2x1.w b/karel/src/test/worlds/2x1.w new file mode 100644 index 0000000..7a09778 --- /dev/null +++ b/karel/src/test/worlds/2x1.w @@ -0,0 +1,4 @@ +Dimension: (2, 1) +Karel: (1, 1) east +BeeperBag: INFINITE +Speed: 0.75 diff --git a/karel/src/test/worlds/8x1.w b/karel/src/test/worlds/8x1.w new file mode 100644 index 0000000..b3c4412 --- /dev/null +++ b/karel/src/test/worlds/8x1.w @@ -0,0 +1,4 @@ +Dimension: (8, 1) +BeeperBag: INFINITY +Karel: (1, 1) East +Speed: 0.75 diff --git a/karel/src/test/worlds/beepers.w b/karel/src/test/worlds/beepers.w new file mode 100644 index 0000000..f2acc30 --- /dev/null +++ b/karel/src/test/worlds/beepers.w @@ -0,0 +1,8 @@ +Dimension: (8, 8) +Beeper: (2, 1) 1 +Beeper: (3, 1) 2 +Beeper: (3, 2) 3 +Beeper: (4, 4) 4 +BeeperBag: 0 +Karel: (1, 1) East +Speed: 0.75 diff --git a/karel/src/test/worlds/inner_walls.w b/karel/src/test/worlds/inner_walls.w new file mode 100644 index 0000000..64ce4a2 --- /dev/null +++ b/karel/src/test/worlds/inner_walls.w @@ -0,0 +1,8 @@ +Dimension: (8, 8) +Wall: (3, 2) west +Wall: (3, 2) south +Wall: (3, 2) north +Wall: (3, 2) east +BeeperBag: 42 +Karel: (3, 2) East +Speed: 0.75 diff --git a/karel/src/test/worlds/outer_walls.w b/karel/src/test/worlds/outer_walls.w new file mode 100644 index 0000000..f6d408e --- /dev/null +++ b/karel/src/test/worlds/outer_walls.w @@ -0,0 +1,8 @@ +Dimension: (8, 8) +Wall: (3, 5) north +Wall: (4, 6) west +Wall: (3, 7) south +Wall: (2, 6) east +BeeperBag: 3 +Karel: (3, 6) East +Speed: 0.75