Zero to Formulas with Qt and TDD


Kevin Ottens

Introduction

Two main goals

  1. Discover the possibilities of Qt and KDE Frameworks
  2. Practice TDD on the application


Approach

  1. Setup the project infrastructure
  2. Discover the tools while creating the GUI
  3. Apply TDD on the application model

Project Setup

This first part will help you discover the base tools used to build a KDE application

Exercise 1: CMake and first source file

  1. Create a main.cpp file containing the main() function, only displaying a debug message using qDebug()
  2. Create a CMakeLists.txt file for this project named spreadsheet
  3. Create the build directory, and compile the project using CMake


Hint: For now, it is in fact easier to work outside of QtCreator

Exercise 2: QApplication and first display

Create a QApplication and an empty QWidget as main window

Main Window Creation

Now, the basics being in place, we’ll build our GUI. This way, you’ll be able to test the classes used to build a standard window.

Exercise 3: Toward a standard window

  1. Add a class named SpreadSheetMainWindow which inherits from KXmlGuiWindow, use it as main window
  2. Put in place the following actions: New, Open, Save, SaveAs, Quit, Cut, Copy, Paste
  3. Add one slot by action in SpreadSheetMainWindow
  4. Make sure the slots are called when the actions are triggered by the user (implement only the Quit behavior, other actions just show a debug message)


Hint: Use the KActionCollection and the method setupGUI() provided by KXmlGuiWindow.

Exercise 4: Spreadsheet interface

  1. Add a QTableWidget as the window central widget (make it a 100 by 100 table)
  2. Add the actions Cut, Copy and Paste in the QTableWidget context menu
  3. Add two QLabels to SpreadSheetMainWindow’s status bar (they will be used to indicate the coordinates of the current cell, and the formula contained)


Important: In SpreadSheetMainWindow, the pointer to the QTableWidget will be of type QTableView*

Basic Features

So far, we have only put building blocks together. Let’s put in place the basic GUI features now. Our first automated unit test will appear.

Exercise 5: Canned data

Populate the table with the following data:

WilliamAdama1947-02-24
SaulTigh1949-03-22
LeeAdama1973-04-03
KaraThrace1980-04-08
LauraRoslin1952-04-28
GaiusBaltar1971-06-04

Exercise 6: Display the location

  1. Write a test for a static QString locationFromIndex(int row, int column) method which converts (0, 0) in A1, (0, 1) in B1, etc.
  2. Implement the method while increasing the amount of data for the test
  3. Implement the necessary to update the status bar depending on the current cell using locationFromIndex()

Exercise 7: Data manipulation

  1. Implement Copy, Cut and Paste for one cell at a time
  2. Extend it for contiguous cell zones (requires to force the behavior of selections in the view), pasting starts at the current cell

Spreadsheet using Model / View

We will now create our own data model for the spreadsheet. Most features will be implemented in the model. The TDD approach will be fully applied.

Exercise 8: Setting up Model / View

  1. Implement a test for a new class SpreadSheetModel inheriting from QAbtractTableModel
  2. Test must verify that at creation the model is an empty matrix of 216 by 216 cells
  3. Implement SpreadSheetModel itself
  4. Replace the QTableWidget with a QTableView, provide it an empty SpreadSheetModel instance


Information: rowCount() and columnCount() must return 0 when the QModelIndex given as parameter is valid (see QAbstractItemModel documentation)

Exercise 9.1: Lines and columns labelling

  1. Write unit tests for static QString rowNameFromIndex(int) in SpreadSheetModel
  2. We verify that we obtain "1" for 0, "2" for 1, "10" for 9, etc.
  3. Implement rowNameFromIndex()
  4. Write unit tests for static QString columnNameFromIndex(int) in SpreadSheetModel
  5. We verify that we obtain "A" for 0, "B" for 1, "Z" for 25, "AA" for 26, "BA" for 52, etc.
  6. Implement columnNameFromIndex()
  7. Reimplement headerData() in SpreadSheeModel and use the newly implemented static methods… hey! the tests first!

Exercise 9.2: First refactoring

Modify locationFromIndex() in SpreadSheetMainWindow to eliminate the code duplication


Information: This latest modification should be done without modifying any test, this is your first refactoring with the unit tests safety net

Make the Spreadsheet Usable

In this section, the questions will only be the unit tests that the model must pass.

Exercise 10: Store text

void testThatTextIsStored()
{
    SpreadSheetModel m;
    QModelIndex index = m.index(21, 0);

    m.setData(index, "A string");
    QCOMPARE(m.data(index).toString(), QString("A string"));

    m.setData(index, "A different string");
    QCOMPARE(m.data(index).toString(), QString("A different string"));

    m.setData(index, "");
    QCOMPARE(m.data(index).toString(), QString(""));
}

