DEV Community

Franz
Franz

Posted on

Building a tested calculator in the Uniface 10 IDE

Uniface is one of those tools you rarely read about, but that still runs a surprising amount of business software. I work with it regularly, and I wanted a small, self-contained project to try out a few things properly: a clean structure, input validation and - the part you almost never see in Uniface examples - automated tests.

The result is a calculator with a modern flat look, about 25 functions (basic arithmetic, powers, roots, trigonometry, logarithms, rounding, memory, history) and a test service with 69 test cases.

Everything here was built with Rocket Uniface 10 Community Edition (10.4), so you can follow along for free.

The architecture in one picture

CALC_FRM (Form)          CALC_SVC (Service)           CALC_TEST_SVC (Service)
 - fields & buttons   --->   - public operations    <---  - exec -> RUN_ALL
 - checks empty fields       - all math                   - 69 test cases
 - shows errors              - validation                 - PASS/FAIL via putmess
 - history                   - returns 0 / -1 + text
Enter fullscreen mode Exit fullscreen mode

The most important decision: the form does not calculate anything. It collects input, calls the service and displays the result. That is what makes the logic testable at all.

Step 1: A modeled entity without a database

A Uniface form needs at least one entity - if you try to compile a form without one, you get:

error: 1017 - Compiling a component without entities is not allowed.
Enter fullscreen mode Exit fullscreen mode

For a calculator there is no table, so I created a modeled entity that is not in the database:

  • Model: CALCULATOR_MDL
  • Entity: CALCULATOR_DMY (Database Behavior: Not in Database)
  • Fields: NUMBER1, NUMBER2, RESULT - data type Numeric

You will see warnings like 1065 - Field ... might be truncated on output to DBMS and 1072 - Scaling ... changed to 8 when compiling. Since the entity is never written to a database, they are harmless.

Step 2: The form

Create a Non-modal Form (CALC_FRM), drag the entity into it and add:

  • the three modeled fields for input and result,
  • non-modeled fields for CALCULATION (last calculation), MEMORY and HISTORY (multi-line edit box),
  • one Command Button per function (BTN_ADD, BTN_SQRT, BTN_SIN, ...).

The button caption is the field's Initial Value (+, √x, sin, ...). The flat look comes entirely from the widget properties, for example for the blue operator buttons:

BACKCOLOR=#2563EB; FORECOLOR=#FFFFFF; BACKCOLORFILL=flat;
BACKCOLORHOVER=#1D4ED8; BACKCOLORSELECT=#1E40AF; FONT=SANSLARGE
TOOLTIPTEXT=Add: Number 1 + Number 2
Enter fullscreen mode Exit fullscreen mode

Grey buttons for functions, indigo for memory, red for "C" - three or four colour groups are enough to make an old-school form look surprisingly current.

Step 3: The logic lives in a service

CALC_SVC is a plain Service component. Every function is a public operation with the same shape:

public operation DIVIDE
params
   numeric pA : IN
   numeric pB : IN
   numeric pResult : OUT
   string pError : OUT
endparams
   pError = ""
   if (pB = 0)
      pError = "Division by 0 is not possible."
      return -1
   endif
   pResult = pA / pB
   return 0
end
Enter fullscreen mode Exit fullscreen mode

The convention is simple: return 0 on success, -1 on error, and put a readable message into pError. The caller never has to know which error happened - it just shows the text.

Unary functions (SQRT, SIN, FACTORIAL, ...) have the same signature without pB. Shared helpers such as the sine/cosine series or the natural logarithm are private entry blocks inside the service, so both LN and LOG use the same code:

public operation LOG
params
   numeric pA : IN
   numeric pResult : OUT
   string pError : OUT
endparams
variables
   numeric vLn
endvariables
   pError = ""
   if (pA <= 0)
      pError = "The logarithm is only defined for numbers greater than 0."
      return -1
   endif
   call NATURAL_LOG(pA, vLn)
   pResult = vLn / 2.302585092994045684
   return 0
end
Enter fullscreen mode Exit fullscreen mode

To keep the project self-contained, I implemented the transcendental functions myself in ProcScript: Taylor series for sine and cosine (after normalising the angle to -180..180 degrees), a series for ln after scaling the argument into 0.5..2, "halve, expand, square back" for e^x, and Newton's method for square roots. It is a nice exercise, and the tests below show whether the approximations are good enough.

Step 4: A button trigger

Each button's detail trigger follows the same pattern:

trigger detail
throws
variables
   numeric vResult
   string vError
endvariables
   call VALIDATE_INPUT
   if ($status < 0)
      return 0
   endif
   if (NUMBER1.CALCULATOR_DMY = "" | NUMBER2.CALCULATOR_DMY = "")
      message/error "Please enter both numbers."
      return 0
   endif
   activate "CALC_SVC".ADD(NUMBER1.CALCULATOR_DMY, NUMBER2.CALCULATOR_DMY, vResult, vError)
   if ($status < 0)
      message/error vError
      return 0
   endif
   RESULT.CALCULATOR_DMY = vResult
   call LOG_CALCULATION($concat(NUMBER1.CALCULATOR_DMY, " + ", NUMBER2.CALCULATOR_DMY, " = ", RESULT.CALCULATOR_DMY))
end
Enter fullscreen mode Exit fullscreen mode

