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
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.
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),MEMORYandHISTORY(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
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
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
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
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)
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
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
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
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)
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
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
-
$concathas an argument limit. A call with seven arguments failed with1000 - Syntax error (Wrong number of arguments). Nesting two$concatcalls fixes it. -
askmessvs.message/error.askmesswithout options shows a Yes/No dialog - odd for an error.message/errorshows 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)