Exercise 11: Verify that many cells exist

void testThatManyCellsExist()
{
    SpreadSheetModel m;
    QModelIndex a = m.index(0, 0);
    QModelIndex b = m.index(23, 26);
    QModelIndex c = m.index(699, 900);

    m.setData(a, "One");
    m.setData(b, "Two");
    m.setData(c, "Three");

    QCOMPARE(m.data(a).toString(), QString("One"));
    QCOMPARE(m.data(b).toString(), QString("Two"));
    QCOMPARE(m.data(c).toString(), QString("Three"));

    m.setData(a, "Four");

    QCOMPARE(m.data(a).toString(), QString("Four"));
    QCOMPARE(m.data(b).toString(), QString("Two"));
    QCOMPARE(m.data(c).toString(), QString("Three"));
}

Exercise 12: Store numeric data

void testNumericCells()
{
    SpreadSheetModel m;
    QModelIndex index = m.index(0, 20);

    m.setData(index, "X99"); // String
    QCOMPARE(m.data(index).toString(), QString("X99"));

    m.setData(index, "14"); // Number
    QCOMPARE(m.data(index).toString(), QString("14"));
    QCOMPARE(m.data(index).toInt(), 14);

    m.setData(index, "99 X"); // Whole string must be numeric
    QCOMPARE(m.data(index).toString(), QString("99 X"));
    bool ok;
    m.data(index).toInt(&ok);
    QVERIFY(!ok);

    m.setData(index, " 1234 "); // Blanks ignored
    QCOMPARE(m.data(index).toString(), QString("1234"));
    QCOMPARE(m.data(index).toInt(), 1234);

    m.setData(index, " "); // Just a blank
    QCOMPARE(m.data(index).toString(), QString(" "));
}

Hint: Add the asserts one by one

Exercise 13: Access to the original data for editing

void testAccessToLiteralForEditing()
{
    SpreadSheetModel m;
    QModelIndex index = m.index(0, 20);

    m.setData(index, "Some string");
    QCOMPARE(m.data(index, Qt::EditRole).toString(), QString("Some string"));

    m.setData(index, " 1234 ");
    QCOMPARE(m.data(index, Qt::EditRole).toString(), QString(" 1234 "));

    m.setData(index, "=7");
    QCOMPARE(m.data(index, Qt::EditRole).toString(), QString("=7"));
}

Formula Support

In this section, it is wise to add the asserts of each proposed test one by one or to separate them in several different tests, or also to add tests. It really depends on your confidence level about parsing.

Exercise 14: Formula basics

void testFormulaBasics()
{
    SpreadSheetModel m;
    QModelIndex index = m.index(2, 10);

    m.setData(index, " =7"); // note leading space, not a formula
    QCOMPARE(m.data(index).toString(), QString("=7"));
    QCOMPARE(m.data(index, Qt::EditRole).toString(), QString(" =7"));

    m.setData(index, "=7"); // constant formula
    QCOMPARE(m.data(index).toString(), QString("7"));
    QCOMPARE(m.data(index, Qt::EditRole).toString(), QString("=7"));

    // Adding intermediate tests here to guide you is fine
    // Go ahead!

    m.setData(index, "=(7)"); // parenthesis
    QCOMPARE(m.data(index).toString(), QString("7"));

    m.setData(index, "=(((10)))"); // more parenthesis
    QCOMPARE(m.data(index).toString(), QString("10"));

    m.setData(index, "=2*3*4"); // multiply
    QCOMPARE(m.data(index).toString(), QString("24"));

    m.setData(index, "=12+3+4"); // add
    QCOMPARE(m.data(index).toString(), QString("19"));

    m.setData(index, "=4+3*2"); // precedence
    QCOMPARE(m.data(index).toString(), QString("10"));

    m.setData(index, "=5*(4+3)*(((2+1)))"); // full expression
    QCOMPARE(m.data(index).toString(), QString("105"));
}

Exercise 15: Error management

void testFormulaErrors()
{
    SpreadSheetModel m;
    QModelIndex index = m.index(0, 0);

    m.setData(index, "=5*");
    QCOMPARE(m.data(index).toString(), QString("#Error"));

    m.setData(index, "=((((5))");
    QCOMPARE(m.data(index).toString(), QString("#Error"));
}

Exercise 16: Substract, divide, negate

The client notices that he forgot to ask for the support of the substract, divide and negate operators…

You now have to add them:

  1. Add tests for substract and then implement
  2. Add tests for divide and then implement
  3. Add tests for negate and then implement

References in Formulas

This section will allow us to add the necessary features to really have a usable spreadsheet. Without the support for references, we’d only have a big calculator… Once again, it is wise to add intermediate tests if necessary.

Exercise 17: Verify that a reference works

