Teaching Legacy MFC Code to Obey the TFUI Principles
This paper exerts the preliminary research to assist a legacy MFC project to obey the test-first strategy outlined here:
Wiki:TestFirstUserInterfacesPrinciples
Our goal is to Wiki:TestDrivenDevelopment for Wiki:MicrosoftFoundationClasses rapid and reliable.
We will write a reveal() function to temporarily display windows under test. That allows us to "right-size" the effort
required to ensure that those tests are constraining window features properly. When we know that windows really appear
and behave the way our tests say they do, this provides confidence that we can write new test cases, and pass them,
without excessively displaying the GUI.
MFC has an interesting object model. It eats main() (aka. WinMain()). An MFC Guru might know how to completely
isolate and decouple our tests. Instead, I will use the in vivo testing pattern from the
book Wiki:WorkingEffectivelyWithLegacyCode by Mike Feathers. We will add a test rig that runs inside the MFC
application, just before it displays its solitary dialog box.
To start, download a sample MFC project, such as this CCheckableGroupBox from:
http://www.codeguru.com/Cpp/Cpp/cpp_mfc/article.php/c4149/
You will soon notice the choice of sample project was almost irrelevant. This particular project introduces test issues by presenting
only a single dialog box. A project with a frame window would require a few tweaks.
To insert tests, find the lines in CheckableGroupboxDemoDlg.cpp that activate this project's single dialog box:
CCheckableGroupboxDemoDlg dlg;
m_pMainWnd = &dlg;
int nResponse = dlg.DoModal();
Insert a call to a test rig:
CCheckableGroupboxDemoDlg dlg;
m_pMainWnd = &dlg;
#ifdef DEBUG
runAllTests();
#endif
int nResponse = dlg.DoModal();
Copy the code in the lower part of NanoCppUnit into new files: debug.h and test.h. Bond these with your
project, and add this to the top of CheckableGroupBoxDemo.cpp:
#ifdef DEBUG
# include "test.h"
none_of_your_business:: dostream *none_of_your_business::p_dout;
none_of_your_business::wdostream *none_of_your_business::p_wdout;
TestCase *TestCase::head = NULL;
bool TestCase::stopAtError (true);
bool
runAllTests()
{
TestCase *aCase = TestCase::head;
bool result (true);
for (; result && aCase; aCase = aCase -> next)
result = aCase -> runTests();
if (!result) // TODO report disabled test count
INFORM("Tests failed!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
else
INFORM("All tests passed!");
return result; // pass failure to calling script
}
#endif
Compile and run after each step. Those lines are the minimum "boilerplate" code required to kick off test runs.
Long term, you should naturally refactor these lines out into a proper test module.
Getting them to work is the hard part.
Now add a test case, near the definition of runAllTests(), and inspect its behavior at fault time:
TEST_(TestCase, reveal)
{
CPPUNIT_ASSERT(false);
}
Test, and predict a fault on that line.
The next step is a little odious, and defies the "small steps" guideline.
Our main impediment is the method DoEvents(), which is generally bad style. (It also exposes advanced
dialogs to minor but annoying bugs in their input event handlers.) We need our dialog to use Create() and
DestroyWindow() when in test mode, so add an OnClose() handler to CheckableGroupboxDemoDlg.h and
CheckableGroupboxDemoDlg.cpp:
void CCheckableGroupboxDemoDlg::OnClose()
{
#ifdef DEBUG
PostMessage(WM_QUIT);
#endif
}
BEGIN_MESSAGE_MAP(CCheckableGroupboxDemoDlg, CDialog)
//{{AFX_MSG_MAP(CCheckableGroupboxDemoDlg)
ON_WM_SYSCOMMAND()
ON_WM_PAINT()
ON_WM_QUERYDRAGICON()
ON_WM_CLOSE()
ON_COMMAND(IDOK, OnClose)
ON_COMMAND(IDCANCEL, OnClose)
//}}AFX_MSG_MAP
END_MESSAGE_MAP()
That allows our dialog to break the event loop when you tap <Alt+F4>, or the close buttons.
Now add all this, to complete our reveal function:
void
reveal(CWnd & aWin)
{
aWin.ShowWindow(SW_SHOW);
aWin.SetForegroundWindow();
MSG msg;
for(;;)
{
BOOL bRet = ::GetMessage(&msg, NULL, 0, 0);
if (!bRet && msg.hwnd == aWin.m_hWnd)
break;
::DispatchMessage(&msg);
}
}
TEST_(TestCase, reveal)
{
CCheckableGroupboxDemoDlg dlg;
dlg.Create(IDD_CHECKABLEGROUPBOXDEMO_DIALOG);
// write test code here
reveal(dlg);
dlg.DestroyWindow();
}
MFC flashes the window at Create() time. Tests should run quick and quiet, so remove the WS_VISIBLE bit from the IDD_CHECKABLEGROUPBOXDEMO_DIALOG template in the resource file.
reveal() regulates our event queue, and displays the window under test. If we replaced the comment
"write test code here" with test code, reveal(dlg) would reveal our dialog in its tested state. Comment reveal() out after it serves its purpose, and before integrating.
Note: This reveal() intends to be generic, and to also handle windows designed for modelessness. A shorter revealDlg() could trivially call RunModalLoop(), and that would not require the extra shutdown code.
Next, we clone our test case, pretending we have two tests. This forces us to refactor the common code into a test suite:
struct
TestDemoDlg: TestCase
{
CCheckableGroupboxDemoDlg * dlg;
TestDemoDlg(): dlg(NULL) {}
void
setUp()
{
dlg = new CCheckableGroupboxDemoDlg;
dlg->Create(IDD_CHECKABLEGROUPBOXDEMO_DIALOG);
}
void
tearDown()
{
dlg->DestroyWindow();
delete dlg;
}
};
TEST_(TestDemoDlg, reveal)
{
// test something here
// reveal(dlg);
}
TEST_(TestDemoDlg, testSomethingElse)
{
// test something else here
//reveal(dlg);
}
Great! Now we have two super-lite test cases, each ready to pass messages to the *dlg
object and query back the messages' effects. And we have an optional reveal() method,
ready to display the window under test and ensure its real behavior matches what the tests claim it does.
Those experiences are left as an exercise for the reader. The next thing our test infrastructure needs is
the ability to record pictures of our GUI under test. We'll do this by passing a Metafile context into the OnPaint() handler.
(A few more refactors could automate the unique picture name, "TestDemoDlg_reveal", based on the test case name.)
Our goal is to permit remote review of GUI features, following the Wiki:BroadbandFeedback principle.
TEST_(TestDemoDlg, reveal)
{
// test something here
// preserve the test output for posterity
takePicture(*dlg, "TestDemoDlg_reveal");
// reveal(*dlg);
}
From here down is comeliness-resistant code to support takePicture(). It calls the WM_PRINT message to
save that picture as a bitmap. From there, one could write acceptance tests that pass into
our program a command to run a scripted scenario and collect results. Among these
results, our bitmap could convert with ImageMagick? into a PNG file, and upload to a
web site for rapid review.
PBITMAPINFO
CreateBitmapInfoStruct( HBITMAP hBmp )
{
BITMAP bmp;
PBITMAPINFO pbmi;
WORD cClrBits;
// Retrieve the bitmap's color format, width, and height.
if ( !GetObject( hBmp, sizeof( BITMAP ), & bmp ) )
return NULL;
// Convert the color format to a count of bits.
cClrBits = ( bmp.bmPlanes * bmp.bmBitsPixel );
if ( cClrBits == 1 )
cClrBits = 1;
else if ( cClrBits <= 4 )
cClrBits = 4;
else if ( cClrBits <= 8 )
cClrBits = 8;
else if ( cClrBits <= 16 )
cClrBits = 16;
else if ( cClrBits <= 24 )
cClrBits = 24;
else
cClrBits = 32;
// Allocate memory for the BITMAPINFO structure. (This structure
// contains a BITMAPINFOHEADER structure and an array of RGBQUAD
// data structures.)
SIZE_T needed = sizeof( BITMAPINFOHEADER );
if ( cClrBits != 24 )
needed += sizeof( RGBQUAD ) * ( 1 << cClrBits );
// There is no RGBQUAD array for the 24-bit-per-pixel format.
pbmi = static_cast< PBITMAPINFO >( LocalAlloc( LPTR, needed ) );
// Initialize the fields in the BITMAPINFO structure.
pbmi->bmiHeader.biSize = sizeof( BITMAPINFOHEADER );
pbmi->bmiHeader.biWidth = bmp.bmWidth;
pbmi->bmiHeader.biHeight = bmp.bmHeight;
pbmi->bmiHeader.biPlanes = bmp.bmPlanes;
pbmi->bmiHeader.biBitCount = bmp.bmBitsPixel;
if ( cClrBits < 24 )
pbmi->bmiHeader.biClrUsed = ( 1 << cClrBits );
// If the bitmap is not compressed, set the BI_RGB flag.
pbmi->bmiHeader.biCompression = BI_RGB;
// Compute the number of bytes in the array of color
// indices and store the result in biSizeImage.
// For Windows NT/2000, the width must be DWORD aligned unless
// the bitmap is RLE compressed. This example shows this.
// For Windows 95/98, the width must be WORD aligned unless the
// bitmap is RLE compressed.
pbmi->bmiHeader.biSizeImage =
( ( pbmi->bmiHeader.biWidth * cClrBits + 31 ) & ~31 ) / 8 * pbmi->bmiHeader.biHeight;
// Set biClrImportant to 0, indicating that all of the
// device colors are important.
pbmi->bmiHeader.biClrImportant = 0;
return pbmi;
}
void
CreateBMPFile( LPTSTR pszFile, PBITMAPINFO pbi, HBITMAP hBMP, HDC hDC )
{
PBITMAPINFOHEADER pbih =
reinterpret_cast< PBITMAPINFOHEADER > (pbi);
HGLOBAL storage = GlobalAlloc( GMEM_FIXED, pbih->biSizeImage );
LPBYTE lpBits = static_cast< LPBYTE >( (HGLOBAL)storage );
assert( lpBits );
// Retrieve the color table (RGBQUAD array) and the bits
// (array of palette indices) from the DIB.
BOOL worked = GetDIBits( hDC, hBMP, 0, pbih->biHeight, lpBits, pbi,
DIB_RGB_COLORS );
assert( worked );
// Create the .BMP file.
HANDLE hf =
CreateFile( pszFile, GENERIC_READ | GENERIC_WRITE, 0, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL );
assert( hf != INVALID_HANDLE_VALUE );
BITMAPFILEHEADER hdr = { 0 };
hdr.bfType = 0x4d42; // 0x42 = "B" 0x4d = "M"
// Compute the size of the entire file.
hdr.bfSize = ( sizeof( BITMAPFILEHEADER ) + pbih->biSize +
pbih->biClrUsed * sizeof( RGBQUAD ) + pbih->biSizeImage );
hdr.bfReserved1 = 0;
hdr.bfReserved2 = 0;
// Compute the offset to the array of color indices.
hdr.bfOffBits = sizeof( BITMAPFILEHEADER ) + pbih->biSize +
pbih->biClrUsed * sizeof ( RGBQUAD );
DWORD dwTmp;
DWORD cb = sizeof( BITMAPFILEHEADER );
// Copy the BITMAPFILEHEADER into the .BMP file.
worked = WriteFile( hf, & hdr, cb, & dwTmp, NULL );
assert( worked );
cb = sizeof( BITMAPINFOHEADER ) +
pbih->biClrUsed * sizeof ( RGBQUAD );
// Copy the BITMAPINFOHEADER and RGBQUAD array into the file.
worked = WriteFile( hf, pbih, cb, & dwTmp, NULL );
assert( worked );
// Copy the array of color indices into the .BMP file.
cb = pbih->biSizeImage;
worked = WriteFile( hf, lpBits, cb, &dwTmp, NULL );
assert( worked );
::CloseHandle( hf );
::GlobalFree( storage );
}
void
takePicture(HWND hWnd, CString fileName)
{
// via http://www.fengyuan.com/article/wmprint.html
HDC hDCMem = CreateCompatibleDC(NULL);
RECT rect;
GetWindowRect(hWnd, & rect);
HBITMAP hBitmap = NULL;
{
HDC hDC = GetDC(hWnd);
hBitmap = CreateCompatibleBitmap(hDC, rect.right - rect.left, rect.bottom - rect.top);
ReleaseDC(hWnd, hDC);
}
HGDIOBJ hOld = SelectObject(hDCMem, hBitmap);
LPARAM lParam = PRF_CHILDREN | PRF_CLIENT | PRF_ERASEBKGND | PRF_NONCLIENT | PRF_OWNED;
SendMessage(hWnd, WM_PRINT, (WPARAM) hDCMem, lParam);
PBITMAPINFO pInfo = CreateBitmapInfoStruct( hBitmap );
assert( pInfo );
CreateBMPFile( (fileName+".bmp").GetBuffer(), pInfo, hBitmap, hDCMem );
SelectObject( hDCMem, hOld );
BOOL worked = DeleteDC( hDCMem );
assert( worked );
worked = DeleteObject( hBitmap );
assert( worked );
::LocalFree( pInfo );
}