After activate, $status holds the return value of the operation, so the -1 from the service arrives directly in the form.

LOG_CALCULATION is a component entry that rounds the result for display, writes it into CALCULATION and prepends it to HISTORY. In a Uniface multi-line field, %%^ is the line separator:

HISTORY.CALCULATOR_DMY = $concat(vText, "%%^", HISTORY.CALCULATOR_DMY)
Enter fullscreen mode Exit fullscreen mode

Step 5: The input validation trap

This one surprised me. Type 2-1 into a numeric field and tab out - Uniface rejects it and keeps the focus in the field. Fine.

But type 2-1 and click a button directly, and the trigger runs anyway. The field's raw text is still 2-1, while the numeric value used in the calculation is 2. So ln(2-1) happily returned 0.693... - the logarithm of 2.

The fix is an explicit check before every calculation. The service has a VALIDATE_NUMBER operation that walks through the raw string:

while (vPos <= vLen)
   vChar = pValue[vPos:1]
   if ($scan("0123456789", vChar) > 0)
      vDigits = vDigits + 1
   elseif (vChar = ".")
      vDots = vDots + 1
   elseif (vChar != "-" | vPos != 1)
      pError = $concat(pLabel, " is not a valid number. Use digits, one dot as decimal separator and an optional leading minus sign.")
      return -1
   endif
   vPos = vPos + 1
endwhile
Enter fullscreen mode Exit fullscreen mode

The form calls it for both fields in its VALIDATE_INPUT entry. Accepted: 42, -3.5. Rejected: 2-1, 2,5, 1.5.2, -, abc.

Step 6: Tests in ProcScript

You don't need a test framework for this. A test service with an exec operation is enough, because Compile & Test on a service runs exec:

public operation exec
   activate $instancename.RUN_ALL()
   return $status
end
Enter fullscreen mode Exit fullscreen mode

Two small helper entries do the assertions. CHECK_VALUE compares with a tolerance (the series only approximate), CHECK_ERROR expects a negative status:

entry CHECK_VALUE
params
   string pName : IN
   numeric pExpected : IN
   numeric pActual : IN
   numeric pStatus : IN
   numeric pTests : INOUT
   numeric pFailures : INOUT
endparams
variables
   numeric vDiff
endvariables
   pTests = pTests + 1
   vDiff = pActual - pExpected
   if (vDiff < 0)
      vDiff = 0 - vDiff
   endif
   if (pStatus < 0 | vDiff > 0.000001)
      pFailures = pFailures + 1
      putmess $concat($concat("FAIL: ", pName, " (expected ", pExpected), $concat(", got ", pActual, ")"))
   else
      putmess $concat("PASS: ", pName)
   endif
end
Enter fullscreen mode Exit fullscreen mode

A test case is then three lines:

activate "CALC_SVC".SIN(30, vResult, vError)
vStatus = $status
call CHECK_VALUE("SIN 30", 0.5, vResult, vStatus, vTests, vFailures)

activate "CALC_SVC".TAN(90, vResult, vError)
vStatus = $status
call CHECK_ERROR("TAN 90", vStatus, vTests, vFailures)
Enter fullscreen mode Exit fullscreen mode

The suite covers every operation with normal values, edge cases and expected errors - for example ROUND(-2.5, 0) = -3, LOG(0.01) = -2, 10! = 3628800, division by zero, 0 ^ -1, 2 ^ 0.5 (only whole-number exponents are supported), FACTORIAL(2.5) and all the validation strings above.

The putmess output ends up in the session log (log\ide_<pid>.log in your Uniface user folder):

PASS: ADD 2 + 3
PASS: DIVIDE 1 / 0 (error expected)
PASS: SIN 30
...
PASS: VALIDATE 2-1 (error expected)
Calculator: 69 tests, 0 failures
Enter fullscreen mode Exit fullscreen mode

One caveat worth knowing: a CHECK_ERROR test would also pass if the service could not be activated at all. That is acceptable here because dozens of CHECK_VALUE tests would fail in the same situation - but if you write a suite that only checks errors, test the activation separately.

Pitfalls I ran into

  • $concat has an argument limit. A call with seven arguments failed with 1000 - Syntax error (Wrong number of arguments). Nesting two $concat calls fixes it.
  • askmess vs. message/error. askmess without options shows a Yes/No dialog - odd for an error. message/error shows a proper error box with OK.
  • The script editor has two sections. Each object in Write Script has a Declarations and a Script section. If you select all and paste a trigger, it can end up in Declarations. Paste into the Script section.
  • Don't touch the repository while the IDE is running. The Community Edition keeps its repository in a local SQLite file (dbms\usys.db). Editing it with an external tool while the IDE was open made the IDE hang, and it would not start again until I restored a backup. Make every change through the IDE - or at least close it first and keep a copy.

Wrap-up

None of this is exotic - it is the same separation you would use in any other stack: UI in one place, logic in a service with a clear contract, and tests that call the service directly. What I like is how little Uniface needs for it: no framework, no plugins, just a service with an exec operation and putmess.

If you are maintaining Uniface applications, the pattern scales: move logic out of form triggers into services one piece at a time, and put a test service next to each one before you change anything.

How do you test your Uniface code - or do you? I would be curious to hear about other approaches in the comments.

Disclosure: This article was created with the help of AI

Top comments (0)