void testThatReferenceWorks()
{
    SpreadSheetModel m;
    QModelIndex a = m.index(0, 0);
    QModelIndex b = m.index(10, 20);

    m.setData(a, "8");
    m.setData(b, "=A1");

    QCOMPARE(m.data(b).toString(), QString("8"));
}

Exercise 18: Verify that changes propagate

void testThatChangesPropagate()
{
    SpreadSheetModel m;
    QModelIndex a = m.index(0, 0);
    QModelIndex b = m.index(10, 20);

    m.setData(a, "8");
    m.setData(b, "=A1");

    QCOMPARE(m.data(b).toString(), QString("8"));

    m.setData(a, "9");
    QCOMPARE(m.data(b).toString(), QString("9"));
}

Exercise 19: Verify that formulas are recalculated

void testThatFormulasRecalculate()
{
    SpreadSheetModel m;
    QModelIndex a = m.index(0, 0);
    QModelIndex b = m.index(1, 0);
    QModelIndex c = m.index(0, 1);

    m.setData(a, "8");
    m.setData(b, "3");
    m.setData(c, "=A1*(A1-A2)+A2/3");

    QCOMPARE(m.data(c).toString(), QString("41"));

    m.setData(b, "6");

    QCOMPARE(m.data(c).toString(), QString("18"));
}

Exercise 20: Verify that changes propagate on several levels

void testThatDeepChangesPropagate()
{
    SpreadSheetModel m;
    QModelIndex a1 = m.index(0, 0);
    QModelIndex a2 = m.index(1, 0);
    QModelIndex a3 = m.index(2, 0);
    QModelIndex a4 = m.index(3, 0);

    m.setData(a1, "8");
    m.setData(a2, "=A1");
    m.setData(a3, "=A2");
    m.setData(a4, "=A3");

    QCOMPARE(m.data(a4).toString(), QString("8"));

    m.setData(a2, "6");

    QCOMPARE(m.data(a4).toString(), QString("6"));
}

Exercise 21: Verify the use of cells in several formulas

void testThatFormulasWorkWithManyCells()
{
    SpreadSheetModel m;
    QModelIndex a1 = m.index(0, 0);
    QModelIndex a2 = m.index(1, 0);
    QModelIndex a3 = m.index(2, 0);
    QModelIndex a4 = m.index(3, 0);
    QModelIndex b1 = m.index(0, 1);
    QModelIndex b2 = m.index(1, 1);
    QModelIndex b3 = m.index(2, 1);
    QModelIndex b4 = m.index(3, 1);

    m.setData(a1, "10");
    m.setData(a2, "=A1+B1");
    m.setData(a3, "=A2+B2");
    m.setData(a4, "=A3");
    m.setData(b1, "7");
    m.setData(b2, "=A2");
    m.setData(b3, "=A3-A2");
    m.setData(b4, "=A4+B3");

    QCOMPARE(m.data(a4).toString(), QString("34"));
    QCOMPARE(m.data(b4).toString(), QString("51"));
}

Exercise 22: Verify that the circular references give an error

void testCircularReferences()
{
    SpreadSheetModel m;
    QModelIndex a1 = m.index(0, 0);
    QModelIndex a2 = m.index(1, 0);
    QModelIndex a3 = m.index(2, 0);

    m.setData(a1, "=A1");
    m.setData(a2, "=A1");
    m.setData(a3, "=A2");

    QCOMPARE(m.data(a1).toString(), QString("#Error"));

    m.setData(a1, "=A3");

    QCOMPARE(m.data(a1).toString(), QString("#Error"));
}

Finalizing the Application

Exercise 23: GUI cleanup

  • If not done yet, remove the initialization of the table which was implemented in exercise 5
  • If necessary, modify Cut, Copy and Paste so that they recopy formulas and not results
  • Add a test to ensure that the status bar displays the formula of the current cell, not its value
  • Add the code necessary to make the test pass


Hint: Use the parent/child relationship of QObject and its name property

To go further…

Some aspects are ignored in this lab, and it would be necessary to see them to complete the application:

  • saving, for this it’d be required to take a look at QIODevice, KIO and QFileDialog
  • some small display issues tied to the lack of events emitted by the model, then it’d be necessary to take care of the QAbstractItemModel signals like dataChanged()
  • function support in formulas
  • and probably more…

Conclusion

The Proposed Solution


  • Application is 402 lines of code in two classes only
  • Tests are 342 lines of code and cover the whole model as well as parts of the GUI (could cover even more)


A very decent spreadsheet for the given effort!

Lessons Learned


  • Safe progress using baby steps, one constraint at a time
  • TDD ensures we avoid regressions now and in the future
  • First taste of Qt and KDE Frameworks

Hope you enjoyed it



Have fun!